refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
This commit is contained in:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 deletions

View File

@@ -0,0 +1,13 @@
import type { ExtensionType } from '@blocksuite/store';
import { BookmarkBlockHtmlAdapterExtension } from './html.js';
import { BookmarkBlockMarkdownAdapterExtension } from './markdown.js';
import { BookmarkBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { BookmarkBlockPlainTextAdapterExtension } from './plain-text.js';
export const BookmarkBlockAdapterExtensions: ExtensionType[] = [
BookmarkBlockHtmlAdapterExtension,
BookmarkBlockMarkdownAdapterExtension,
BookmarkBlockNotionHtmlAdapterExtension,
BookmarkBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,10 @@
import { createEmbedBlockHtmlAdapterMatcher } from '@blocksuite/affine-block-embed';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
export const bookmarkBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(BookmarkBlockSchema.model.flavour);
export const BookmarkBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
bookmarkBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,4 @@
export * from './html.js';
export * from './markdown.js';
export * from './notion-html.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,9 @@
import { createEmbedBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-embed';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
export const bookmarkBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(BookmarkBlockSchema.model.flavour);
export const BookmarkBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(bookmarkBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,71 @@
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
export const bookmarkBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: BookmarkBlockSchema.model.flavour,
toMatch: o => {
return (
HastUtils.isElement(o.node) &&
o.node.tagName === 'figure' &&
!!HastUtils.querySelector(o.node, '.bookmark')
);
},
fromMatch: () => false,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const bookmark = HastUtils.querySelector(o.node, '.bookmark');
if (!bookmark) {
return;
}
const { walkerContext } = context;
const bookmarkURL = bookmark.properties?.href;
const bookmarkTitle = HastUtils.getTextContent(
HastUtils.querySelector(bookmark, '.bookmark-title')
);
const bookmarkDescription = HastUtils.getTextContent(
HastUtils.querySelector(bookmark, '.bookmark-description')
);
const bookmarkIcon = HastUtils.querySelector(
bookmark,
'.bookmark-icon'
);
const bookmarkIconURL =
typeof bookmarkIcon?.properties?.src === 'string'
? bookmarkIcon.properties.src
: '';
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: BookmarkBlockSchema.model.flavour,
props: {
type: 'card',
url: bookmarkURL ?? '',
title: bookmarkTitle,
description: bookmarkDescription,
icon: bookmarkIconURL,
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
},
},
fromBlockSnapshot: {},
};
export const BookmarkBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(bookmarkBlockNotionHtmlAdapterMatcher);

View File

@@ -0,0 +1,9 @@
import { createEmbedBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-embed';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
export const bookmarkBlockPlainTextAdapterMatcher =
createEmbedBlockPlainTextAdapterMatcher(BookmarkBlockSchema.model.flavour);
export const BookmarkBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(bookmarkBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,121 @@
import {
CaptionedBlockComponent,
SelectedStyle,
} from '@blocksuite/affine-components/caption';
import type { BookmarkBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { refreshBookmarkUrlData } from './utils.js';
export const BOOKMARK_MIN_WIDTH = 450;
export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBlockModel> {
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
() => ({
'selected-style': this.selected$.value,
})
);
private _fetchAbortController?: AbortController;
blockDraggable = true;
protected containerStyleMap!: ReturnType<typeof styleMap>;
open = () => {
let link = this.model.props.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
};
refreshData = () => {
refreshBookmarkUrlData(this, this._fetchAbortController?.signal).catch(
console.error
);
};
override connectedCallback() {
super.connectedCallback();
const mode = this.std.get(DocModeProvider).getEditorMode();
const miniWidth = `${BOOKMARK_MIN_WIDTH}px`;
this.containerStyleMap = styleMap({
position: 'relative',
width: '100%',
...(mode === 'edgeless' ? { miniWidth } : {}),
});
this._fetchAbortController = new AbortController();
this.contentEditable = 'false';
if (!this.model.props.description && !this.model.props.title) {
this.refreshData();
}
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'url') {
this.refreshData();
}
})
);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._fetchAbortController?.abort();
}
override renderBlock() {
return html`
<div
draggable="${this.blockDraggable ? 'true' : 'false'}"
class=${classMap({
'affine-bookmark-container': true,
...this.selectedStyle$?.value,
})}
style=${this.containerStyleMap}
>
<bookmark-card
.bookmark=${this}
.loading=${this.loading}
.error=${this.error}
></bookmark-card>
</div>
`;
}
protected override accessor blockContainerStyles: StyleInfo = {
margin: '18px 0',
};
@query('bookmark-card')
accessor bookmarkCard!: HTMLElement;
@property({ attribute: false })
accessor error = false;
@property({ attribute: false })
accessor loading = false;
override accessor selectedStyle = SelectedStyle.Border;
override accessor useCaptionEditor = true;
override accessor useZeroWidth = true;
}
declare global {
interface HTMLElementTagNameMap {
'affine-bookmark': BookmarkBlockComponent;
}
}

