feat(editor): add embed doc block extension (#12090)

Closes: BS-3393

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced a new "Embed Doc" block, enabling embedding and rendering of linked and synced documents within cards, including support for banners and note previews.
  - Added new toolbar and quick search options for inserting embedded linked and synced documents.

- **Improvements**
  - Updated dependencies and internal references to support the new embed doc functionality across related blocks and components.
  - Enhanced support for edgeless environments with new clipboard and configuration options for embedded docs.

- **Refactor**
  - Streamlined and reorganized embed-related code, moving linked and synced doc logic into a dedicated embed doc module.
  - Removed obsolete adapter and utility files to simplify maintenance.

- **Chores**
  - Updated project and TypeScript configuration files to include the new embed doc module in builds and references.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saul-Mirone
2025-04-30 09:16:17 +00:00
parent 4660b41d20
commit 04531508cb
77 changed files with 496 additions and 586 deletions

View File

@@ -0,0 +1,213 @@
import { getNotesFromDoc } from '@blocksuite/affine-block-embed';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine-ext-loader';
import {
ImageBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { BlockStdScope } from '@blocksuite/std';
import type { BlockModel, Query } from '@blocksuite/store';
import { render, type TemplateResult } from 'lit';
import type { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block';
import type { EmbedSyncedDocCard } from '../embed-synced-doc-block/components/embed-synced-doc-card';
export function renderLinkedDocInCard(
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
) {
const linkedDoc = card.linkedDoc;
if (!linkedDoc) {
console.error(
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
);
return;
}
// eslint-disable-next-line sonarjs/no-collapsible-if
if ('bannerContainer' in card) {
if (card.editorMode === 'page') {
renderPageAsBanner(card).catch(e => {
console.error(e);
card.isError = true;
});
}
}
renderNoteContent(card).catch(e => {
console.error(e);
card.isError = true;
});
}
async function renderPageAsBanner(card: EmbedSyncedDocCard) {
const linkedDoc = card.linkedDoc;
if (!linkedDoc) {
console.error(
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
);
return;
}
const notes = getNotesFromDoc(linkedDoc);
if (!notes) {
card.isBannerEmpty = true;
return;
}
const target = notes.flatMap(note =>
note.children.filter(child => matchModels(child, [ImageBlockModel]))
)[0];
if (target) {
await renderImageAsBanner(card, target);
return;
}
card.isBannerEmpty = true;
}
async function renderImageAsBanner(
card: EmbedSyncedDocCard,
image: BlockModel
) {
const sourceId = (image as ImageBlockModel).props.sourceId;
if (!sourceId) return;
const storage = card.linkedDoc?.blobSync;
if (!storage) return;
const blob = await storage.get(sourceId);
if (!blob) return;
const url = URL.createObjectURL(blob);
const $img = document.createElement('img');
$img.src = url;
await addCover(card, $img);
card.isBannerEmpty = false;
}
async function addCover(
card: EmbedSyncedDocCard,
cover: HTMLElement | TemplateResult<1>
) {
const coverContainer = await card.bannerContainer;
if (!coverContainer) return;
while (coverContainer.firstChild) {
coverContainer.firstChild.remove();
}
if (cover instanceof HTMLElement) {
coverContainer.append(cover);
} else {
render(cover, coverContainer);
}
}
async function renderNoteContent(
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
) {
card.isNoteContentEmpty = true;
const doc = card.linkedDoc;
if (!doc) {
console.error(
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
);
return;
}
const notes = getNotesFromDoc(doc);
if (!notes) {
return;
}
const cardStyle = card.model.props.style;
const isHorizontal = cardStyle === 'horizontal';
const allowFlavours = isHorizontal ? [] : [ImageBlockModel];
const noteChildren = notes.flatMap(note =>
note.children.filter(model => {
if (matchModels(model, allowFlavours)) {
return true;
}
return filterTextModel(model);
})
);
if (!noteChildren.length) {
return;
}
card.isNoteContentEmpty = false;
const noteContainer = await card.noteContainer;
if (!noteContainer) {
return;
}
while (noteContainer.firstChild) {
noteContainer.firstChild.remove();
}
const noteBlocksContainer = document.createElement('div');
noteBlocksContainer.classList.add('affine-embed-doc-content-note-blocks');
noteBlocksContainer.contentEditable = 'false';
noteContainer.append(noteBlocksContainer);
if (isHorizontal) {
// When the card is horizontal, we only render the first block
noteChildren.splice(1);
} else {
// Before rendering, we can not know the height of each block
// But we can limit the number of blocks to render simply by the height of the card
const cardHeight = EMBED_CARD_HEIGHT[cardStyle];
const minSingleBlockHeight = 20;
const maxBlockCount = Math.floor(cardHeight / minSingleBlockHeight);
if (noteChildren.length > maxBlockCount) {
noteChildren.splice(maxBlockCount);
}
}
const childIds = noteChildren.map(child => child.id);
const ids: string[] = [];
childIds.forEach(block => {
let parent: string | null = block;
while (parent && !ids.includes(parent)) {
ids.push(parent);
parent = doc.getParent(parent)?.id ?? null;
}
});
const query: Query = {
mode: 'strict',
match: ids.map(id => ({ id, viewType: 'display' })),
};
const previewDoc = doc.doc.getStore({ query });
const std = card.host.std;
const previewSpec = std
.get(ViewExtensionManagerIdentifier)
.get('preview-page');
const previewStd = new BlockStdScope({
store: previewDoc,
extensions: previewSpec,
});
const previewTemplate = previewStd.render();
const fragment = document.createDocumentFragment();
render(previewTemplate, fragment);
noteBlocksContainer.append(fragment);
const contentEditableElements = noteBlocksContainer.querySelectorAll(
'[contenteditable="true"]'
);
contentEditableElements.forEach(element => {
(element as HTMLElement).contentEditable = 'false';
});
}
function filterTextModel(model: BlockModel) {
if (matchModels(model, [ParagraphBlockModel, ListBlockModel])) {
return !!model.text?.toString().length;
}
return false;
}

View File

@@ -0,0 +1,37 @@
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block';
import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block';
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block';
import { EmbedSyncedDocCard } from './embed-synced-doc-block/components/embed-synced-doc-card';
import { EmbedEdgelessSyncedDocBlockComponent } from './embed-synced-doc-block/embed-edgeless-synced-doc-block';
export function effects() {
customElements.define('affine-embed-synced-doc-card', EmbedSyncedDocCard);
customElements.define(
'affine-embed-edgeless-linked-doc-block',
EmbedEdgelessLinkedDocBlockComponent
);
customElements.define(
'affine-embed-linked-doc-block',
EmbedLinkedDocBlockComponent
);
customElements.define(
'affine-embed-edgeless-synced-doc-block',
EmbedEdgelessSyncedDocBlockComponent
);
customElements.define(
'affine-embed-synced-doc-block',
EmbedSyncedDocBlockComponent
);
}
declare global {
interface HTMLElementTagNameMap {
'affine-embed-synced-doc-card': EmbedSyncedDocCard;
'affine-embed-synced-doc-block': EmbedSyncedDocBlockComponent;
'affine-embed-edgeless-synced-doc-block': EmbedEdgelessSyncedDocBlockComponent;
'affine-embed-linked-doc-block': EmbedLinkedDocBlockComponent;
'affine-embed-edgeless-linked-doc-block': EmbedEdgelessLinkedDocBlockComponent;
}
}

View File

@@ -0,0 +1,11 @@
import type { ExtensionType } from '@blocksuite/store';
import { EmbedLinkedDocHtmlAdapterExtension } from './html.js';
import { EmbedLinkedDocMarkdownAdapterExtension } from './markdown.js';
import { EmbedLinkedDocBlockPlainTextAdapterExtension } from './plain-text.js';
export const EmbedLinkedDocBlockAdapterExtensions: ExtensionType[] = [
EmbedLinkedDocHtmlAdapterExtension,
EmbedLinkedDocMarkdownAdapterExtension,
EmbedLinkedDocBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,63 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import {
AdapterTextUtils,
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedLinkedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: EmbedLinkedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const { configs, walkerContext } = context;
// Parse as link
if (!o.node.props.pageId) {
return;
}
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
const url = AdapterTextUtils.generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(o.node.props.pageId),
o.node.props.params ?? Object.create(null)
);
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'a',
properties: {
href: url,
},
children: [
{
type: 'text',
value: title,
},
],
},
'children'
)
.closeNode()
.closeNode();
},
},
};
export const EmbedLinkedDocHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedLinkedDocBlockHtmlAdapterMatcher
);

View File

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

View File

