refactor(editor): move embed-card-modal to components (#10037)

This commit is contained in:
fundon
2025-02-10 10:56:13 +00:00
parent 397887e3b5
commit d03744688b
24 changed files with 189 additions and 153 deletions

View File

@@ -0,0 +1,105 @@
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { embedCardModalStyles } from './styles.js';
export class EmbedCardEditCaptionEditModal extends WithDisposable(
ShadowlessElement
) {
static override styles = embedCardModalStyles;
private get _doc() {
return this.block.doc;
}
private get _model() {
return this.block.model as BlockModel<{ caption: string }>;
}
private _onKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
this._onSave();
}
if (e.key === 'Escape') {
this.remove();
}
}
private _onSave() {
const caption = this.captionInput.value;
this._doc.updateBlock(this._model, {
caption,
});
this.remove();
}
override connectedCallback() {
super.connectedCallback();
this.updateComplete
.then(() => {
this.captionInput.focus();
})
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
}
override render() {
return html`
<div class="embed-card-modal">
<div class="embed-card-modal-mask" @click=${() => this.remove()}></div>
<div class="embed-card-modal-wrapper">
<div class="embed-card-modal-row">
<label for="card-title">Caption</label>
<textarea
class="embed-card-modal-input caption"
placeholder="Write a caption..."
.value=${this._model.caption ?? ''}
></textarea>
</div>
<div class="embed-card-modal-row">
<button
class=${classMap({
'embed-card-modal-button': true,
save: true,
})}
@click=${() => this._onSave()}
>
Save
</button>
</div>
</div>
</div>
`;
}
@property({ attribute: false })
accessor block!: BlockComponent;
@query('.embed-card-modal-input.caption')
accessor captionInput!: HTMLTextAreaElement;
}
export function toggleEmbedCardCaptionEditModal(block: BlockComponent) {
const host = block.host;
host.selection.clear();
const embedCardEditCaptionEditModal = new EmbedCardEditCaptionEditModal();
embedCardEditCaptionEditModal.block = block;
document.body.append(embedCardEditCaptionEditModal);
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-caption-edit-modal': EmbedCardEditCaptionEditModal;
}
}

View File

@@ -0,0 +1,206 @@
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import { ShadowlessElement } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { toast } from '../toast';
import { embedCardModalStyles } from './styles.js';
export class EmbedCardCreateModal extends WithDisposable(ShadowlessElement) {
static override styles = embedCardModalStyles;
private readonly _onCancel = () => {
this.remove();
};
private readonly _onConfirm = () => {
const url = this.input.value;
if (!isValidUrl(url)) {
toast(this.host, 'Invalid link');
return;
}
const embedOptions = this.host.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
const { mode } = this.createOptions;
if (mode === 'page') {
const { parentModel, index } = this.createOptions;
let flavour = 'affine:bookmark';
if (embedOptions) {
flavour = embedOptions.flavour;
}
this.host.doc.addBlock(
flavour as never,
{
url,
},
parentModel,
index
);
} else if (mode === 'edgeless') {
const gfx = this.host.std.get(GfxControllerIdentifier);
const surfaceModel = gfx.surface;
if (!surfaceModel) {
return;
}
this.createOptions.onSave(url);
gfx.tool.setTool(
// @ts-expect-error FIXME: resolve after gfx tool refactor
'default'
);
}
this.onConfirm();
this.remove();
};
private readonly _onDocumentKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
this._onConfirm();
}
if (e.key === 'Escape') {
this.remove();
}
};
private _handleInput(e: InputEvent) {
const target = e.target as HTMLInputElement;
this._linkInputValue = target.value;
}
override connectedCallback() {
super.connectedCallback();
this.updateComplete
.then(() => {
requestAnimationFrame(() => {
this.input.focus();
});
})
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', this._onDocumentKeydown);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
}
override render() {
return html`<div class="embed-card-modal">
<div class="embed-card-modal-mask" @click=${this._onCancel}></div>
<div class="embed-card-modal-wrapper">
<div class="embed-card-modal-row">
<div class="embed-card-modal-title">${this.titleText}</div>
</div>
<div class="embed-card-modal-row">
<div class="embed-card-modal-description">
${this.descriptionText}
</div>
</div>
<div class="embed-card-modal-row">
<input
class="embed-card-modal-input link"
id="card-description"
type="text"
placeholder="Input in https://..."
value=${this._linkInputValue}
@input=${this._handleInput}
/>
</div>
<div class="embed-card-modal-row">
<button
class=${classMap({
'embed-card-modal-button': true,
save: true,
})}
?disabled=${!isValidUrl(this._linkInputValue)}
@click=${this._onConfirm}
>
Confirm
</button>
</div>
</div>
</div>`;
}
@state()
private accessor _linkInputValue = '';
@property({ attribute: false })
accessor createOptions!:
| {
mode: 'page';
parentModel: BlockModel | string;
index?: number;
}
| {
mode: 'edgeless';
onSave: (url: string) => void;
};
@property({ attribute: false })
accessor descriptionText!: string;
@property({ attribute: false })
accessor host!: EditorHost;
@query('input')
accessor input!: HTMLInputElement;
@property({ attribute: false })
accessor onConfirm!: () => void;
@property({ attribute: false })
accessor titleText!: string;
}
export async function toggleEmbedCardCreateModal(
host: EditorHost,
titleText: string,
descriptionText: string,
createOptions:
| {
mode: 'page';
parentModel: BlockModel | string;
index?: number;
}
| {
mode: 'edgeless';
onSave: (url: string) => void;
}
): Promise<void> {
host.selection.clear();
const embedCardCreateModal = new EmbedCardCreateModal();
embedCardCreateModal.host = host;
embedCardCreateModal.titleText = titleText;
embedCardCreateModal.descriptionText = descriptionText;
embedCardCreateModal.createOptions = createOptions;
document.body.append(embedCardCreateModal);
return new Promise(resolve => {
embedCardCreateModal.onConfirm = () => resolve();
});
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-create-modal': EmbedCardCreateModal;
}
}

