Files
AFFiNE-Mirror/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts
2025-01-04 12:51:56 +00:00

585 lines
15 KiB
TypeScript

import { Peekable } from '@blocksuite/affine-components/peek';
import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text';
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,
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,
type GetBlocksOptions,
type Query,
Text,
} 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 * as Y from 'yjs';
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 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: 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 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;
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: GetBlocksOptions = { readonly: true };
if (this.isPageMode) options.query = this._pageFilter;
return this.std.workspace.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.workspace.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.workspace.slots.docListUpdated.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();
}
});
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;
}