@@ -0,0 +1,131 @@
import {
EmbedLinkedDocBlockSchema,
FootNoteReferenceParamsSchema,
} from '@blocksuite/affine-model';
import {
AdapterTextUtils,
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
FOOTNOTE_DEFINITION_PREFIX,
getFootnoteDefinitionText,
isFootnoteDefinitionNode,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { nanoid } from '@blocksuite/store';
const isLinkedDocFootnoteDefinitionNode = (node: MarkdownAST) => {
if (!isFootnoteDefinitionNode(node)) return false;
const footnoteDefinition = getFootnoteDefinitionText(node);
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
return (
footnoteDefinitionJson.type === 'doc' && !!footnoteDefinitionJson.docId
);
} catch {
return false;
}
};
export const embedLinkedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: EmbedLinkedDocBlockSchema.model.flavour,
toMatch: o => isLinkedDocFootnoteDefinitionNode(o.node),
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { provider } = context;
let enableCitation = false;
try {
const featureFlagService = provider?.get(FeatureFlagService);
enableCitation = !!featureFlagService?.getFlag('enable_citation');
} catch {
enableCitation = false;
}
if (!isFootnoteDefinitionNode(o.node) || !enableCitation) {
return;
}
const { walkerContext, configs } = context;
const footnoteIdentifier = o.node.identifier;
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnoteIdentifier}`;
const footnoteDefinition = configs.get(footnoteDefinitionKey);
if (!footnoteDefinition) {
return;
}
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
const { docId } = footnoteDefinitionJson;
if (!docId) {
return;
}
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: EmbedLinkedDocBlockSchema.model.flavour,
props: {
pageId: docId,
footnoteIdentifier,
style: 'citation',
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
} catch (err) {
console.warn('Failed to parse linked doc footnote definition:', err);
return;
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { configs, walkerContext } = context;
// Parse as link
if (!o.node.props.pageId) {
return;
}
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
const url = AdapterTextUtils.generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(o.node.props.pageId),
o.node.props.params ?? Object.create(null)
);
walkerContext
.openNode(
{
type: 'paragraph',
children: [],
},
'children'
)
.openNode(
{
type: 'link',
url,
title: o.node.props.caption as string | null,
children: [
{
type: 'text',
value: title,
},
],
},
'children'
)
.closeNode()
.closeNode();
},
},
};
export const EmbedLinkedDocMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedLinkedDocBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,33 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import {
AdapterTextUtils,
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedLinkedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
{
flavour: EmbedLinkedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const { configs, textBuffer } = context;
// Parse as link
if (!o.node.props.pageId) {
return;
}
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
const url = AdapterTextUtils.generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(o.node.props.pageId),
o.node.props.params ?? Object.create(null)
);
textBuffer.content += `${title}: ${url}\n`;
},
},
};
export const EmbedLinkedDocBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedLinkedDocBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1 @@
export * from './insert-embed-linked-doc';

View File

@@ -0,0 +1,33 @@
import { insertEmbedCard } from '@blocksuite/affine-block-embed';
import type { EmbedCardStyle, ReferenceParams } from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/std';
export type LinkableFlavour =
| 'affine:bookmark'
| 'affine:embed-linked-doc'
| 'affine:embed-iframe'
| 'affine:embed-figma'
| 'affine:embed-github'
| 'affine:embed-loom'
| 'affine:embed-youtube';
export type InsertedLinkType = {
flavour: LinkableFlavour;
} | null;
export const insertEmbedLinkedDocCommand: Command<
{
docId: string;
params?: ReferenceParams;
},
{ blockId: string }
> = (ctx, next) => {
const { docId, params, std } = ctx;
const flavour = 'affine:embed-linked-doc';
const targetStyle: EmbedCardStyle = 'vertical';
const props: Record<string, unknown> = { pageId: docId };
if (params) props.params = params;
const blockId = insertEmbedCard(std, { flavour, targetStyle, props });
if (!blockId) return;
next({ blockId });
};

View File

@@ -0,0 +1,82 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import { insertContent } from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
import {
type SlashMenuConfig,
SlashMenuConfigIdentifier,
} from '@blocksuite/affine-widget-slash-menu';
import { LinkedPageIcon, PlusIcon } from '@blocksuite/icons/lit';
import { type ExtensionType } from '@blocksuite/store';
import { LinkDocTooltip, NewDocTooltip } from './tooltips';
const linkedDocSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'New Doc',
description: 'Start a new document.',
icon: PlusIcon(),
tooltip: {
figure: NewDocTooltip,
caption: 'New Doc',
},
group: '3_Page@0',
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
action: ({ std, model }) => {
const newDoc = createDefaultDoc(std.host.doc.workspace);
insertContent(std, model, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: newDoc.id,
},
});
},
},
{
name: 'Linked Doc',
description: 'Link to another document.',
icon: LinkedPageIcon(),
tooltip: {
figure: LinkDocTooltip,
caption: 'Link Doc',
},
searchAlias: ['dual link'],
group: '3_Page@1',
when: ({ std, model }) => {
const root = model.doc.root;
if (!root) return false;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
root.id
);
if (!linkedDocWidget) return false;
return model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc');
},
action: ({ model, std }) => {
const root = model.doc.root;
if (!root) return;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
root.id
);
if (!linkedDocWidget) return;
// TODO(@L-Sun): make linked-doc-widget as extension
// @ts-expect-error same as above
linkedDocWidget.show({ addTriggerKey: true });
},
},
],
};
export const LinkedDocSlashMenuConfigIdentifier = SlashMenuConfigIdentifier(
EmbedLinkedDocBlockSchema.model.flavour
);
export const LinkedDocSlashMenuConfigExtension: ExtensionType = {
setup: di => {
di.addImpl(LinkedDocSlashMenuConfigIdentifier, linkedDocSlashMenuConfig);
},
};

View File

@@ -0,0 +1,514 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import {
type EmbedCardStyle,
EmbedLinkedDocModel,
EmbedLinkedDocStyles,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
ActionPlacement,
DocDisplayMetaProvider,
EditorSettingProvider,
FeatureFlagService,
type LinkEventType,
type OpenDocMode,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import {
getBlockProps,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandFullIcon,
OpenInNewIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier, isGfxBlockComponent } from '@blocksuite/std';
import { type ExtensionType, Slice } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block';
const trackBaseProps = {
category: 'linked doc',
type: 'card view',
};
const createOnToggleFn =
(
ctx: ToolbarContext,
name: Extract<
LinkEventType,
| 'OpenedViewSelector'
| 'OpenedCardStyleSelector'
| 'OpenedCardScaleSelector'
>,
control: 'switch view' | 'switch card style' | 'switch card scale'
) =>
(e: CustomEvent<boolean>) => {
e.stopPropagation();
const opened = e.detail;
if (!opened) return;
ctx.track(name, { ...trackBaseProps, control });
};
const docTitleAction = {
id: 'a.doc-title',
content(ctx) {
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
if (!block) return null;
const model = block.model;
if (!model.props.title) return null;
const originalTitle =
ctx.std.get(DocDisplayMetaProvider).title(model.props.pageId).value ||
'Untitled';
const open = (event: MouseEvent) => block.open({ event });
return html`<affine-linked-doc-title
.title=${originalTitle}
.open=${open}
></affine-linked-doc-title>`;
},
} as const satisfies ToolbarAction;
const captionAction = {
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
block?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
} as const satisfies ToolbarAction;
const openDocActions = [
{
mode: 'open-in-active-view',
id: 'a.open-in-active-view',
label: 'Open this doc',
icon: ExpandFullIcon(),
},
] as const satisfies (Pick<ToolbarAction, 'id' | 'label' | 'icon'> & {
mode: OpenDocMode;
})[];
const openDocActionGroup = {
placement: ActionPlacement.Start,
id: 'A.open-doc',
content(ctx) {
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
if (!block) return null;
const actions = openDocActions.map<ToolbarAction>(action => {
const openMode = action.mode;
const shouldOpenInActiveView = openMode === 'open-in-active-view';
return {
...action,
disabled: shouldOpenInActiveView
? block.model.props.pageId === ctx.store.id
: false,
when: true,
run: (_ctx: ToolbarContext) => block.open({ openMode }),
};
});
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
${OpenInNewIcon()} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
actions,
action => action.id,
({ label, icon, run, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${ifDefined(
typeof disabled === 'function' ? disabled(ctx) : disabled
)}
@click=${() => run?.(ctx)}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
},
} as const satisfies ToolbarAction;
const conversionsActionGroup = {
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
block?.convertToInline();
// Clears
ctx.select('note');
ctx.reset();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
when: ctx => !ctx.hasSelectedSurfaceModels,
},
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
if (!block) return true;
if (block.closest('affine-embed-synced-doc-block')) return true;
const model = block.model;
// same doc
if (model.props.pageId === ctx.store.id) return true;
// linking to block
if (referenceToNode(model.props)) return true;
return false;
},
run(ctx) {
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
if (
ctx.std
.get(FeatureFlagService)
.getFlag('enable_embed_doc_with_alias') &&
isGfxBlockComponent(block)
) {
const editorSetting = ctx.std.getOptional(EditorSettingProvider);
editorSetting?.set?.(
'docDropCanvasPreferView',
'affine:embed-synced-doc'
);
}
block?.convertToEmbed();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
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>`
)}`;
},
} as const satisfies ToolbarActionGroup<ToolbarAction>;
const builtinToolbarConfig = {
actions: [
docTitleAction,
conversionsActionGroup,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
].filter(action =>
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle)
),
content(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
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(EmbedLinkedDocModel);
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(EmbedLinkedDocModel);
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: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
const builtinSurfaceToolbarConfig = {
actions: [
openDocActionGroup,
docTitleAction,
conversionsActionGroup,
{
id: 'c.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 =>
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle)
),
content(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
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: 'e.scale',
content(ctx) {
const model = ctx.getCurrentBlockByType(
EmbedLinkedDocBlockComponent
)?.model;
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.h = EMBED_CARD_HEIGHT[style] * scale;
bounds.w = EMBED_CARD_WIDTH[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(EmbedLinkedDocModel).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,31 @@
import { html } from 'lit';
// prettier-ignore
export const NewDocTooltip = 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_991" 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_991)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="22" font-weight="600" letter-spacing="0px"><tspan x="8" y="27.5">Title</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="44.6364">Type &#39;/&#39; for commands</tspan></text>
</g>
</svg>
`;
// prettier-ignore
export const LinkDocTooltip = 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_998" 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_998)">
<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>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 39C10.125 38.2406 10.7406 37.625 11.5 37.625H16.5C17.2594 37.625 17.875 38.2406 17.875 39V42C17.875 42.2071 17.7071 42.375 17.5 42.375C17.2929 42.375 17.125 42.2071 17.125 42V39C17.125 38.6548 16.8452 38.375 16.5 38.375H11.5C11.1548 38.375 10.875 38.6548 10.875 39V45C10.875 45.3452 11.1548 45.625 11.5 45.625H14C14.2071 45.625 14.375 45.7929 14.375 46C14.375 46.2071 14.2071 46.375 14 46.375H11.5C10.7406 46.375 10.125 45.7594 10.125 45V39ZM12.125 40C12.125 39.7929 12.2929 39.625 12.5 39.625H14C14.2071 39.625 14.375 39.7929 14.375 40C14.375 40.2071 14.2071 40.375 14 40.375H12.5C12.2929 40.375 12.125 40.2071 12.125 40ZM12.5 41.375C12.2929 41.375 12.125 41.5429 12.125 41.75C12.125 41.9571 12.2929 42.125 12.5 42.125H15.5C15.7071 42.125 15.875 41.9571 15.875 41.75C15.875 41.5429 15.7071 41.375 15.5 41.375H12.5ZM12.125 43.5C12.125 43.2929 12.2929 43.125 12.5 43.125H13.75C13.9571 43.125 14.125 43.2929 14.125 43.5C14.125 43.7071 13.9571 43.875 13.75 43.875H12.5C12.2929 43.875 12.125 43.7071 12.125 43.5ZM15.75 43.125C15.5429 43.125 15.375 43.2929 15.375 43.5C15.375 43.7071 15.5429 43.875 15.75 43.875H17.0947L15.2348 45.7348C15.0884 45.8813 15.0884 46.1187 15.2348 46.2652C15.3813 46.4116 15.6187 46.4116 15.7652 46.2652L17.625 44.4053V45.75C17.625 45.9571 17.7929 46.125 18 46.125C18.2071 46.125 18.375 45.9571 18.375 45.75V43.5C18.375 43.4005 18.3355 43.3052 18.2652 43.2348C18.1948 43.1645 18.0995 43.125 18 43.125H15.75Z" fill="#77757D"/>
<mask id="path-5-inside-1_16460_998" fill="white">
<path d="M24 35H98V49H24V35Z"/>
</mask>
<path d="M98 48.5H24V49.5H98V48.5Z" fill="#E3E2E4" mask="url(#path-5-inside-1_16460_998)"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="45.6364">What&#x2019;s AFFiNE?</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,31 @@
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
import { type BlockSnapshot } from '@blocksuite/store';
export class EdgelessClipboardEmbedLinkedDocConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-linked-doc';
override createBlock(linkedDocEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, caption, pageId, params, title, description } =
linkedDocEmbed.props;
const referenceInfo = ReferenceInfoSchema.parse({
pageId,
params,
title,
description,
});
return this.crud.addBlock(
'affine:embed-linked-doc',
{
xywh,
style,
caption,
...referenceInfo,
},
this.surface.model.id
);
}
}

