mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 00:06:09 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user