mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
585 lines
15 KiB
TypeScript
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;
|
|
}
|