mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
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.
625 lines
16 KiB
TypeScript
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;
|
|
}
|