View File

@@ -0,0 +1,451 @@
import type { AliasInfo, LinkableEmbedModel } from '@blocksuite/affine-model';
import {
EmbedLinkedDocModel,
EmbedSyncedDocModel,
isInternalEmbedModel,
} from '@blocksuite/affine-model';
import {
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { FONT_SM, FONT_XS } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
listenClickAway,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import type {
BlockComponent,
BlockStdScope,
EditorHost,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
import { computed, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { live } from 'lit/directives/live.js';
import { toast } from '../toast';
export class EmbedCardEditModal extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
position: absolute;
top: 0;
left: 0;
z-index: var(--affine-z-index-popover);
animation: affine-popover-fade-in 0.2s ease;
}
@keyframes affine-popover-fade-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.embed-card-modal-wrapper {
display: flex;
padding: 12px;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
width: 421px;
color: var(--affine-icon-color);
box-shadow: var(--affine-overlay-shadow);
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
border-radius: 4px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
}
.row .input {
display: flex;
padding: 4px 10px;
width: 100%;
min-width: 100%;
box-sizing: border-box;
border-radius: 4px;
user-select: none;
background: transparent;
border: 1px solid ${unsafeCSSVarV2('input/border/default')};
color: var(--affine-text-primary-color);
${FONT_SM};
}
.input::placeholder {
color: var(--affine-placeholder-color);
}
.input:focus {
border-color: ${unsafeCSSVarV2('input/border/active')};
outline: none;
}
textarea.input {
min-height: 80px;
resize: none;
}
.row.actions {
justify-content: flex-end;
}
.row.actions .button {
display: flex;
padding: 4px 12px;
align-items: center;
gap: 4px;
border-radius: 4px;
border: 1px solid ${unsafeCSSVarV2('button/innerBlackBorder')};
background: ${unsafeCSSVarV2('button/secondary')};
${FONT_XS};
color: ${unsafeCSSVarV2('text/primary')};
}
.row.actions .button[disabled],
.row.actions .button:disabled {
pointer-events: none;
color: ${unsafeCSSVarV2('text/disable')};
}
.row.actions .button.save {
color: ${unsafeCSSVarV2('button/pureWhiteText')};
background: ${unsafeCSSVarV2('button/primary')};
}
.row.actions .button[disabled].save,
.row.actions .button:disabled.save {
opacity: 0.5;
}
`;
private _blockComponent: BlockComponent | null = null;
private readonly _hide = () => {
this.remove();
};
private readonly _onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !(e.isComposing || e.shiftKey)) {
this._onSave();
}
if (e.key === 'Escape') {
e.preventDefault();
this.remove();
}
};
private readonly _onReset = () => {
const blockComponent = this._blockComponent;
if (!blockComponent) {
this.remove();
return;
}
const std = blockComponent.std;
this.model.doc.updateBlock(this.model, { title: null, description: null });
this.onReset?.(std, blockComponent);
blockComponent.requestUpdate();
track(std, this.model, this.viewType, 'ResetedAlias', { control: 'reset' });
this.remove();
};
private readonly _onSave = () => {
const blockComponent = this._blockComponent;
if (!blockComponent) {
this.remove();
return;
}
const title = this.title$.value.trim();
if (title.length === 0) {
toast(this.host, 'Title can not be empty');
return;
}
const std = blockComponent.std;
const description = this.description$.value.trim();
const props: AliasInfo = { title };
if (description) props.description = description;
this.onSave?.(std, blockComponent, props);
track(std, this.model, this.viewType, 'SavedAlias', { control: 'save' });
this.remove();
};
private readonly _updateDescription = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
this.description$.value = target.value;
};
private readonly _updateTitle = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.title$.value = target.value;
};
get isEmbedLinkedDocModel() {
return this.model instanceof EmbedLinkedDocModel;
}
get isEmbedSyncedDocModel() {
return this.model instanceof EmbedSyncedDocModel;
}
get isInternalEmbedModel() {
return isInternalEmbedModel(this.model);
}
get modelType(): 'linked' | 'synced' | null {
if (this.isEmbedLinkedDocModel) return 'linked';
if (this.isEmbedSyncedDocModel) return 'synced';
return null;
}
get placeholders() {
if (this.isInternalEmbedModel) {
return {
title: 'Add title alias',
description:
'Add description alias (empty to inherit document content)',
};
}
return {
title: 'Write a title',
description: 'Write a description...',
};
}
private _updateInfo() {
const title = this.model.title || this.originalDocInfo?.title || '';
const description =
this.model.description || this.originalDocInfo?.description || '';
this.title$.value = title;
this.description$.value = description;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(this.host.slots.unmounted.on(this._hide));
this._updateInfo();
}
override firstUpdated() {
const blockComponent = this.host.std.view.getBlock(this.model.id);
if (!blockComponent) return;
this._blockComponent = blockComponent;
this.disposables.add(
autoUpdate(blockComponent, this, () => {
computePosition(blockComponent, this, {
placement: 'top-start',
middleware: [flip(), offset(8)],
})
.then(({ x, y }) => {
this.style.left = `${x}px`;
this.style.top = `${y}px`;
})
.catch(console.error);
})
);
this.disposables.add(listenClickAway(this, this._hide));
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.titleInput.focus();
this.titleInput.select();
}
override render() {
return html`
<div class="embed-card-modal-wrapper">
<div class="row">
<input
class="input title"
type="text"
placeholder=${this.placeholders.title}
.value=${live(this.title$.value)}
@input=${this._updateTitle}
/>
</div>
<div class="row">
<textarea
class="input description"
maxlength="500"
placeholder=${this.placeholders.description}
.value=${live(this.description$.value)}
@input=${this._updateDescription}
></textarea>
</div>
<div class="row actions">
${choose(this.modelType, [
[
'linked',
() => html`
<button
class=${classMap({
button: true,
reset: true,
})}
.disabled=${this.resetButtonDisabled$.value}
@click=${this._onReset}
>
Reset
</button>
`,
],
[
'synced',
() => html`
<button
class=${classMap({
button: true,
cancel: true,
})}
@click=${this._hide}
>
Cancel
</button>
`,
],
])}
<button
class=${classMap({
button: true,
save: true,
})}
.disabled=${this.saveButtonDisabled$.value}
@click=${this._onSave}
>
Save
</button>
</div>
</div>
`;
}
accessor description$ = signal<string>('');
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor model!: LinkableEmbedModel;
@property({ attribute: false })
accessor originalDocInfo: AliasInfo | undefined = undefined;
@property({ attribute: false })
accessor onReset:
| ((std: BlockStdScope, component: BlockComponent) => void)
| undefined = undefined;
@property({ attribute: false })
accessor onSave:
| ((
std: BlockStdScope,
component: BlockComponent,
props: AliasInfo
) => void)
| undefined = undefined;
accessor resetButtonDisabled$ = computed<boolean>(
() =>
!(
Boolean(this.model.title$.value?.length) ||
Boolean(this.model.description$.value?.length)
)
);
accessor saveButtonDisabled$ = computed<boolean>(
() => this.title$.value.trim().length === 0
);
accessor title$ = signal<string>('');
@query('.input.title')
accessor titleInput!: HTMLInputElement;
@property({ attribute: false })
accessor viewType!: string;
}
export function toggleEmbedCardEditModal(
host: EditorHost,
embedCardModel: LinkableEmbedModel,
viewType: string,
originalDocInfo?: AliasInfo,
onReset?: (std: BlockStdScope, component: BlockComponent) => void,
onSave?: (
std: BlockStdScope,
component: BlockComponent,
props: AliasInfo
) => void
) {
document.body.querySelector('embed-card-edit-modal')?.remove();
const embedCardEditModal = new EmbedCardEditModal();
embedCardEditModal.model = embedCardModel;
embedCardEditModal.host = host;
embedCardEditModal.viewType = viewType;
embedCardEditModal.originalDocInfo = originalDocInfo;
embedCardEditModal.onReset = onReset;
embedCardEditModal.onSave = onSave;
document.body.append(embedCardEditModal);
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-edit-modal': EmbedCardEditModal;
}
}
function track(
std: BlockStdScope,
model: LinkableEmbedModel,
viewType: string,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'embed card edit popup',
type: `${viewType} view`,
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
...props,
});
}

View File

@@ -0,0 +1,16 @@
import { EmbedCardEditCaptionEditModal } from './embed-card-caption-edit-modal';
import { EmbedCardCreateModal } from './embed-card-create-modal';
import { EmbedCardEditModal } from './embed-card-edit-modal';
export * from './embed-card-caption-edit-modal';
export * from './embed-card-create-modal';
export * from './embed-card-edit-modal';
export function effects() {
customElements.define(
'embed-card-caption-edit-modal',
EmbedCardEditCaptionEditModal
);
customElements.define('embed-card-create-modal', EmbedCardCreateModal);
customElements.define('embed-card-edit-modal', EmbedCardEditModal);
}

View File

@@ -0,0 +1,120 @@
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
export const embedCardModalStyles = css`
.embed-card-modal-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
z-index: 1;
}
.embed-card-modal-wrapper {
${PANEL_BASE};
flex-direction: column;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
z-index: 2;
width: 305px;
height: max-content;
padding: 12px;
gap: 12px;
border-radius: 8px;
font-size: var(--affine-font-xs);
line-height: 20px;
}
.embed-card-modal-row {
display: flex;
flex-direction: column;
align-self: stretch;
}
.embed-card-modal-row label {
padding: 0px 2px;
color: var(--affine-text-secondary-color);
font-weight: 600;
}
.embed-card-modal-input {
display: flex;
padding-left: 10px;
padding-right: 10px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background: var(--affine-white-10);
color: var(--affine-text-primary-color);
${FONT_XS};
}
input.embed-card-modal-input {
padding-top: 4px;
padding-bottom: 4px;
}
textarea.embed-card-modal-input {
padding-top: 6px;
padding-bottom: 6px;
min-width: 100%;
max-width: 100%;
}
.embed-card-modal-input:focus {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
outline: none;
}
.embed-card-modal-input::placeholder {
color: var(--affine-placeholder-color);
}
.embed-card-modal-row:has(.embed-card-modal-button) {
flex-direction: row;
gap: 4px;
justify-content: flex-end;
}
.embed-card-modal-row:has(.embed-card-modal-button.reset) {
justify-content: space-between;
}
.embed-card-modal-button {
padding: 4px 18px;
border-radius: 8px;
box-sizing: border-box;
}
.embed-card-modal-button.save {
border: 1px solid var(--affine-black-10);
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.embed-card-modal-button[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
background: transparent;
}
.embed-card-modal-button.reset {
padding: 4px 0;
border: none;
background: transparent;
text-decoration: underline;
color: var(--affine-secondary-color);
user-select: none;
}
.embed-card-modal-title {
font-size: 18px;
font-weight: 600;
line-height: 26px;
user-select: none;
}
.embed-card-modal-description {
font-size: 15px;
font-weight: 500;
line-height: 24px;
user-select: none;
}
`;