View File

@@ -0,0 +1,63 @@
import { toEdgelessEmbedBlock } from '@blocksuite/affine-block-embed';
import {
EdgelessCRUDIdentifier,
reassociateConnectorsCommand,
} from '@blocksuite/affine-block-surface';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
cloneReferenceInfoWithoutAliases,
isNewTabTrigger,
isNewViewTrigger,
} from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block.js';
export class EmbedEdgelessLinkedDocBlockComponent extends toEdgelessEmbedBlock(
EmbedLinkedDocBlockComponent
) {
override convertToEmbed = () => {
const { caption, xywh } = this.model.props;
const { doc, id } = this.model;
const style = 'syncedDoc';
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[style];
bound.h = EMBED_CARD_HEIGHT[style];
const { addBlock } = this.std.get(EdgelessCRUDIdentifier);
const surface = this.gfx.surface ?? undefined;
const newId = addBlock(
'affine:embed-synced-doc',
{
xywh: bound.serialize(),
caption,
...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()),
},
surface
);
this.std.command.exec(reassociateConnectorsCommand, {
oldId: id,
newId,
});
this.gfx.selection.set({
editing: false,
elements: [newId],
});
doc.deleteBlock(this.model);
};
protected override _handleClick = (evt: MouseEvent): void => {
if (isNewTabTrigger(evt)) {
this.open({ openMode: 'open-in-new-tab', event: evt });
} else if (isNewViewTrigger(evt)) {
this.open({ openMode: 'open-in-new-view', event: evt });
}
};
}

View File

