Files
AFFiNE-Mirror/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts
Saul-Mirone fe5f0f62ec feat(editor): rich text package (#10689)
This PR performs a significant architectural refactoring by extracting rich text functionality into a dedicated package. Here are the key changes:

1. **New Package Creation**
- Created a new package `@blocksuite/affine-rich-text` to house rich text related functionality
- Moved rich text components, utilities, and types from `@blocksuite/affine-components` to this new package

2. **Dependency Updates**
- Updated multiple block packages to include the new `@blocksuite/affine-rich-text` as a direct dependency:
  - block-callout
  - block-code
  - block-database
  - block-edgeless-text
  - block-embed
  - block-list
  - block-note
  - block-paragraph

3. **Import Path Updates**
- Refactored all imports that previously referenced rich text functionality from `@blocksuite/affine-components/rich-text` to now use `@blocksuite/affine-rich-text`
- Updated imports for components like:
  - DefaultInlineManagerExtension
  - RichText types and interfaces
  - Text manipulation utilities (focusTextModel, textKeymap, etc.)
  - Reference node components and providers

4. **Build Configuration Updates**
- Added references to the new rich text package in the `tsconfig.json` files of all affected packages
- Maintained workspace dependencies using the `workspace:*` version specifier

The primary motivation appears to be:
1. Better separation of concerns by isolating rich text functionality
2. Improved maintainability through more modular package structure
3. Clearer dependencies between packages
4. Potential for better tree-shaking and bundle optimization

This is primarily an architectural improvement that should make the codebase more maintainable and better organized.
2025-03-07 04:08:47 +00:00

625 lines
16 KiB
TypeScript

import { Peekable } from '@blocksuite/affine-components/peek';
import {
type AliasInfo,
type DocMode,
type EmbedSyncedDocModel,
NoteDisplayMode,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import {
type DocLinkClickedEvent,
RefNodeSlotsProvider,
} from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
DocDisplayMetaProvider,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
GeneralSettingSchema,
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfo,
SpecProvider,
} from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
BlockStdScope,
type EditorHost,
LifeCycleWatcher,
} from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
GfxExtension,
} from '@blocksuite/block-std/gfx';
import { Bound, getCommonBound } from '@blocksuite/global/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 { 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: 'hidden',
},
],
};
protected _buildPreviewSpec = (name: 'preview:page' | 'preview:edgeless') => {
const nextDepth = this.depth + 1;
const previewSpecBuilder = SpecProvider._.getSpec(name);
const currentDisposables = this.disposables;
const editorSetting =
this.std.getOptional(EditorSettingProvider) ??
signal(GeneralSettingSchema.parse({}));
class EmbedSyncedDocWatcher extends LifeCycleWatcher {
static override key = 'embed-synced-doc-watcher';
override mounted(): void {
const { view } = this.std;
view.viewUpdated.on(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();
}
}
previewSpecBuilder.extend([
EmbedSyncedDocWatcher,
GfxViewportInitializer,
EditorSettingExtension(editorSetting),
]);
return previewSpecBuilder.value;
};
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, caption } = this.model;
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 }),
]);
};
covertToInline = () => {
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;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, referenced: true }).value;
});
open = (event?: Partial<DocLinkClickedEvent>) => {
const pageId = this.model.pageId;
if (pageId === this.doc.id) return;
this.std
.getOptional(RefNodeSlotsProvider)
?.docLinkClicked.emit({ ...event, pageId, host: this.host });
};
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 });
});
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('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.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.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.linkedMode) {
const docMode = this.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;
}