import { toast } from '@blocksuite/affine-components/toast';
import {
ActionPlacement,
EmbedIframeService,
EmbedOptionProvider,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
CopyIcon,
DeleteIcon,
EditIcon,
UnlinkIcon,
} from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
import { signal } from '@preact/signals-core';
import { html } from 'lit-html';
import { keyed } from 'lit-html/directives/keyed.js';
import { AffineLink } from '../affine-link';
import { toggleLinkPopup } from '../link-popup/toggle-link-popup';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'link',
type: 'inline view',
};
export const builtinInlineLinkToolbarConfig = {
actions: [
{
id: 'a.preview',
content(cx) {
const target = cx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const { link } = target;
if (!link) return null;
return html``;
},
},
{
id: 'b.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { link } = target;
if (!link) return;
// Clears
ctx.reset();
navigator.clipboard.writeText(link).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
...trackBaseProps,
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit',
icon: EditIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
const abortController = new AbortController();
const popover = toggleLinkPopup(
ctx.std,
'edit',
inlineEditor,
selfInlineRange,
abortController
);
abortController.signal.onabort = () => popover.remove();
ctx.track('OpenedAliasPopup', {
...trackBaseProps,
control: 'edit',
});
},
},
],
},
{
id: 'c.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
disabled: true,
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const url = target.link;
if (!url) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const title = inlineEditor.yTextString.slice(
selfInlineRange.index,
selfInlineRange.index + selfInlineRange.length
);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
const flavour =
options?.viewType === 'card'
? options.flavour
: 'affine:bookmark';
const index = parent.children.indexOf(model);
const props = {
url,
title: title === url ? '' : title,
};
const blockId = ctx.store.addBlock(
flavour,
props,
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
const url = target.link;
if (!url) return false;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return false;
// check if the url can be embedded as iframe block
const embedIframeService = ctx.std.get(EmbedIframeService);
const canEmbedAsIframe = embedIframeService.canEmbed(url);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return canEmbedAsIframe || options?.viewType === 'embed';
},
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const url = target.link;
if (!url) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const index = parent.children.indexOf(model);
const props = { url };
let blockId: string | undefined;
const embedIframeService = ctx.std.get(EmbedIframeService);
const embedOptions = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (embedOptions?.viewType === 'embed') {
const flavour = embedOptions.flavour;
blockId = ctx.store.addBlock(flavour, props, parent, index + 1);
} else if (embedIframeService.canEmbed(url)) {
blockId = embedIframeService.addEmbedIframeBlock(
props,
parent.id,
index + 1
);
}
if (!blockId) return;
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[0].label);
const onToggle = (e: CustomEvent) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
target,
html``
)}`;
},
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
if (ctx.flags.isNative()) return false;
if (
target.block.closest('affine-database') ||
target.block.closest('affine-table')
)
return false;
if (!target.link.startsWith('http')) return false;
const { model } = target.block;
const parent = model.parent;
if (!parent) return false;
const schema = ctx.store.schema;
const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark');
if (!bookmarkSchema) return false;
const parentSchema = schema.flavourSchemaMap.get(parent.flavour);
if (!parentSchema) return false;
try {
schema.validateSchema(bookmarkSchema, parentSchema);
} catch {
return false;
}
return true;
},
} satisfies ToolbarActionGroup,
{
placement: ActionPlacement.More,
id: 'b.remove-link',
label: 'Remove link',
icon: UnlinkIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.formatText(selfInlineRange, { link: null });
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.deleteText(selfInlineRange);
},
},
],
} as const satisfies ToolbarModuleConfig;