chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -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.collection.getDoc(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);
@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './plain-text.js';
@@ -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.collection.getDoc(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 EmbedSyncedDocBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedSyncedDocBlockMarkdownAdapterMatcher);
@@ -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.collection.getDoc(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);
@@ -0,0 +1,251 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { isGfxBlockComponent, ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
import { property, queryAsync } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { renderLinkedDocInCard } from '../../common/render-linked-doc.js';
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('block', {
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 {
syncedDoc.slots.rootAdded.once(() => {
renderLinkedDocInCard(this);
});
}
this.disposables.add(
syncedDoc.collection.meta.docMetaUpdated.on(() => {
renderLinkedDocInCard(this);
})
);
this.disposables.add(
syncedDoc.slots.blockUpdated.on(payload => {
if (this._dragging) {
return;
}
if (
payload.type === 'update' &&
['', 'caption', 'xywh'].includes(payload.props.key)
) {
return;
}
renderLinkedDocInCard(this);
})
);
}
}
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>;
}
@@ -0,0 +1,180 @@
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 { BlockStdScope } from '@blocksuite/block-std';
import { assertExists, Bound } from '@blocksuite/global/utils';
import { html } 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 { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block.js';
export class EmbedEdgelessSyncedDocBlockComponent extends toEdgelessEmbedBlock(
EmbedSyncedDocBlockComponent
) {
protected override _renderSyncedView = () => {
const { syncedDoc, editorMode } = this;
assertExists(syncedDoc, 'Doc should exist');
let containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const modelScale = this.model.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 isSelected = !!this.selected?.is('block');
const scale = this.model.scale ?? 1;
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('page:preview'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('edgeless:preview'),
}).render()}
</div>
`,
],
]);
};
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
selected: isSelected,
surface: true,
})}
@click=${this._handleClick}
style=${containerStyleMap}
?data-scale=${scale}
>
<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, caption, xywh } = this.model;
const edgelessService = this.rootService;
const style = 'vertical';
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[style];
bound.h = EMBED_CARD_HEIGHT[style];
if (!edgelessService) {
return;
}
// @ts-expect-error TODO: fix after edgeless refactor
const newId = edgelessService.addBlock(
'affine:embed-linked-doc',
{
xywh: bound.serialize(),
style,
caption,
...this.referenceInfo,
...aliasInfo,
},
// @ts-expect-error TODO: fix after edgeless refactor
edgelessService.surface
);
this.std.command.exec('reassociateConnectors', {
oldId: id,
newId,
});
// @ts-expect-error TODO: fix after edgeless refactor
edgelessService.selection.set({
editing: false,
elements: [newId],
});
doc.deleteBlock(this.model);
};
get rootService() {
return this.std.getService('affine:page');
}
override renderGfxBlock() {
const { style, xywh } = this.model;
const bound = Bound.deserialize(xywh);
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_WIDTH[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;
}
@@ -0,0 +1,596 @@
import { Peekable } from '@blocksuite/affine-components/peek';
import {
REFERENCE_NODE,
RefNodeSlotsProvider,
} from '@blocksuite/affine-components/rich-text';
import {
type AliasInfo,
type DocMode,
type EmbedSyncedDocModel,
NoteDisplayMode,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import {
DocDisplayMetaProvider,
DocModeProvider,
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfo,
SpecProvider,
} from '@blocksuite/affine-shared/utils';
import {
BlockServiceWatcher,
BlockStdScope,
type EditorHost,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { assertExists, Bound, getCommonBound } from '@blocksuite/global/utils';
import {
BlockViewType,
DocCollection,
type GetDocOptions,
type Query,
} from '@blocksuite/store';
import { computed } from '@preact/signals-core';
import { html, 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 { EmbedBlockComponent } from '../common/embed-block-element.js';
import { isEmptyDoc } from '../common/render-linked-doc.js';
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 _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 _pageFilter: Query = {
mode: 'loose',
match: [
{
flavour: 'affine:note',
props: {
displayMode: NoteDisplayMode.EdgelessOnly,
},
viewType: BlockViewType.Hidden,
},
],
};
protected _buildPreviewSpec = (name: 'page:preview' | 'edgeless:preview') => {
const nextDepth = this.depth + 1;
const previewSpecBuilder = SpecProvider.getInstance().getSpec(name);
const currentDisposables = this.disposables;
class EmbedSyncedDocWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:embed-synced-doc';
override mounted() {
const disposableGroup = this.blockService.disposables;
const slots = this.blockService.specSlots;
disposableGroup.add(
slots.viewConnected.on(({ component }) => {
const nextComponent = component as EmbedSyncedDocBlockComponent;
nextComponent.depth = nextDepth;
currentDisposables.add(() => {
nextComponent.depth = 0;
});
})
);
disposableGroup.add(
slots.viewDisconnected.on(({ component }) => {
const nextComponent = component as EmbedSyncedDocBlockComponent;
nextComponent.depth = 0;
})
);
}
}
previewSpecBuilder.extend([EmbedSyncedDocWatcher]);
return previewSpecBuilder.value;
};
protected _renderSyncedView = () => {
const syncedDoc = this.syncedDoc;
const editorMode = this.editorMode;
const isPageMode = this.isPageMode;
assertExists(syncedDoc);
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;
const isSelected = !!this.selected?.is('block');
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('page:preview'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('edgeless:preview'),
}).render()}
</div>
`,
],
]);
};
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
selected: isSelected,
surface: false,
})}
@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], renderEditor)}
</div>
<div
class=${classMap({
'affine-embed-synced-doc-header-wrapper': true,
selected: isSelected,
})}
>
<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, caption } = this.model;
const parent = doc.getParent(this.model);
assertExists(parent);
const index = parent.children.indexOf(this.model);
doc.addBlock(
'affine:embed-linked-doc',
{ caption, ...this.referenceInfo, ...aliasInfo },
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(this.model);
};
covertToInline = () => {
const { doc } = this.model;
const parent = doc.getParent(this.model);
assertExists(parent);
const index = parent.children.indexOf(this.model);
const yText = new DocCollection.Y.Text();
yText.insert(0, REFERENCE_NODE);
yText.format(0, REFERENCE_NODE.length, {
reference: {
type: 'LinkedPage',
...this.referenceInfo,
},
});
const text = new doc.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;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, referenced: true }).value;
});
open = () => {
const pageId = this.model.pageId;
if (pageId === this.doc.id) return;
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({ pageId });
};
refreshData = () => {
this._load().catch(e => {
console.error(e);
this._error = true;
});
};
title$ = computed(() => {
const { pageId, params } = this.model;
return this.std
.get(DocDisplayMetaProvider)
.title(pageId, { params, referenced: true });
});
private get _rootService() {
return this.std.getService('affine:page');
}
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);
}
get syncedDoc() {
const options: GetDocOptions = { readonly: true };
if (this.isPageMode) options.query = this._pageFilter;
return this.std.collection.getDoc(this.model.pageId, options);
}
private _checkCycle() {
let editorHost: EditorHost | null = this.host;
while (editorHost && !this._cycle) {
this._cycle = !!editorHost && editorHost.doc.id === this.model.pageId;
editorHost =
editorHost.parentElement?.closest<EditorHost>('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 => {
syncedDoc.slots.rootAdded.once(() => resolve());
});
}
this._loading = false;
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
private _setDocUpdatedAt() {
const meta = this.doc.collection.meta.getDocMeta(this.model.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.style;
this.style.display = 'block';
this._load().catch(e => {
console.error(e);
this._error = true;
});
this.contentEditable = 'false';
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this._error = true;
});
}
})
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.collection.meta.docMetaUpdated.on(() => {
this._setDocUpdatedAt();
})
);
if (this._rootService && !this.linkedMode) {
const docMode = this._rootService.std.get(DocModeProvider);
this.syncedDocMode = docMode.getPrimaryMode(this.model.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.pageId)
);
}
this.syncedDoc &&
this.disposables.add(
this.syncedDoc.slots.blockUpdated.on(() => {
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();
}
});
// Forward docLinkClicked event from the synced doc
const refNodeProvider =
this.syncedDocEditorHost?.std.getOptional(RefNodeSlotsProvider);
if (refNodeProvider) {
this.disposables.add(
refNodeProvider.docLinkClicked.on(args => {
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit(args);
})
);
}
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;
}
@@ -0,0 +1,6 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import { BlockService } from '@blocksuite/block-std';
export class EmbedSyncedDocBlockService extends BlockService {
static override readonly flavour = EmbedSyncedDocBlockSchema.model.flavour;
}
@@ -0,0 +1,18 @@
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { EmbedSyncedDocBlockService } from './embed-synced-doc-service.js';
export const EmbedSyncedDocBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-synced-doc'),
EmbedSyncedDocBlockService,
BlockViewExtension('affine:embed-synced-doc', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-synced-doc-block`
: literal`affine-embed-synced-doc-block`;
}),
];
@@ -0,0 +1,4 @@
export * from './adapters/index.js';
export * from './embed-synced-doc-block.js';
export * from './embed-synced-doc-spec.js';
export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from './styles.js';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import {
DarkLoadingIcon,
EmbedEdgelessIcon,
EmbedPageIcon,
LightLoadingIcon,
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 {
if (theme === ColorScheme.Light) {
return {
LoadingIcon: LightLoadingIcon,
SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon,
SyncedDocErrorIcon,
SyncedDocDeletedIcon,
ReloadIcon,
SyncedDocEmptyBanner: LightSyncedDocEmptyBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
SyncedDocDeletedBanner: LightSyncedDocDeletedBanner,
};
} else {
return {
LoadingIcon: DarkLoadingIcon,
SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon,
SyncedDocErrorIcon,
SyncedDocDeletedIcon,
ReloadIcon,
SyncedDocEmptyBanner: DarkSyncedDocEmptyBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
SyncedDocDeletedBanner: DarkSyncedDocDeletedBanner,
};
}
}