View File

@@ -0,0 +1,57 @@
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { toGfxBlockComponent } from '@blocksuite/std';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { BookmarkBlockComponent } from './bookmark-block.js';
export class BookmarkEdgelessBlockComponent extends toGfxBlockComponent(
BookmarkBlockComponent
) {
override selectedStyle$ = null;
override blockDraggable = false;
override getRenderingRect() {
const elementBound = this.model.elementBound;
const style = this.model.props.style$.value;
return {
x: elementBound.x,
y: elementBound.y,
w: EMBED_CARD_WIDTH[style],
h: EMBED_CARD_HEIGHT[style],
zIndex: this.toZIndex(),
};
}
override renderGfxBlock() {
const style = this.model.props.style$.value;
const width = EMBED_CARD_WIDTH[style];
const height = EMBED_CARD_HEIGHT[style];
const bound = this.model.elementBound;
const scaleX = bound.w / width;
const scaleY = bound.h / height;
this.containerStyleMap = styleMap({
width: `100%`,
height: `100%`,
transform: `scale(${scaleX}, ${scaleY})`,
transformOrigin: '0 0',
});
return this.renderPageContent();
}
protected override accessor blockContainerStyles: StyleInfo = {
height: '100%',
};
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-bookmark': BookmarkEdgelessBlockComponent;
}
}

View File

