### What's Changed! #### Added Manage various types of toolbars uniformly in one place. * `affine-toolbar-widget` * `ToolbarRegistryExtension` The toolbar currently supports and handles several scenarios: 1. Select blocks: `BlockSelection` 2. Select text: `TextSelection` or `NativeSelection` 3. Hover a link: `affine-link` and `affine-reference` #### Removed Remove redundant toolbar implementations. * `attachment` toolbar * `bookmark` toolbar * `embed` toolbar * `formatting` toolbar * `affine-link` toolbar * `affine-reference` toolbar ### How to migrate? Here is an example that can help us migrate some unrefactored toolbars: Check out the more detailed types of [`ToolbarModuleConfig`](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/blocksuite/affine/shared/src/services/toolbar-service/config.ts). 1. Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/blocksuite/affine/block-bookmark/src/configs/toolbar.ts) ```ts export const builtinToolbarConfig = { actions: [ { id: 'a.preview', content(ctx) { const model = ctx.getCurrentModelBy(BlockSelection, BookmarkBlockModel); if (!model) return null; const { url } = model; return html`<affine-link-preview .url=${url}></affine-link-preview>`; }, }, { id: 'b.conversions', actions: [ { id: 'inline', label: 'Inline view', run(ctx) { }, }, { id: 'card', label: 'Card view', disabled: true, }, { id: 'embed', label: 'Embed view', disabled(ctx) { }, run(ctx) { }, }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'c.style', actions: [ { id: 'horizontal', label: 'Large horizontal style', }, { id: 'list', label: 'Small horizontal style', }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'd.caption', tooltip: 'Caption', icon: CaptionIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'a.clipboard', actions: [ { id: 'copy', label: 'Copy', icon: CopyIcon(), run(ctx) { }, }, { id: 'duplicate', label: 'Duplicate', icon: DuplicateIcon(), run(ctx) { }, }, ], }, { placement: ActionPlacement.More, id: 'b.refresh', label: 'Reload', icon: ResetIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'c.delete', label: 'Delete', icon: DeleteIcon(), variant: 'destructive', run(ctx) { }, }, ], } as const satisfies ToolbarModuleConfig; ``` 2. Add configuration extension to a block spec: [bookmark's spec](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/blocksuite/affine/block-bookmark/src/bookmark-spec.ts) ```ts const flavour = BookmarkBlockSchema.model.flavour; export const BookmarkBlockSpec: ExtensionType[] = [ ..., ToolbarModuleExtension({ id: BlockFlavourIdentifier(flavour), config: builtinToolbarConfig, }), ].flat(); ``` 3. If the bock type already has a toolbar configuration built in, we can customize it in the following ways: Check out the [editor's config](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts#L51C4-L54C8) file. ```ts // Defines a toolbar configuration for the bookmark block type const customBookmarkToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:bookmark'), config: customBookmarkToolbarConfig, }), ``` 4. If we want to extend the global: ```ts // Defines a toolbar configuration const customWildcardToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:*'), config: customWildcardToolbarConfig, }), ``` Currently, only most toolbars in page mode have been refactored. Next is edgeless mode.
465 lines
12 KiB
TypeScript
465 lines
12 KiB
TypeScript
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 {
|
|
nextTick,
|
|
SignalWatcher,
|
|
WithDisposable,
|
|
} from '@blocksuite/global/utils';
|
|
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
|
|
import { computed, signal } from '@preact/signals-core';
|
|
import { css, html, LitElement } from 'lit';
|
|
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();
|
|
this.abortController?.abort();
|
|
};
|
|
|
|
private readonly _onKeydown = (e: KeyboardEvent) => {
|
|
e.stopPropagation();
|
|
if (e.key === 'Enter' && !(e.isComposing || e.shiftKey)) {
|
|
this._onSave();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
this._hide();
|
|
}
|
|
};
|
|
|
|
private readonly _onReset = () => {
|
|
const blockComponent = this._blockComponent;
|
|
|
|
if (!blockComponent) {
|
|
this._hide();
|
|
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._hide();
|
|
};
|
|
|
|
private readonly _onSave = () => {
|
|
const blockComponent = this._blockComponent;
|
|
|
|
if (!blockComponent) {
|
|
this._hide();
|
|
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._hide();
|
|
};
|
|
|
|
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);
|
|
})
|
|
);
|
|
|
|
// Resolves the click event is triggered after the first rendering.
|
|
nextTick()
|
|
.then(() => this.disposables.add(listenClickAway(this, this._hide)))
|
|
.catch(console.error);
|
|
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
|
|
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
|
|
this.disposables.addFromEvent(this, 'cut', stopPropagation);
|
|
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;
|
|
|
|
@property({ attribute: false })
|
|
accessor abortController: AbortController | undefined = undefined;
|
|
}
|
|
|
|
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,
|
|
abortController?: AbortController
|
|
) {
|
|
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;
|
|
embedCardEditModal.abortController = abortController;
|
|
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,
|
|
});
|
|
}
|