@@ -0,0 +1,608 @@
import {
EmbedBlockComponent,
RENDER_CARD_THROTTLE_MS,
} from '@blocksuite/affine-block-embed';
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import { isPeekable, Peekable } from '@blocksuite/affine-components/peek';
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
import type {
DocMode,
EmbedLinkedDocModel,
EmbedLinkedDocStyles,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
REFERENCE_NODE,
} from '@blocksuite/affine-shared/consts';
import {
DocDisplayMetaProvider,
DocModeProvider,
OpenDocExtensionIdentifier,
type OpenDocMode,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfo,
cloneReferenceInfoWithoutAliases,
isNewTabTrigger,
isNewViewTrigger,
matchModels,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import { BlockSelection } from '@blocksuite/std';
import { Text } from '@blocksuite/store';
import { computed } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property, queryAsync, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import throttle from 'lodash-es/throttle';
import * as Y from 'yjs';
import { renderLinkedDocInCard } from '../common/render-linked-doc';
import { SyncedDocErrorIcon } from '../embed-synced-doc-block/styles.js';
import { styles } from './styles.js';
import { getEmbedLinkedDocIcons } from './utils.js';
@Peekable({
enableOn: ({ doc }: EmbedLinkedDocBlockComponent) => !doc.readonly,
})
export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinkedDocModel> {
static override styles = styles;
private readonly _load = async () => {
// If this is a citation linked doc block, we don't need to load the linked doc and render linked doc content in card
if (this.isCitation) {
return;
}
const {
loading = true,
isError = false,
isBannerEmpty = true,
isNoteContentEmpty = true,
} = this.getInitialState();
this._loading = loading;
this.isError = isError;
this.isBannerEmpty = isBannerEmpty;
this.isNoteContentEmpty = isNoteContentEmpty;
if (!this._loading) {
return;
}
const linkedDoc = this.linkedDoc;
if (!linkedDoc) {
this._loading = false;
return;
}
if (!linkedDoc.loaded) {
try {
linkedDoc.load();
} catch (e) {
console.error(e);
this.isError = true;
}
}
if (!this.isError && !linkedDoc.root) {
await new Promise<void>(resolve => {
const subscription = linkedDoc.slots.rootAdded.subscribe(() => {
subscription.unsubscribe();
resolve();
});
});
}
this._loading = false;
// If it is a link to a block or element, the content will not be rendered.
if (this._referenceToNode) {
return;
}
if (!this.isError) {
const cardStyle = this.model.props.style;
if (cardStyle === 'horizontal' || cardStyle === 'vertical') {
renderLinkedDocInCard(this);
}
}
};
private readonly _selectBlock = () => {
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
};
private readonly _setDocUpdatedAt = () => {
const meta = this.doc.workspace.meta.getDocMeta(this.model.props.pageId);
if (meta) {
const date = meta.updatedDate || meta.createDate;
this._docUpdatedAt = new Date(date);
}
};
override _cardStyle: (typeof EmbedLinkedDocStyles)[number] = 'horizontal';
convertToEmbed = () => {
if (this._referenceToNode) return;
const { caption } = this.model.props;
const { parent, doc } = this.model;
const index = parent?.children.indexOf(this.model);
const blockId = doc.addBlock(
'affine:embed-synced-doc',
{
caption,
...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()),
},
parent,
index
);
doc.deleteBlock(this.model);
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, { blockId }),
]);
};
convertToInline = () => {
const { doc } = this.model;
const parent = doc.getParent(this.model);
if (!parent) {
return;
}
const index = parent.children.indexOf(this.model);
const yText = new Y.Text();
yText.insert(0, REFERENCE_NODE);
yText.format(0, REFERENCE_NODE.length, {
reference: {
type: 'LinkedPage',
...this.referenceInfo$.peek(),
},
});
const text = new Text(yText);
doc.addBlock(
'affine:paragraph',
{
text,
},
parent,
index
);
doc.deleteBlock(this.model);
};
referenceInfo$ = computed(() => {
const { pageId, params, title$, description$ } = this.model.props;
return cloneReferenceInfo({
pageId,
params,
title: title$.value,
description: description$.value,
});
});
icon$ = computed(() => {
const { pageId, params, title } = this.referenceInfo$.value;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, title, referenced: true }).value;
});
open = ({
openMode,
event,
}: {
openMode?: OpenDocMode;
event?: MouseEvent;
} = {}) => {
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({
...this.referenceInfo$.peek(),
openMode,
event,
host: this.host,
});
};
refreshData = () => {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
};
title$ = computed(() => {
const { pageId, params, title } = this.referenceInfo$.value;
return (
this.std
.get(DocDisplayMetaProvider)
.title(pageId, { params, title, referenced: true }) || title
);
});
get docTitle() {
return this.model.props.title || this.linkedDoc?.meta?.title || 'Untitled';
}
get editorMode() {
return this._linkedDocMode;
}
get linkedDoc() {
const doc = this.std.workspace.getDoc(this.model.props.pageId);
return doc?.getStore({ id: this.model.props.pageId });
}
get readonly() {
return this.doc.readonly;
}
get isCitation() {
return (
!!this.model.props.footnoteIdentifier &&
this.model.props.style === 'citation'
);
}
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
const openDocService = this.std.get(OpenDocExtensionIdentifier);
const shouldOpenInPeek =
openDocService.isAllowed('open-in-center-peek') && isPeekable(this);
this.open({
openMode: shouldOpenInPeek
? 'open-in-center-peek'
: 'open-in-active-view',
event,
});
}
private _isDocEmpty() {
const linkedDoc = this.linkedDoc;
if (!linkedDoc) {
return false;
}
return !!linkedDoc && this.isNoteContentEmpty && this.isBannerEmpty;
}
protected _handleClick = (event: MouseEvent) => {
if (isNewTabTrigger(event)) {
this.open({ openMode: 'open-in-new-tab', event });
} else if (isNewViewTrigger(event)) {
this.open({ openMode: 'open-in-new-view', event });
}
if (this.readonly) {
return;
}
this._selectBlock();
};
private readonly _renderCitationView = () => {
const { footnoteIdentifier } = this.model.props;
return html`<div
draggable="${this.blockDraggable ? 'true' : 'false'}"
class=${classMap({
'embed-block-container': true,
...this.selectedStyle$?.value,
})}
style=${styleMap({
...this.embedContainerStyle,
})}
>
<affine-citation-card
.icon=${this.icon$.value}
.citationTitle=${this.title$.value}
.citationIdentifier=${footnoteIdentifier}
.active=${this.selected$.value}
.onClickCallback=${this._handleClick}
></affine-citation-card>
</div> `;
};
private readonly _renderEmbedView = () => {
const linkedDoc = this.linkedDoc;
const isDeleted = !linkedDoc;
const isLoading = this._loading;
const isError = this.isError;
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
const inCanvas = matchModels(this.model.parent, [SurfaceBlockModel]);
const cardClassMap = classMap({
loading: isLoading,
error: isError,
deleted: isDeleted,
empty: isEmpty,
'banner-empty': this.isBannerEmpty,
'note-empty': this.isNoteContentEmpty,
'in-canvas': inCanvas,
[this._cardStyle]: true,
});
const theme = this.std.get(ThemeProvider).theme;
const {
LoadingIcon,
ReloadIcon,
LinkedDocDeletedBanner,
LinkedDocEmptyBanner,
SyncedDocErrorBanner,
} = getEmbedLinkedDocIcons(theme, this._linkedDocMode, this._cardStyle);
const icon = isError
? SyncedDocErrorIcon
: isLoading
? LoadingIcon
: this.icon$.value;
const title = isLoading ? 'Loading...' : this.title$;
const description = this.model.props.description$;
const showDefaultNoteContent = isError || isLoading || isDeleted || isEmpty;
const defaultNoteContent = isError
? 'This linked doc failed to load.'
: isLoading
? ''
: isDeleted
? 'This linked doc is deleted.'
: isEmpty
? 'Preview of the doc will be displayed here.'
: '';
const dateText =
this._cardStyle === 'cube'
? this._docUpdatedAt.toLocaleTimeString()
: this._docUpdatedAt.toLocaleString();
const showDefaultBanner = isError || isLoading || isDeleted || isEmpty;
const defaultBanner = isError
? SyncedDocErrorBanner
: isLoading
? LinkedDocEmptyBanner
: isDeleted
? LinkedDocDeletedBanner
: LinkedDocEmptyBanner;
const hasDescriptionAlias = Boolean(description.value);
return this.renderEmbed(
() => html`
<div
class="affine-embed-linked-doc-block ${cardClassMap}"
style=${styleMap({
transform: `scale(${this._scale})`,
transformOrigin: '0 0',
})}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-linked-doc-content">
<div class="affine-embed-linked-doc-content-title">
<div class="affine-embed-linked-doc-content-title-icon">
${icon}
</div>
<div class="affine-embed-linked-doc-content-title-text">
${title}
</div>
</div>
${when(
hasDescriptionAlias,
() =>
html`<div class="affine-embed-linked-doc-content-note alias">
${repeat(
(description.value ?? '').split('\n'),
text => html`<p>${text}</p>`
)}
</div>`,
() =>
when(
showDefaultNoteContent,
() => html`
<div class="affine-embed-linked-doc-content-note default">
${defaultNoteContent}
</div>
`,
() => html`
<div
class="affine-embed-linked-doc-content-note render"
></div>
`
)
)}
${isError
? html`
<div class="affine-embed-linked-doc-card-content-reload">
<div
class="affine-embed-linked-doc-card-content-reload-button"
@click=${this.refreshData}
>
${ReloadIcon} <span>Reload</span>
</div>
</div>
`
: html`
<div class="affine-embed-linked-doc-content-date">
<span>Updated</span>
<span>${dateText}</span>
</div>
`}
</div>
${showDefaultBanner
? html`
<div class="affine-embed-linked-doc-banner default">
${defaultBanner}
</div>
`
: nothing}
</div>
`
);
};
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.props.style;
this._referenceToNode = referenceToNode(this.model.props);
this._load().catch(e => {
console.error(e);
this.isError = true;
});
const linkedDoc = this.linkedDoc;
if (linkedDoc) {
this.disposables.add(
linkedDoc.workspace.slots.docListUpdated.subscribe(() => {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
})
);
// Should throttle the blockUpdated event to avoid too many re-renders
// Because the blockUpdated event is triggered too frequently at some cases
this.disposables.add(
linkedDoc.slots.blockUpdated.subscribe(
throttle(payload => {
if (
payload.type === 'update' &&
['', 'caption', 'xywh'].includes(payload.props.key)
) {
return;
}
if (payload.type === 'add' && payload.init) {
return;
}
this._load().catch(e => {
console.error(e);
this.isError = true;
});
}, RENDER_CARD_THROTTLE_MS)
)
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.workspace.slots.docListUpdated.subscribe(() => {
this._setDocUpdatedAt();
})
);
if (this._referenceToNode) {
this._linkedDocMode = this.model.props.params?.mode ?? 'page';
} else {
const docMode = this.std.get(DocModeProvider);
this._linkedDocMode = docMode.getPrimaryMode(this.model.props.pageId);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this._linkedDocMode = mode;
}, this.model.props.pageId)
);
}
}
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'style') {
this._cardStyle = this.model.props.style;
}
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
}
})
);
}
getInitialState(): {
loading?: boolean;
isError?: boolean;
isNoteContentEmpty?: boolean;
isBannerEmpty?: boolean;
} {
return {};
}
override renderBlock() {
return this.isCitation
? this._renderCitationView()
: this._renderEmbedView();
}
override updated() {
if (this.readonly) {
return;
}
// update card style when linked doc deleted
const linkedDoc = this.linkedDoc;
const { xywh, style } = this.model.props;
const bound = Bound.deserialize(xywh);
if (linkedDoc && style === 'horizontalThin') {
bound.w = EMBED_CARD_WIDTH.horizontal;
bound.h = EMBED_CARD_HEIGHT.horizontal;
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
style: 'horizontal',
});
});
} else if (!linkedDoc && style === 'horizontal') {
bound.w = EMBED_CARD_WIDTH.horizontalThin;
bound.h = EMBED_CARD_HEIGHT.horizontalThin;
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
style: 'horizontalThin',
});
});
}
}
@state()
private accessor _docUpdatedAt: Date = new Date();
@state()
private accessor _linkedDocMode: DocMode = 'page';
@state()
private accessor _loading = false;
// reference to block/element
@state()
private accessor _referenceToNode = false;
@property({ attribute: false })
accessor isBannerEmpty = false;
@property({ attribute: false })
accessor isError = false;
@property({ attribute: false })
accessor isNoteContentEmpty = false;
@queryAsync('.affine-embed-linked-doc-content-note.render')
accessor noteContainer!: Promise<HTMLDivElement | null>;
}

View File

@@ -0,0 +1,31 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import { BlockViewExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension';
import { LinkedDocSlashMenuConfigExtension } from './configs/slash-menu';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
const flavour = EmbedLinkedDocBlockSchema.model.flavour;
export const EmbedLinkedDocBlockSpec: ExtensionType[] = [
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-linked-doc-block`
: literal`affine-embed-linked-doc-block`;
}),
EmbedLinkedDocBlockAdapterExtensions,
createBuiltinToolbarConfigExtension(flavour),
LinkedDocSlashMenuConfigExtension,
].flat();
export const EmbedLinkedDocViewExtensions: ExtensionType[] = [
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-linked-doc-block`
: literal`affine-embed-linked-doc-block`;
}),
createBuiltinToolbarConfigExtension(flavour),
LinkedDocSlashMenuConfigExtension,
].flat();

View File