@@ -0,0 +1,22 @@
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { BookmarkBlockAdapterExtensions } from './adapters/extension';
import { BookmarkSlashMenuConfigExtension } from './configs/slash-menu';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
const flavour = BookmarkBlockSchema.model.flavour;
export const BookmarkBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-edgeless-bookmark`
: literal`affine-bookmark`;
}),
BookmarkBlockAdapterExtensions,
createBuiltinToolbarConfigExtension(flavour),
BookmarkSlashMenuConfigExtension,
].flat();

View File

@@ -0,0 +1,2 @@
export { insertBookmarkCommand } from './insert-bookmark.js';
export { insertLinkByQuickSearchCommand } from './insert-link-by-quick-search.js';

View File

@@ -0,0 +1,25 @@
import '@blocksuite/affine-block-embed/effects';
import { insertEmbedCard } from '@blocksuite/affine-block-embed';
import type { EmbedCardStyle } from '@blocksuite/affine-model';
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
import type { Command } from '@blocksuite/std';
export const insertBookmarkCommand: Command<
{ url: string },
{ blockId: string; flavour: string }
> = (ctx, next) => {
const { url, std } = ctx;
const embedOptions = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
let flavour = 'affine:bookmark';
let targetStyle: EmbedCardStyle = 'vertical';
const props: Record<string, unknown> = { url };
if (embedOptions) {
flavour = embedOptions.flavour;
targetStyle = embedOptions.styles[0];
}
const blockId = insertEmbedCard(std, { flavour, targetStyle, props });
if (!blockId) return;
next({ blockId, flavour });
};

View File

@@ -0,0 +1,61 @@
import {
type InsertedLinkType,
insertEmbedIframeWithUrlCommand,
insertEmbedLinkedDocCommand,
type LinkableFlavour,
} from '@blocksuite/affine-block-embed';
import { QuickSearchProvider } from '@blocksuite/affine-shared/services';
import type { Command } from '@blocksuite/std';
import { insertBookmarkCommand } from './insert-bookmark';
export const insertLinkByQuickSearchCommand: Command<
{},
{ insertedLinkType: Promise<InsertedLinkType> }
> = (ctx, next) => {
const { std } = ctx;
const quickSearchService = std.getOptional(QuickSearchProvider);
if (!quickSearchService) {
next();
return;
}
const insertedLinkType: Promise<InsertedLinkType> = quickSearchService
.openQuickSearch()
.then(result => {
if (!result) return null;
// add linked doc
if ('docId' in result) {
std.command.exec(insertEmbedLinkedDocCommand, {
docId: result.docId,
params: result.params,
});
return {
flavour: 'affine:embed-linked-doc',
};
}
// add normal link;
if ('externalUrl' in result) {
// try to insert embed iframe block first
const [success, { flavour }] = std.command
.chain()
.try(chain => [
chain.pipe(insertEmbedIframeWithUrlCommand, {
url: result.externalUrl,
}),
chain.pipe(insertBookmarkCommand, { url: result.externalUrl }),
])
.run();
if (!success || !flavour) return null;
return {
flavour: flavour as LinkableFlavour,
};
}
return null;
});
next({ insertedLinkType });
};

View File

@@ -0,0 +1,159 @@
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import { WebIcon16 } from '@blocksuite/affine-components/icons';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { getHostName } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { OpenInNewIcon } from '@blocksuite/icons/lit';
import { BlockSelection, ShadowlessElement } from '@blocksuite/std';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import type { BookmarkBlockComponent } from '../bookmark-block.js';
import { styles } from '../styles.js';
export class BookmarkCard extends WithDisposable(ShadowlessElement) {
static override styles = styles;
private _handleClick(event: MouseEvent) {
event.stopPropagation();
const model = this.bookmark.model;
if (model.parent?.flavour !== 'affine:surface') {
this._selectBlock();
}
}
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.bookmark.open();
}
private _selectBlock() {
const selectionManager = this.bookmark.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.bookmark.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback(): void {
super.connectedCallback();
this.disposables.add(
this.bookmark.model.propsUpdated.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(
this.bookmark.std
.get(ThemeProvider)
.theme$.subscribe(() => this.requestUpdate())
);
}
override render() {
const { icon, title, url, description, image, style } =
this.bookmark.model.props;
const cardClassMap = classMap({
loading: this.loading,
error: this.error,
[style]: true,
selected: this.bookmark.selected$.value,
});
const domainName = url.match(
/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n]+)/im
)?.[1];
const titleText = this.loading
? 'Loading...'
: !title
? this.error
? (domainName ?? 'Link card')
: ''
: title;
const theme = this.bookmark.std.get(ThemeProvider).theme;
const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const titleIconType =
!icon?.split('.').pop() || icon?.split('.').pop() === 'svg'
? 'svg+xml'
: icon?.split('.').pop();
const titleIcon = this.loading
? LoadingIcon
: icon
? html`<object
type="image/${titleIconType}"
data=${icon}
draggable="false"
>
${WebIcon16}
</object>`
: WebIcon16;
const descriptionText = this.loading
? ''
: !description
? this.error
? 'Failed to retrieve link information.'
: url
: (description ?? '');
const bannerImage =
!this.loading && image
? html`<object type="image/webp" data=${image} draggable="false">
${EmbedCardBannerIcon}
</object>`
: EmbedCardBannerIcon;
return html`
<div
class="affine-bookmark-card ${cardClassMap}"
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-bookmark-content">
<div class="affine-bookmark-content-title">
<div class="affine-bookmark-content-title-icon">${titleIcon}</div>
<div class="affine-bookmark-content-title-text">${titleText}</div>
</div>
<div class="affine-bookmark-content-description">
${descriptionText}
</div>
<div
class="affine-bookmark-content-url-wrapper"
@click=${this.bookmark.open}
>
<div class="affine-bookmark-content-url">
<span>${getHostName(url)}</span>
<div class="affine-bookmark-content-url-icon">
${OpenInNewIcon({ width: '12', height: '12' })}
</div>
</div>
</div>
</div>
<div class="affine-bookmark-banner">${bannerImage}</div>
</div>
`;
}
@property({ attribute: false })
accessor bookmark!: BookmarkBlockComponent;
@property({ attribute: false })
accessor error!: boolean;
@property({ attribute: false })
accessor loading!: boolean;
}
declare global {
interface HTMLElementTagNameMap {
'bookmark-card': BookmarkCard;
}
}

View File

@@ -0,0 +1 @@
export * from './bookmark-card';

View File

@@ -0,0 +1,57 @@
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import {
type SlashMenuConfig,
SlashMenuConfigIdentifier,
} from '@blocksuite/affine-widget-slash-menu';
import { LinkIcon } from '@blocksuite/icons/lit';
import type { ExtensionType } from '@blocksuite/store';
import { LinkTooltip } from './tooltips';
const bookmarkSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'Link',
description: 'Add a bookmark for reference.',
icon: LinkIcon(),
tooltip: {
figure: LinkTooltip,
caption: 'Link',
},
group: '4_Content & Media@2',
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:bookmark'),
action: ({ std, model }) => {
const { host } = std;
const parentModel = host.doc.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
toggleEmbedCardCreateModal(
host,
'Links',
'The added link will be displayed as a card view.',
{ mode: 'page', parentModel, index }
)
.then(() => {
if (model.text?.length === 0) {
model.doc.deleteBlock(model);
}
})
.catch(console.error);
},
},
],
};
export const BookmarkSlashMenuConfigIdentifier = SlashMenuConfigIdentifier(
BookmarkBlockSchema.model.flavour
);
export const BookmarkSlashMenuConfigExtension: ExtensionType = {
setup: di => {
di.addImpl(BookmarkSlashMenuConfigIdentifier, bookmarkSlashMenuConfig);
},
};

View File

@@ -0,0 +1,621 @@
import {
canEmbedAsIframe,
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
} from '@blocksuite/affine-block-embed';
import { reassociateConnectorsCommand } from '@blocksuite/affine-block-surface';
import { toast } from '@blocksuite/affine-components/toast';
import {
BookmarkBlockModel,
BookmarkStyles,
type EmbedCardStyle,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
ActionPlacement,
EmbedIframeService,
EmbedOptionProvider,
type LinkEventType,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier, BlockSelection } from '@blocksuite/std';
import { type ExtensionType, Slice, Text } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import * as Y from 'yjs';
import { BookmarkBlockComponent } from '../bookmark-block';
const trackBaseProps = {
category: 'bookmark',
type: 'card view',
};
const previewAction = {
id: 'a.preview',
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const { url } = model.props;
return html`<affine-link-preview .url=${url}></affine-link-preview>`;
},
} satisfies ToolbarAction;
const captionAction = {
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(BookmarkBlockComponent);
block?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
} satisfies ToolbarAction;
const createOnToggleFn =
(
ctx: ToolbarContext,
name: Extract<
LinkEventType,
| 'OpenedViewSelector'
| 'OpenedCardStyleSelector'
| 'OpenedCardScaleSelector'
>,
control: string
) =>
(e: CustomEvent<boolean>) => {
e.stopPropagation();
const opened = e.detail;
if (!opened) return;
ctx.track(name, {
...trackBaseProps,
control,
});
};
const builtinToolbarConfig = {
actions: [
previewAction,
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { title, caption, url } = model.props;
const { parent } = model;
const index = parent?.children.indexOf(model);
const yText = new Y.Text();
const insert = title || caption || url;
yText.insert(0, insert);
yText.format(0, insert.length, { link: url });
const text = new Text(yText);
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
ctx.store.deleteBlock(model);
// Clears
ctx.reset();
ctx.select('note');
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return true;
const url = model.props.url;
// check if the url can be embedded as iframe block or other embed blocks
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return (
!canEmbedAsIframe(ctx.std, url) && options?.viewType !== 'embed'
);
},
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { caption, url, title, description, style } = model.props;
const { parent } = model;
const index = parent?.children.indexOf(model);
if (!parent) return;
let blockId: string | undefined;
// first try to embed as iframe block
if (canEmbedAsIframe(ctx.std, url)) {
const embedIframeService = ctx.std.get(EmbedIframeService);
blockId = embedIframeService.addEmbedIframeBlock(
{ url, caption, title, description },
parent.id,
index
);
} else {
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (!options) return;
const { flavour, styles } = options;
const newStyle = styles.includes(style)
? style
: styles.find(s => s !== 'vertical' && s !== 'cube');
blockId = ctx.store.addBlock(
flavour,
{
url,
caption,
title,
description,
style: newStyle,
},
parent,
index
);
}
if (!blockId) return;
ctx.store.deleteBlock(model);
// Selects new block
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[1].label);
const onToggle = createOnToggleFn(
ctx,
'OpenedViewSelector',
'switch view'
);
return html`${keyed(
model,
html`<affine-view-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
store.updateBlock(model, { style: action.id });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: action.id,
});
},
})) satisfies ToolbarAction[];
const onToggle = createOnToggleFn(
ctx,
'OpenedCardStyleSelector',
'switch card style'
);
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.style$=${model.props.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
captionAction,
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'b.refresh',
label: 'Reload',
icon: ResetIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(BookmarkBlockComponent);
block?.refreshData();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
const builtinSurfaceToolbarConfig = {
actions: [
previewAction,
{
id: 'b.conversions',
actions: [
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { id: oldId, xywh, parent } = model;
const { url, caption, title, description } = model.props;
let newId: string | undefined;
// first try to embed as iframe block
if (canEmbedAsIframe(ctx.std, url)) {
const embedIframeService = ctx.std.get(EmbedIframeService);
const config = embedIframeService.getConfig(url);
if (!config) {
return;
}
const bound = Bound.deserialize(xywh);
const options = config.options;
const { widthInSurface, heightInSurface } = options ?? {};
bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
bound.h =
heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
newId = ctx.store.addBlock(
'affine:embed-iframe',
{ url, caption, title, description, xywh: bound.serialize() },
parent
);
} else {
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (options?.viewType !== 'embed') return;
const { flavour, styles } = options;
let { style } = model.props;
if (!styles.includes(style)) {
style = styles[0];
}
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[style];
bound.h = EMBED_CARD_HEIGHT[style];
newId = ctx.store.addBlock(
flavour,
{
url,
caption,
title,
description,
style,
xywh: bound.serialize(),
},
parent
);
}
ctx.command.exec(reassociateConnectorsCommand, { oldId, newId });
ctx.store.deleteBlock(model);
// Selects new block
ctx.gfx.selection.set({ editing: false, elements: [newId] });
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
when(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return false;
const { url } = model.props;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return canEmbedAsIframe(ctx.std, url) || options?.viewType === 'embed';
},
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal('Card view');
const onToggle = createOnToggleFn(
ctx,
'OpenedViewSelector',
'switch view'
);
return html`${keyed(
model,
html`<affine-view-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'b.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
].filter(action => BookmarkStyles.includes(action.id as EmbedCardStyle)),
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
const style = action.id as EmbedCardStyle;
const bounds = Bound.deserialize(model.xywh);
bounds.w = EMBED_CARD_WIDTH[style];
bounds.h = EMBED_CARD_HEIGHT[style];
const xywh = bounds.serialize();
store.updateBlock(model, { style, xywh });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: style,
});
},
})) satisfies ToolbarAction[];
const style$ = model.props.style$;
const onToggle = createOnToggleFn(
ctx,
'OpenedCardStyleSelector',
'switch card style'
);
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.style$=${style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
...captionAction,
id: 'c.caption',
},
{
id: 'd.scale',
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const scale$ = computed(() => {
const {
xywh$: { value: xywh },
} = model;
const {
style$: { value: style },
} = model.props;
const bounds = Bound.deserialize(xywh);
const height = EMBED_CARD_HEIGHT[style];
return Math.round(100 * (bounds.h / height));
});
const onSelect = (e: CustomEvent<number>) => {
e.stopPropagation();
const scale = e.detail / 100;
const bounds = Bound.deserialize(model.xywh);
const style = model.props.style;
bounds.w = EMBED_CARD_WIDTH[style] * scale;
bounds.h = EMBED_CARD_HEIGHT[style] * scale;
const xywh = bounds.serialize();
ctx.store.updateBlock(model, { xywh });
ctx.track('SelectedCardScale', {
...trackBaseProps,
control: 'select card scale',
});
};
const onToggle = createOnToggleFn(
ctx,
'OpenedCardScaleSelector',
'switch card scale'
);
const format = (value: number) => `${value}%`;
return html`${keyed(
model,
html`<affine-size-dropdown-menu
@select=${onSelect}
@toggle=${onToggle}
.format=${format}
.size$=${scale$}
></affine-size-dropdown-menu>`
)}`;
},
},
],
when: ctx => ctx.getSurfaceModelsByType(BookmarkBlockModel).length === 1,
} as const satisfies ToolbarModuleConfig;
export const createBuiltinToolbarConfigExtension = (
flavour: string
): ExtensionType[] => {
const name = flavour.split(':').pop();
return [
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier(`affine:surface:${name}`),
config: builtinSurfaceToolbarConfig,
}),
];
};

View File

@@ -0,0 +1,13 @@
import { html } from 'lit';
// prettier-ignore
export const LinkTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1007" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1007)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="63.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="75.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="111.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="123.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="135.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="147.636">other users.&#10;</tspan><tspan x="8" y="183.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="195.636">those changes to their version of the document.</tspan></text>
<text fill="#1E67AF" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="11" letter-spacing="0em"><tspan x="8" y="45.5">Learn about AFFiNE</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,29 @@
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
import { type BlockSnapshot } from '@blocksuite/store';
export class EdgelessClipboardBookmarkConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:bookmark';
override createBlock(bookmark: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, url, caption, description, icon, image, title } =
bookmark.props;
const bookmarkId = this.crud.addBlock(
'affine:bookmark',
{
xywh,
style,
url,
caption,
description,
icon,
image,
title,
},
this.surface.model.id
);
return bookmarkId;
}
}

View File

@@ -0,0 +1,12 @@
import { BookmarkBlockComponent } from './bookmark-block';
import { BookmarkEdgelessBlockComponent } from './bookmark-edgeless-block';
import { BookmarkCard } from './components/bookmark-card';
export function effects() {
customElements.define(
'affine-edgeless-bookmark',
BookmarkEdgelessBlockComponent
);
customElements.define('affine-bookmark', BookmarkBlockComponent);
customElements.define('bookmark-card', BookmarkCard);
}

View File

@@ -0,0 +1,7 @@
export * from './adapters';
export * from './bookmark-block';
export * from './bookmark-spec';
export * from './commands';
export * from './components';
export { BookmarkSlashMenuConfigIdentifier } from './configs/slash-menu';
export * from './edgeless-clipboard-config';

View File

@@ -0,0 +1,285 @@
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
export const styles = css`
bookmark-card {
display: block;
height: 100%;
width: 100%;
}
.affine-bookmark-card {
container: affine-bookmark-card / inline-size;
margin: 0 auto;
box-sizing: border-box;
display: flex;
width: 100%;
border-radius: 8px;
border: 1px solid var(--affine-background-tertiary-color);
opacity: var(--add, 1);
background: var(--affine-background-primary-color);
user-select: none;
}
.affine-bookmark-content {
width: calc(100% - 204px);
height: 100%;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 4px;
padding: 12px;
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-bookmark-content-title {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-bookmark-content-title-icon {
display: flex;
width: 16px;
height: 16px;
justify-content: center;
align-items: center;
}
.affine-bookmark-content-title-icon img,
.affine-bookmark-content-title-icon object,
.affine-bookmark-content-title-icon svg {
width: 16px;
height: 16px;
fill: var(--affine-background-primary-color);
}
.affine-bookmark-content-title-text {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
.affine-bookmark-content-description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
flex-grow: 1;
white-space: normal;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-bookmark-content-url {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
width: max-content;
max-width: 100%;
}
.affine-bookmark-content-url > span {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-all;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-secondary-color);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-bookmark-content-url:hover > span {
color: var(--affine-link-color);
}
.affine-bookmark-content-url:hover {
fill: var(--affine-link-color);
}
.affine-bookmark-content-url-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 20px;
}
.affine-bookmark-content-url-icon {
height: 12px;
width: 12px;
color: ${unsafeCSSVar('iconSecondary')};
}
.affine-bookmark-banner {
margin: 12px 12px 0px 0px;
width: 204px;
max-width: 100%;
height: 102px;
opacity: var(--add, 1);
}
.affine-bookmark-banner img,
.affine-bookmark-banner object,
.affine-bookmark-banner svg {
width: 204px;
max-width: 100%;
height: 102px;
object-fit: cover;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-bookmark-card.loading {
.affine-bookmark-content-title-text {
color: var(--affine-placeholder-color);
}
}
.affine-bookmark-card.error {
.affine-bookmark-content-description {
color: var(--affine-placeholder-color);
}
}
.affine-bookmark-card.selected {
.affine-bookmark-content-url > span {
color: var(--affine-link-color);
}
.affine-bookmark-content-url .affine-bookmark-content-url-icon {
color: var(--affine-link-color);
}
}
.affine-bookmark-card.list {
.affine-bookmark-content {
width: 100%;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.affine-bookmark-content-title {
width: calc(100% - 204px);
}
.affine-bookmark-content-url {
width: 204px;
justify-content: flex-end;
}
.affine-bookmark-content-description {
display: none;
}
.affine-bookmark-banner {
display: none;
}
}
.affine-bookmark-card.vertical {
flex-direction: column-reverse;
height: 100%;
.affine-bookmark-content {
width: 100%;
}
.affine-bookmark-content-description {
-webkit-line-clamp: 6;
max-height: 120px;
}
.affine-bookmark-content-url-wrapper {
max-width: fit-content;
display: flex;
align-items: flex-end;
flex-grow: 1;
cursor: pointer;
}
.affine-bookmark-banner {
width: 340px;
height: 170px;
margin-left: 12px;
}
.affine-bookmark-banner img,
.affine-bookmark-banner object,
.affine-bookmark-banner svg {
width: 340px;
height: 170px;
}
}
.affine-bookmark-card.cube {
.affine-bookmark-content {
width: 100%;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
}
.affine-bookmark-content-title {
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.affine-bookmark-content-title-text {
-webkit-line-clamp: 2;
}
.affine-bookmark-content-description {
display: none;
}
.affine-bookmark-banner {
display: none;
}
}
@container affine-bookmark-card (width < 375px) {
.affine-bookmark-content {
width: 100%;
}
.affine-bookmark-banner {
display: none;
}
}
`;

View File

@@ -0,0 +1,47 @@
import { LinkPreviewerService } from '@blocksuite/affine-shared/services';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import type { BookmarkBlockComponent } from './bookmark-block.js';
export async function refreshBookmarkUrlData(
bookmarkElement: BookmarkBlockComponent,
signal?: AbortSignal
) {
let title = null,
description = null,
icon = null,
image = null;
try {
bookmarkElement.loading = true;
const linkPreviewer = bookmarkElement.doc.get(LinkPreviewerService);
const bookmarkUrlData = await linkPreviewer.query(
bookmarkElement.model.props.url,
signal
);
title = bookmarkUrlData.title ?? null;
description = bookmarkUrlData.description ?? null;
icon = bookmarkUrlData.icon ?? null;
image = bookmarkUrlData.image ?? null;
if (!title && !description && !icon && !image) {
bookmarkElement.error = true;
}
if (signal?.aborted) return;
bookmarkElement.doc.updateBlock(bookmarkElement.model, {
title,
description,
icon,
image,
});
} catch (error) {
if (signal?.aborted || isAbortError(error)) return;
throw error;
} finally {
bookmarkElement.loading = false;
}
}