@@ -0,0 +1,6 @@
export * from './adapters';
export * from './commands';
export { LinkedDocSlashMenuConfigIdentifier } from './configs/slash-menu';
export * from './edgeless-clipboard-config';
export * from './embed-linked-doc-block';
export * from './embed-linked-doc-spec';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
import {
EmbedEdgelessIcon,
EmbedPageIcon,
getLoadingIconWith,
ReloadIcon,
} from '@blocksuite/affine-components/icons';
import {
ColorScheme,
type EmbedLinkedDocStyles,
} from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import {
DarkSyncedDocErrorBanner,
LightSyncedDocErrorBanner,
} from '../embed-synced-doc-block/styles.js';
import {
DarkLinkedEdgelessDeletedLargeBanner,
DarkLinkedEdgelessDeletedSmallBanner,
DarkLinkedEdgelessEmptyLargeBanner,
DarkLinkedEdgelessEmptySmallBanner,
DarkLinkedPageDeletedLargeBanner,
DarkLinkedPageDeletedSmallBanner,
DarkLinkedPageEmptyLargeBanner,
DarkLinkedPageEmptySmallBanner,
LightLinkedEdgelessDeletedLargeBanner,
LightLinkedEdgelessDeletedSmallBanner,
LightLinkedEdgelessEmptyLargeBanner,
LightLinkedEdgelessEmptySmallBanner,
LightLinkedPageDeletedLargeBanner,
LightLinkedPageDeletedSmallBanner,
LightLinkedPageEmptyLargeBanner,
LightLinkedPageEmptySmallBanner,
LinkedDocDeletedIcon,
} from './styles.js';
type EmbedCardImages = {
LoadingIcon: TemplateResult<1>;
ReloadIcon: TemplateResult<1>;
LinkedDocIcon: TemplateResult<1>;
LinkedDocDeletedIcon: TemplateResult<1>;
LinkedDocEmptyBanner: TemplateResult<1>;
LinkedDocDeletedBanner: TemplateResult<1>;
SyncedDocErrorBanner: TemplateResult<1>;
};
export function getEmbedLinkedDocIcons(
theme: ColorScheme,
editorMode: 'page' | 'edgeless',
style: (typeof EmbedLinkedDocStyles)[number]
): EmbedCardImages {
const small = style !== 'vertical';
const LoadingIcon = getLoadingIconWith(theme);
if (editorMode === 'page') {
if (theme === ColorScheme.Light) {
return {
LoadingIcon,
ReloadIcon,
LinkedDocIcon: EmbedPageIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? LightLinkedPageEmptySmallBanner
: LightLinkedPageEmptyLargeBanner,
LinkedDocDeletedBanner: small
? LightLinkedPageDeletedSmallBanner
: LightLinkedPageDeletedLargeBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
};
} else {
return {
ReloadIcon,
LoadingIcon,
LinkedDocIcon: EmbedPageIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? DarkLinkedPageEmptySmallBanner
: DarkLinkedPageEmptyLargeBanner,
LinkedDocDeletedBanner: small
? DarkLinkedPageDeletedSmallBanner
: DarkLinkedPageDeletedLargeBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
};
}
} else {
if (theme === ColorScheme.Light) {
return {
ReloadIcon,
LoadingIcon,
LinkedDocIcon: EmbedEdgelessIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? LightLinkedEdgelessEmptySmallBanner
: LightLinkedEdgelessEmptyLargeBanner,
LinkedDocDeletedBanner: small
? LightLinkedEdgelessDeletedSmallBanner
: LightLinkedEdgelessDeletedLargeBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
};
} else {
return {
ReloadIcon,
LoadingIcon,
LinkedDocIcon: EmbedEdgelessIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? DarkLinkedEdgelessEmptySmallBanner
: DarkLinkedEdgelessEmptyLargeBanner,
LinkedDocDeletedBanner: small
? DarkLinkedEdgelessDeletedSmallBanner
: DarkLinkedEdgelessDeletedLargeBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
};
}
}
}

View File

@@ -0,0 +1,11 @@
import type { ExtensionType } from '@blocksuite/store';
import { EmbedSyncedDocBlockHtmlAdapterExtension } from './html.js';
import { EmbedSyncedDocMarkdownAdapterExtension } from './markdown.js';
import { EmbedSyncedDocBlockPlainTextAdapterExtension } from './plain-text.js';
export const EmbedSyncedDocBlockAdapterExtensions: ExtensionType[] = [
EmbedSyncedDocBlockHtmlAdapterExtension,
EmbedSyncedDocMarkdownAdapterExtension,
EmbedSyncedDocBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,88 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') === undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.docCRUD.get(syncedDocId);
walkerContext.setGlobalContext('hast:html-root-doc', false);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'p',
properties: {},
children: [
{ type: 'text', value: syncedDoc.meta?.title ?? '' },
],
},
'children'
)
.closeNode()
.closeNode();
}
}
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
const currentCounter = counter - 1;
walkerContext.setGlobalContext(
'embed-synced-doc-counter',
currentCounter
);
// When leave the last embed synced doc block, we need to set the html root doc context to true
walkerContext.setGlobalContext(
'hast:html-root-doc',
currentCounter === 0
);
},
},
};
export const EmbedSyncedDocBlockHtmlAdapterExtension =
BlockHtmlAdapterExtension(embedSyncedDocBlockHtmlAdapterMatcher);

View File

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

View File

@@ -0,0 +1,64 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') ===
undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.docCRUD.get(syncedDocId);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
// TODO(@L-Sun) may be use the nested content
walkerContext
.openNode({
type: 'paragraph',
children: [
{ type: 'text', value: syncedDoc.meta?.title ?? '' },
],
})
.closeNode();
}
}
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1);
},
},
};
export const EmbedSyncedDocMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedSyncedDocBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,62 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
{
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job, textBuffer } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') ===
undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
let buffer = '';
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.docCRUD.get(syncedDocId);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
buffer = syncedDoc.meta?.title ?? '';
if (buffer) {
buffer += '\n';
}
}
}
textBuffer.content += buffer;
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1);
},
},
};
export const EmbedSyncedDocBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedSyncedDocBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,262 @@
import { RENDER_CARD_THROTTLE_MS } from '@blocksuite/affine-block-embed';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { WithDisposable } from '@blocksuite/global/lit';
import {
BlockSelection,
isGfxBlockComponent,
ShadowlessElement,
} from '@blocksuite/std';
import { html, nothing } from 'lit';
import { property, queryAsync } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import throttle from 'lodash-es/throttle';
import { renderLinkedDocInCard } from '../../common/render-linked-doc';
import type { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block.js';
import { cardStyles } from '../styles.js';
import { getSyncedDocIcons } from '../utils.js';
export class EmbedSyncedDocCard extends WithDisposable(ShadowlessElement) {
static override styles = cardStyles;
private _dragging = false;
get blockState() {
return this.block.blockState;
}
get editorMode() {
return this.block.editorMode;
}
get host() {
return this.block.host;
}
get linkedDoc() {
return this.block.syncedDoc;
}
get model() {
return this.block.model;
}
get std() {
return this.block.std;
}
private _handleClick(event: MouseEvent) {
event.stopPropagation();
if (!isGfxBlockComponent(this.block)) {
this._selectBlock();
}
}
private _isDocEmpty() {
const syncedDoc = this.block.syncedDoc;
if (!syncedDoc) {
return false;
}
return (
!!syncedDoc &&
!syncedDoc.meta?.title.length &&
this.isNoteContentEmpty &&
this.isBannerEmpty
);
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.block.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback() {
super.connectedCallback();
this.block.handleEvent(
'dragStart',
() => {
this._dragging = true;
},
{ global: true }
);
this.block.handleEvent(
'dragEnd',
() => {
this._dragging = false;
},
{ global: true }
);
const { isCycle } = this.block.blockState;
const syncedDoc = this.block.syncedDoc;
if (isCycle && syncedDoc) {
if (syncedDoc.root) {
renderLinkedDocInCard(this);
} else {
const subscription = syncedDoc.slots.rootAdded.subscribe(() => {
subscription.unsubscribe();
renderLinkedDocInCard(this);
});
}
this.disposables.add(
syncedDoc.workspace.slots.docListUpdated.subscribe(() => {
renderLinkedDocInCard(this);
})
);
// Should throttle the blockUpdated event to avoid too many re-renders
// Because the blockUpdated event is triggered too frequently at some cases
this.disposables.add(
syncedDoc.slots.blockUpdated.subscribe(
throttle(payload => {
if (this._dragging) {
return;
}
if (
payload.type === 'update' &&
['', 'caption', 'xywh'].includes(payload.props.key)
) {
return;
}
renderLinkedDocInCard(this);
}, RENDER_CARD_THROTTLE_MS)
)
);
}
}
override render() {
const { isLoading, isDeleted, isError, isCycle } = this.blockState;
const error = this.isError || isError;
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
const cardClassMap = classMap({
loading: isLoading,
error,
deleted: isDeleted,
cycle: isCycle,
surface: isGfxBlockComponent(this.block),
empty: isEmpty,
'banner-empty': this.isBannerEmpty,
'note-empty': this.isNoteContentEmpty,
});
const theme = this.std.get(ThemeProvider).theme;
const {
LoadingIcon,
SyncedDocErrorIcon,
ReloadIcon,
SyncedDocEmptyBanner,
SyncedDocErrorBanner,
SyncedDocDeletedBanner,
} = getSyncedDocIcons(theme, this.editorMode);
const icon = error
? SyncedDocErrorIcon
: isLoading
? LoadingIcon
: this.block.icon$.value;
const title = isLoading ? 'Loading...' : this.block.title$;
const showDefaultNoteContent = isLoading || error || isDeleted || isEmpty;
const defaultNoteContent = error
? 'This linked doc failed to load.'
: isLoading
? ''
: isDeleted
? 'This linked doc is deleted.'
: isEmpty
? 'Preview of the page will be displayed here.'
: '';
const dateText = this.block.docUpdatedAt.toLocaleString();
const showDefaultBanner = isLoading || error || isDeleted || isEmpty;
const defaultBanner = isLoading
? SyncedDocEmptyBanner
: error
? SyncedDocErrorBanner
: isDeleted
? SyncedDocDeletedBanner
: SyncedDocEmptyBanner;
return html`
<div
class="affine-embed-synced-doc-card ${cardClassMap}"
@click=${this._handleClick}
>
<div class="affine-embed-synced-doc-card-content">
<div class="affine-embed-synced-doc-card-content-title">
<div class="affine-embed-synced-doc-card-content-title-icon">
${icon}
</div>
<div class="affine-embed-synced-doc-card-content-title-text">
${title}
</div>
</div>
${showDefaultNoteContent
? html`<div class="affine-embed-synced-doc-content-note default">
${defaultNoteContent}
</div>`
: nothing}
<div class="affine-embed-synced-doc-content-note render"></div>
${error
? html`
<div class="affine-embed-synced-doc-card-content-reload">
<div
class="affine-embed-synced-doc-card-content-reload-button"
@click=${() => this.block.refreshData()}
>
${ReloadIcon} <span>Reload</span>
</div>
</div>
`
: html`
<div class="affine-embed-synced-doc-card-content-date">
<span>Updated</span>
<span>${dateText}</span>
</div>
`}
</div>
<div class="affine-embed-synced-doc-card-banner render"></div>
${showDefaultBanner
? html`
<div class="affine-embed-synced-doc-card-banner default">
${defaultBanner}
</div>
`
: nothing}
</div>
`;
}
@queryAsync('.affine-embed-synced-doc-card-banner.render')
accessor bannerContainer!: Promise<HTMLDivElement>;
@property({ attribute: false })
accessor block!: EmbedSyncedDocBlockComponent;
@property({ attribute: false })
accessor isBannerEmpty = false;
@property({ attribute: false })
accessor isError = false;
@property({ attribute: false })
accessor isNoteContentEmpty = false;
@queryAsync('.affine-embed-synced-doc-content-note.render')
accessor noteContainer!: Promise<HTMLDivElement>;
}

View File

@@ -0,0 +1,18 @@
import {
EmbedSyncedDocBlockSchema,
type EmbedSyncedDocModel,
} from '@blocksuite/affine-model';
import { type BlockStdScope, ConfigExtensionFactory } from '@blocksuite/std';
import type { TemplateResult } from 'lit';
export type EmbedSyncedDocConfig = {
edgelessHeader: (context: {
model: EmbedSyncedDocModel;
std: BlockStdScope;
}) => TemplateResult;
};
export const EmbedSyncedDocConfigExtension =
ConfigExtensionFactory<EmbedSyncedDocConfig>(
EmbedSyncedDocBlockSchema.model.flavour
);

View File

@@ -0,0 +1,487 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import {
DEFAULT_NOTE_HEIGHT,
DEFAULT_NOTE_WIDTH,
EmbedSyncedDocModel,
NoteBlockModel,
NoteDisplayMode,
type NoteProps,
type ParagraphProps,
} from '@blocksuite/affine-model';
import {
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
} from '@blocksuite/affine-shared/commands';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
ActionPlacement,
EditorSettingProvider,
FeatureFlagService,
type LinkEventType,
type OpenDocMode,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { getBlockProps, matchModels } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandFullIcon,
InsertIntoPageIcon,
OpenInNewIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier, isGfxBlockComponent } from '@blocksuite/std';
import {
type BlockModel,
type ExtensionType,
Slice,
Text,
} from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block';
const trackBaseProps = {
category: 'linked doc',
type: 'embed view',
};
const createOnToggleFn =
(
ctx: ToolbarContext,
name: Extract<
LinkEventType,
'OpenedViewSelector' | 'OpenedCardScaleSelector'
>,
control: 'switch view' | 'switch card scale'
) =>
(e: CustomEvent<boolean>) => {
e.stopPropagation();
const opened = e.detail;
if (!opened) return;
ctx.track(name, { ...trackBaseProps, control });
};
const openDocActions = [
{
mode: 'open-in-active-view',
id: 'a.open-in-active-view',
label: 'Open this doc',
icon: ExpandFullIcon(),
},
] as const satisfies (Pick<ToolbarAction, 'id' | 'label' | 'icon'> & {
mode: OpenDocMode;
})[];
const openDocActionGroup = {
placement: ActionPlacement.Start,
id: 'A.open-doc',
content(ctx) {
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
if (!block) return null;
const actions = openDocActions.map<ToolbarAction>(action => {
const openMode = action.mode;
const shouldOpenInActiveView = openMode === 'open-in-active-view';
return {
...action,
disabled: shouldOpenInActiveView
? block.model.props.pageId === ctx.store.id
: false,
when: true,
run: (_ctx: ToolbarContext) => block.open({ openMode }),
};
});
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
${OpenInNewIcon()} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
actions,
action => action.id,
({ label, icon, run, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${ifDefined(
typeof disabled === 'function' ? disabled(ctx) : disabled
)}
@click=${() => run?.(ctx)}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
},
} as const satisfies ToolbarAction;
const conversionsActionGroup = {
id: 'a.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
block?.convertToInline();
// Clears
ctx.select('note');
ctx.reset();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
when: ctx => !ctx.hasSelectedSurfaceModels,
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
if (
ctx.std
.get(FeatureFlagService)
.getFlag('enable_embed_doc_with_alias') &&
isGfxBlockComponent(block)
) {
const editorSetting = ctx.std.getOptional(EditorSettingProvider);
editorSetting?.set?.(
'docDropCanvasPreferView',
'affine:embed-linked-doc'
);
}
block?.convertToCard();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
disabled: true,
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal('Embed 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>`
)}`;
},
} as const satisfies ToolbarActionGroup<ToolbarAction>;
const captionAction = {
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
block?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
} as const satisfies ToolbarAction;
const builtinToolbarConfig = {
actions: [
openDocActionGroup,
conversionsActionGroup,
captionAction,
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
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(EmbedSyncedDocModel);
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: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
const builtinSurfaceToolbarConfig = {
actions: [
openDocActionGroup,
conversionsActionGroup,
{
id: 'b.insert-to-page',
label: 'Insert to page',
tooltip: 'Insert to page',
icon: InsertIntoPageIcon(),
when: ({ std }) =>
std.get(FeatureFlagService).getFlag('enable_embed_doc_with_alias'),
run: ctx => {
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
if (!model) return;
const lastVisibleNote = ctx.store
.getModelsByFlavour('affine:note')
.findLast(
(note): note is NoteBlockModel =>
matchModels(note, [NoteBlockModel]) &&
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
);
ctx.doc.captureSync();
ctx.chain
.pipe(duplicateSelectedModelsCommand, {
selectedModels: [model],
parentModel: lastVisibleNote,
})
.run();
},
},
{
id: 'c.duplicate-as-note',
label: 'Duplicate as note',
tooltip:
'Duplicate as note to create an editable copy, the original remains unchanged.',
icon: DuplicateIcon(),
when: ({ std }) =>
std.get(FeatureFlagService).getFlag('enable_embed_doc_with_alias'),
run: ctx => {
const { gfx } = ctx;
const syncedDocModel = ctx.getCurrentModelByType(EmbedSyncedDocModel);
if (!syncedDocModel) return;
let contentModels: BlockModel[] = [];
{
const doc = ctx.store.workspace.getDoc(syncedDocModel.props.pageId);
// TODO(@L-Sun): clear query cache
const store = doc?.getStore({ readonly: true });
if (!store) return;
contentModels = store
.getModelsByFlavour('affine:note')
.filter(
(note): note is NoteBlockModel =>
matchModels(note, [NoteBlockModel]) &&
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
)
.flatMap(note => note.children);
}
if (contentModels.length === 0) return;
ctx.doc.captureSync();
ctx.chain
.pipe(draftSelectedModelsCommand, {
selectedModels: contentModels,
})
.pipe(({ std, draftedModels }, next) => {
(async () => {
const PADDING = 20;
const x =
syncedDocModel.elementBound.x +
syncedDocModel.elementBound.w +
PADDING;
const y = syncedDocModel.elementBound.y;
const children = await draftedModels;
const noteId = std.store.addBlock(
'affine:note',
{
xywh: new Bound(
x,
y,
DEFAULT_NOTE_WIDTH,
DEFAULT_NOTE_HEIGHT
).serialize(),
index: gfx.layer.generateIndex(),
displayMode: NoteDisplayMode.EdgelessOnly,
} satisfies Partial<NoteProps>,
ctx.store.root
);
std.store.addBlock(
'affine:paragraph',
{
text: new Text<AffineTextAttributes>([
{
insert: REFERENCE_NODE,
attributes: {
reference: {
type: 'LinkedPage',
pageId: syncedDocModel.props.pageId,
},
},
},
]),
} satisfies Partial<ParagraphProps>,
noteId
);
await std.clipboard.duplicateSlice(
Slice.fromModels(std.store, children),
std.store,
noteId
);
gfx.selection.set({
elements: [noteId],
editing: false,
});
})().catch(console.error);
return next();
})
.run();
},
},
captionAction,
{
id: 'e.scale',
content(ctx) {
const model = ctx.getCurrentBlockByType(
EmbedSyncedDocBlockComponent
)?.model;
if (!model) return null;
const scale$ = computed(() =>
Math.round(100 * (model.props.scale$.value ?? 1))
);
const onSelect = (e: CustomEvent<number>) => {
e.stopPropagation();
const scale = e.detail / 100;
const oldScale = model.props.scale ?? 1;
const ratio = scale / oldScale;
const bounds = Bound.deserialize(model.xywh);
bounds.h *= ratio;
bounds.w *= ratio;
const xywh = bounds.serialize();
ctx.store.updateBlock(model, { scale, 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(EmbedSyncedDocModel).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,27 @@
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
import { type BlockSnapshot } from '@blocksuite/store';
export class EdgelessClipboardEmbedSyncedDocConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-synced-doc';
override createBlock(syncedDocEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, caption, scale, pageId, params } =
syncedDocEmbed.props;
const referenceInfo = ReferenceInfoSchema.parse({ pageId, params });
return this.crud.addBlock(
'affine:embed-synced-doc',
{
xywh,
style,
caption,
scale,
...referenceInfo,
},
this.surface.model.id
);
}
}

View File

@@ -0,0 +1,194 @@
import { toEdgelessEmbedBlock } from '@blocksuite/affine-block-embed';
import {
EdgelessCRUDIdentifier,
reassociateConnectorsCommand,
} from '@blocksuite/affine-block-surface';
import type { AliasInfo } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { Bound } from '@blocksuite/global/gfx';
import { BlockStdScope } from '@blocksuite/std';
import { html, nothing } from 'lit';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { EmbedSyncedDocConfigExtension } from './configs';
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block';
export class EmbedEdgelessSyncedDocBlockComponent extends toEdgelessEmbedBlock(
EmbedSyncedDocBlockComponent
) {
protected override _renderSyncedView = () => {
const { syncedDoc, editorMode } = this;
if (!syncedDoc) {
console.error('Synced doc is not found');
return html`${nothing}`;
}
let containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const modelScale = this.model.props.scale ?? 1;
const bound = Bound.deserialize(this.model.xywh);
const width = bound.w / modelScale;
const height = bound.h / modelScale;
containerStyleMap = styleMap({
width: `${width}px`,
height: `${height}px`,
minHeight: `${height}px`,
transform: `scale(${modelScale})`,
transformOrigin: '0 0',
});
const themeService = this.std.get(ThemeProvider);
const themeExtension = this.std.getOptional(ThemeExtensionIdentifier);
const appTheme = themeService.app$.value;
let edgelessTheme = themeService.edgeless$.value;
if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) {
edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value;
}
const theme = this.isPageMode ? appTheme : edgelessTheme;
const scale = this.model.props.scale ?? 1;
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview-page'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview-edgeless'),
}).render()}
</div>
`,
],
]);
};
const header =
this.std
.getOptional(EmbedSyncedDocConfigExtension.identifier)
?.edgelessHeader({
model: this.model,
std: this.std,
}) ?? nothing;
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
surface: true,
selected: this.selected$.value,
})}
@click=${this._handleClick}
style=${containerStyleMap}
?data-scale=${scale}
>
<div class="affine-embed-synced-doc-edgeless-header-wrapper">
${header}
</div>
${when(
!this.model.isFolded,
() =>
html`<div class="affine-embed-synced-doc-editor">
${this.isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard([editorMode, syncedDoc], renderEditor)}
</div>
<div class="affine-embed-synced-doc-editor-overlay"></div>`
)}
</div>
`
);
};
override convertToCard = (aliasInfo?: AliasInfo) => {
const { id, doc, xywh } = this.model;
const { caption } = this.model.props;
const style = 'vertical';
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[style];
bound.h = EMBED_CARD_HEIGHT[style];
const { addBlock } = this.std.get(EdgelessCRUDIdentifier);
const surface = this.gfx.surface ?? undefined;
const newId = addBlock(
'affine:embed-linked-doc',
{
xywh: bound.serialize(),
style,
caption,
...this.referenceInfo,
...aliasInfo,
},
surface
);
this.std.command.exec(reassociateConnectorsCommand, {
oldId: id,
newId,
});
this.gfx.selection.set({
editing: false,
elements: [newId],
});
doc.deleteBlock(this.model);
};
override renderGfxBlock() {
const { style, xywh$ } = this.model.props;
const bound = Bound.deserialize(xywh$.value);
this.embedContainerStyle.width = `${bound.w}px`;
this.embedContainerStyle.height = `${bound.h}px`;
this.cardStyleMap = {
display: 'block',
width: `${EMBED_CARD_WIDTH[style]}px`,
height: `${EMBED_CARD_HEIGHT[style]}px`,
transform: `scale(${bound.w / EMBED_CARD_WIDTH[style]}, ${bound.h / EMBED_CARD_HEIGHT[style]})`,
transformOrigin: '0 0',
};
return this.renderPageContent();
}
override accessor useCaptionEditor = true;
}

View File

@@ -0,0 +1,626 @@
import {
EmbedBlockComponent,
isEmptyDoc,
} from '@blocksuite/affine-block-embed';
import { Peekable } from '@blocksuite/affine-components/peek';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine-ext-loader';
import {
type DocLinkClickedEvent,
RefNodeSlotsProvider,
} from '@blocksuite/affine-inline-reference';
import {
type AliasInfo,
type DocMode,
type EmbedSyncedDocModel,
NoteDisplayMode,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
DocDisplayMetaProvider,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
GeneralSettingSchema,
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { cloneReferenceInfo } from '@blocksuite/affine-shared/utils';
import { Bound, getCommonBound } from '@blocksuite/global/gfx';
import {
BlockSelection,
BlockStdScope,
type EditorHost,
LifeCycleWatcher,
} from '@blocksuite/std';
import { GfxControllerIdentifier, GfxExtension } from '@blocksuite/std/gfx';
import { type GetBlocksOptions, type Query, Text } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { html, nothing, type PropertyValues } from 'lit';
import { query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import type { EmbedSyncedDocCard } from './components/embed-synced-doc-card.js';
import { blockStyles } from './styles.js';
@Peekable({
enableOn: ({ doc }: EmbedSyncedDocBlockComponent) => !doc.readonly,
})
export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSyncedDocModel> {
static override styles = blockStyles;
// Caches total bounds, includes all blocks and elements.
private _cachedBounds: Bound | null = null;
private readonly _initEdgelessFitEffect = () => {
const fitToContent = () => {
if (this.isPageMode) return;
const controller = this.syncedDocEditorHost?.std.getOptional(
GfxControllerIdentifier
);
if (!controller) return;
const viewport = controller.viewport;
if (!viewport) return;
if (!this._cachedBounds) {
this._cachedBounds = getCommonBound([
...controller.layer.blocks.map(block =>
Bound.deserialize(block.xywh)
),
...controller.layer.canvasElements,
]);
}
viewport.onResize();
const { centerX, centerY, zoom } = viewport.getFitToScreenData(
this._cachedBounds
);
viewport.setCenter(centerX, centerY);
viewport.setZoom(zoom);
};
const observer = new ResizeObserver(fitToContent);
const block = this.embedBlock;
observer.observe(block);
this._disposables.add(() => {
observer.disconnect();
});
this.syncedDocEditorHost?.updateComplete
.then(() => fitToContent())
.catch(() => {});
};
private readonly _pageFilter: Query = {
mode: 'loose',
match: [
{
flavour: 'affine:note',
props: {
displayMode: NoteDisplayMode.EdgelessOnly,
},
viewType: 'hidden',
},
],
};
protected _buildPreviewSpec = (name: 'preview-page' | 'preview-edgeless') => {
const nextDepth = this.depth + 1;
const viewExtensionManager = this.std.get(ViewExtensionManagerIdentifier);
const previewSpec = viewExtensionManager.get(name);
const currentDisposables = this.disposables;
const editorSetting = this.std.getOptional(EditorSettingProvider) ?? {
setting$: signal(GeneralSettingSchema.parse({})),
};
class EmbedSyncedDocWatcher extends LifeCycleWatcher {
static override key = 'embed-synced-doc-watcher';
override mounted(): void {
const { view } = this.std;
view.viewUpdated.subscribe(payload => {
if (
payload.type !== 'block' ||
payload.view.model.flavour !== 'affine:embed-synced-doc'
) {
return;
}
const nextComponent = payload.view as EmbedSyncedDocBlockComponent;
if (payload.method === 'add') {
nextComponent.depth = nextDepth;
currentDisposables.add(() => {
nextComponent.depth = 0;
});
return;
}
if (payload.method === 'delete') {
nextComponent.depth = 0;
return;
}
});
}
}
class GfxViewportInitializer extends GfxExtension {
static override key = 'gfx-viewport-initializer';
override mounted(): void {
this.gfx.fitToScreen();
}
}
return previewSpec.concat([
EmbedSyncedDocWatcher,
GfxViewportInitializer,
EditorSettingExtension(editorSetting),
]);
};
protected _renderSyncedView = () => {
const syncedDoc = this.syncedDoc;
const editorMode = this.editorMode;
const isPageMode = this.isPageMode;
if (!syncedDoc) {
console.error('Synced doc is not found');
return html`${nothing}`;
}
if (isPageMode) {
this.dataset.pageMode = '';
}
const containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const themeService = this.std.get(ThemeProvider);
const themeExtension = this.std.getOptional(ThemeExtensionIdentifier);
const appTheme = themeService.app$.value;
let edgelessTheme = themeService.edgeless$.value;
if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) {
edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value;
}
const theme = isPageMode ? appTheme : edgelessTheme;
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview-page'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview-edgeless'),
}).render()}
</div>
`,
],
]);
};
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
surface: false,
selected: this.selected$.value,
'show-hover-border': true,
})}
@click=${this._handleClick}
style=${containerStyleMap}
?data-scale=${undefined}
>
<div class="affine-embed-synced-doc-editor">
${isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard(
[editorMode, syncedDoc, appTheme, edgelessTheme],
renderEditor
)}
</div>
<div
class=${classMap({
'affine-embed-synced-doc-header-wrapper': true,
selected: this.selected$.value,
})}
>
<div class="affine-embed-synced-doc-header">
<span class="affine-embed-synced-doc-icon"
>${this.icon$.value}</span
>
<span class="affine-embed-synced-doc-title">${this.title$}</span>
</div>
</div>
</div>
`
);
};
protected cardStyleMap = styleMap({
position: 'relative',
display: 'block',
width: '100%',
});
convertToCard = (aliasInfo?: AliasInfo) => {
const { doc } = this.model;
const { caption } = this.model.props;
const parent = doc.getParent(this.model);
if (!parent) {
console.error(
`Trying to convert synced doc to card, but the parent is not found.`
);
return;
}
const index = parent.children.indexOf(this.model);
const blockId = doc.addBlock(
'affine:embed-linked-doc',
{ caption, ...this.referenceInfo, ...aliasInfo },
parent,
index
);
doc.deleteBlock(this.model);
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, { blockId }),
]);
};
convertToInline = () => {
const { doc } = this.model;
const parent = doc.getParent(this.model);
if (!parent) {
console.error(
`Trying to convert synced doc to inline, but the parent is not found.`
);
return;
}
const index = parent.children.indexOf(this.model);
const yText = new Y.Text();
yText.insert(0, REFERENCE_NODE);
yText.format(0, REFERENCE_NODE.length, {
reference: {
type: 'LinkedPage',
...this.referenceInfo,
},
});
const text = new Text(yText);
doc.addBlock(
'affine:paragraph',
{
text,
},
parent,
index
);
doc.deleteBlock(this.model);
};
protected override embedContainerStyle: StyleInfo = {
height: 'unset',
};
icon$ = computed(() => {
const { pageId, params } = this.model.props;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, referenced: true }).value;
});
open = (event?: Partial<DocLinkClickedEvent>) => {
const pageId = this.model.props.pageId;
if (pageId === this.doc.id) return;
this.std
.getOptional(RefNodeSlotsProvider)
?.docLinkClicked.next({ ...event, pageId, host: this.host });
};
refreshData = () => {
this._load().catch(e => {
console.error(e);
this._error = true;
});
};
title$ = computed(() => {
const { pageId, params } = this.model.props;
return this.std
.get(DocDisplayMetaProvider)
.title(pageId, { params, referenced: true });
});
get blockState() {
return {
isLoading: this._loading,
isError: this._error,
isDeleted: this._deleted,
isCycle: this._cycle,
};
}
get docTitle() {
return this.syncedDoc?.meta?.title || 'Untitled';
}
get docUpdatedAt() {
return this._docUpdatedAt;
}
get editorMode() {
return this.linkedMode ?? this.syncedDocMode;
}
protected get isPageMode() {
return this.editorMode === 'page';
}
get linkedMode() {
return this.referenceInfo.params?.mode;
}
get referenceInfo(): ReferenceInfo {
return cloneReferenceInfo(this.model.props);
}
get syncedDoc() {
const options: GetBlocksOptions = { readonly: true };
if (this.isPageMode) options.query = this._pageFilter;
const doc = this.std.workspace.getDoc(this.model.props.pageId);
return doc?.getStore(options) ?? null;
}
private _checkCycle() {
let editorHost: EditorHost | null = this.host;
while (editorHost && !this._cycle) {
this._cycle =
!!editorHost && editorHost.doc.id === this.model.props.pageId;
editorHost = editorHost.parentElement?.closest('editor-host') ?? null;
}
}
private _isClickAtBorder(
event: MouseEvent,
element: HTMLElement,
tolerance = 8
): boolean {
const { x, y } = event;
const rect = element.getBoundingClientRect();
if (!rect) {
return false;
}
return (
Math.abs(x - rect.left) < tolerance ||
Math.abs(x - rect.right) < tolerance ||
Math.abs(y - rect.top) < tolerance ||
Math.abs(y - rect.bottom) < tolerance
);
}
private async _load() {
this._loading = true;
this._error = false;
this._deleted = false;
this._cycle = false;
const syncedDoc = this.syncedDoc;
if (!syncedDoc) {
this._deleted = true;
this._loading = false;
return;
}
this._checkCycle();
if (!syncedDoc.loaded) {
try {
syncedDoc.load();
} catch (e) {
console.error(e);
this._error = true;
}
}
if (!this._error && !syncedDoc.root) {
await new Promise<void>(resolve => {
const subscription = syncedDoc.slots.rootAdded.subscribe(() => {
subscription.unsubscribe();
resolve();
});
});
}
this._loading = false;
}
private _selectBlock() {
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
private _setDocUpdatedAt() {
const meta = this.doc.workspace.meta.getDocMeta(this.model.props.pageId);
if (meta) {
const date = meta.updatedDate || meta.createDate;
this._docUpdatedAt = new Date(date);
}
}
protected _handleClick(_event: MouseEvent) {
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.props.style;
this.style.display = 'block';
this._load().catch(e => {
console.error(e);
this._error = true;
});
this.contentEditable = 'false';
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this._error = true;
});
}
})
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.workspace.slots.docListUpdated.subscribe(() => {
this._setDocUpdatedAt();
})
);
if (!this.linkedMode) {
const docMode = this.std.get(DocModeProvider);
this.syncedDocMode = docMode.getPrimaryMode(this.model.props.pageId);
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this.syncedDocMode = mode;
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
}, this.model.props.pageId)
);
}
this.syncedDoc &&
this.disposables.add(
this.syncedDoc.slots.blockUpdated.subscribe(() => {
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
})
);
}
override firstUpdated() {
this.disposables.addFromEvent(this, 'click', e => {
e.stopPropagation();
if (this._isClickAtBorder(e, this)) {
e.preventDefault();
this._selectBlock();
}
});
this._initEdgelessFitEffect();
}
override renderBlock() {
delete this.dataset.nestedEditor;
const syncedDoc = this.syncedDoc;
const { isLoading, isError, isDeleted, isCycle } = this.blockState;
const isCardOnly = this.depth >= 1;
if (
isLoading ||
isError ||
isDeleted ||
isCardOnly ||
isCycle ||
!syncedDoc
) {
return this.renderEmbed(
() => html`
<affine-embed-synced-doc-card
style=${this.cardStyleMap}
.block=${this}
></affine-embed-synced-doc-card>
`
);
}
return this._renderSyncedView();
}
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
this.syncedDocCard?.requestUpdate();
}
@state()
private accessor _cycle = false;
@state()
private accessor _deleted = false;
@state()
private accessor _docUpdatedAt: Date = new Date();
@state()
private accessor _error = false;
@state()
protected accessor _isEmptySyncedDoc: boolean = true;
@state()
private accessor _loading = false;
@state()
accessor depth = 0;
@query(
':scope > .affine-block-component > .embed-block-container > affine-embed-synced-doc-card'
)
accessor syncedDocCard: EmbedSyncedDocCard | null = null;
@query(
':scope > .affine-block-component > .embed-block-container > .affine-embed-synced-doc-container > .affine-embed-synced-doc-editor > div > editor-host'
)
accessor syncedDocEditorHost: EditorHost | null = null;
@state()
accessor syncedDocMode: DocMode = 'page';
override accessor useCaptionEditor = true;
}

View File

@@ -0,0 +1,30 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
const flavour = EmbedSyncedDocBlockSchema.model.flavour;
export const EmbedSyncedDocBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-synced-doc-block`
: literal`affine-embed-synced-doc-block`;
}),
EmbedSyncedDocBlockAdapterExtensions,
createBuiltinToolbarConfigExtension(flavour),
].flat();
export const EmbedSyncedDocViewExtensions: ExtensionType[] = [
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-synced-doc-block`
: literal`affine-embed-synced-doc-block`;
}),
createBuiltinToolbarConfigExtension(flavour),
].flat();

View File

@@ -0,0 +1,6 @@
export * from './adapters';
export * from './configs';
export * from './edgeless-clipboard-config';
export * from './embed-synced-doc-block';
export * from './embed-synced-doc-spec';
export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from '@blocksuite/affine-model';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
import {
EmbedEdgelessIcon,
EmbedPageIcon,
getLoadingIconWith,
ReloadIcon,
} from '@blocksuite/affine-components/icons';
import { ColorScheme } from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import {
DarkSyncedDocDeletedBanner,
DarkSyncedDocEmptyBanner,
DarkSyncedDocErrorBanner,
LightSyncedDocDeletedBanner,
LightSyncedDocEmptyBanner,
LightSyncedDocErrorBanner,
SyncedDocDeletedIcon,
SyncedDocErrorIcon,
} from './styles.js';
type SyncedCardImages = {
LoadingIcon: TemplateResult<1>;
SyncedDocIcon: TemplateResult<1>;
SyncedDocErrorIcon: TemplateResult<1>;
SyncedDocDeletedIcon: TemplateResult<1>;
ReloadIcon: TemplateResult<1>;
SyncedDocEmptyBanner: TemplateResult<1>;
SyncedDocErrorBanner: TemplateResult<1>;
SyncedDocDeletedBanner: TemplateResult<1>;
};
export function getSyncedDocIcons(
theme: ColorScheme,
editorMode: 'page' | 'edgeless'
): SyncedCardImages {
const LoadingIcon = getLoadingIconWith(theme);
if (theme === ColorScheme.Light) {
return {
LoadingIcon,
SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon,
SyncedDocErrorIcon,
SyncedDocDeletedIcon,
ReloadIcon,
SyncedDocEmptyBanner: LightSyncedDocEmptyBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
SyncedDocDeletedBanner: LightSyncedDocDeletedBanner,
};
} else {
return {
LoadingIcon,
SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon,
SyncedDocErrorIcon,
SyncedDocDeletedIcon,
ReloadIcon,
SyncedDocEmptyBanner: DarkSyncedDocEmptyBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
SyncedDocDeletedBanner: DarkSyncedDocDeletedBanner,
};
}
}

View File

@@ -0,0 +1,2 @@
export * from './embed-linked-doc-block';
export * from './embed-synced-doc-block';

View File

@@ -0,0 +1,25 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import {
EmbedLinkedDocBlockSchemaExtension,
EmbedSyncedDocBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { EmbedLinkedDocBlockAdapterExtensions } from './embed-linked-doc-block/adapters/extension';
import { EmbedSyncedDocBlockAdapterExtensions } from './embed-synced-doc-block/adapters/extension';
export class EmbedDocStoreExtension extends StoreExtensionProvider {
override name = 'affine-embed-doc-block';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register([
EmbedSyncedDocBlockSchemaExtension,
EmbedLinkedDocBlockSchemaExtension,
]);
context.register(EmbedLinkedDocBlockAdapterExtensions);
context.register(EmbedSyncedDocBlockAdapterExtensions);
}
}

View File

@@ -0,0 +1,36 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import {
EdgelessClipboardEmbedLinkedDocConfig,
EmbedLinkedDocViewExtensions,
} from './embed-linked-doc-block';
import {
EdgelessClipboardEmbedSyncedDocConfig,
EmbedSyncedDocViewExtensions,
} from './embed-synced-doc-block';
export class EmbedDocViewExtension extends ViewExtensionProvider {
override name = 'affine-embed-doc-block';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(EmbedLinkedDocViewExtensions);
context.register(EmbedSyncedDocViewExtensions);
const isEdgeless = this.isEdgeless(context.scope);
if (isEdgeless) {
context.register([
EdgelessClipboardEmbedLinkedDocConfig,
EdgelessClipboardEmbedSyncedDocConfig,
]);
}
}
}