refactor(editor): extensionalize plain text adapter (#9330)

This commit is contained in:
donteatfriedrice
2024-12-26 07:27:07 +00:00
parent 45acdbda04
commit cadb9211a6
27 changed files with 226 additions and 120 deletions

View File

@@ -42,9 +42,13 @@ export {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
BlockPlainTextAdapterMatcherIdentifier,
InlineDeltaToPlainTextAdapterExtension,
type InlineDeltaToPlainTextAdapterMatcher,
InlineDeltaToPlainTextAdapterMatcherIdentifier,
type PlainText,
PlainTextAdapter,
PlainTextAdapterFactoryExtension,
PlainTextAdapterFactoryIdentifier,
PlainTextDeltaConverter,
} from './plain-text';
export {

View File

@@ -1,4 +1,8 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { DeltaInsert } from '@blocksuite/inline';
import type { AffineTextAttributes } from '../../types/index.js';
@@ -17,6 +21,22 @@ export const InlineDeltaToPlainTextAdapterMatcherIdentifier =
'InlineDeltaToPlainTextAdapterMatcher'
);
export function InlineDeltaToPlainTextAdapterExtension(
matcher: InlineDeltaToPlainTextAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<InlineDeltaToPlainTextAdapterMatcher>;
} {
const identifier = InlineDeltaToPlainTextAdapterMatcherIdentifier(
matcher.name
);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export type PlainTextASTToDeltaMatcher = ASTToDeltaMatcher<string>;
export class PlainTextDeltaConverter extends DeltaASTConverter<

View File

@@ -1,3 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './type.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,331 @@
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import type { ServiceProvider } from '@blocksuite/global/di';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
} from '@blocksuite/store';
import {
type AdapterContext,
AdapterFactoryIdentifier,
type TextBuffer,
} from '../types';
import {
type BlockPlainTextAdapterMatcher,
BlockPlainTextAdapterMatcherIdentifier,
} from './block-adapter';
import {
InlineDeltaToPlainTextAdapterMatcherIdentifier,
PlainTextDeltaConverter,
} from './delta-converter';
export type PlainText = string;
type PlainTextToSliceSnapshotPayload = {
file: PlainText;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
export class PlainTextAdapter extends BaseAdapter<PlainText> {
deltaConverter: PlainTextDeltaConverter;
readonly blockMatchers: BlockPlainTextAdapterMatcher[];
constructor(
job: Job,
readonly provider: ServiceProvider
) {
super(job);
const blockMatchers = Array.from(
provider.getAll(BlockPlainTextAdapterMatcherIdentifier).values()
);
const inlineDeltaToPlainTextAdapterMatchers = Array.from(
provider.getAll(InlineDeltaToPlainTextAdapterMatcherIdentifier).values()
);
this.blockMatchers = blockMatchers;
this.deltaConverter = new PlainTextDeltaConverter(
job.adapterConfigs,
inlineDeltaToPlainTextAdapterMatchers,
[]
);
}
private async _traverseSnapshot(
snapshot: BlockSnapshot
): Promise<{ plaintext: string }> {
const textBuffer: TextBuffer = {
content: '',
};
const walker = new ASTWalker<BlockSnapshot, TextBuffer>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer,
};
await matcher.fromBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer,
};
await matcher.fromBlockSnapshot.leave?.(o, adapterContext);
}
}
});
await walker.walkONode(snapshot);
return {
plaintext: textBuffer.content,
};
}
async fromBlockSnapshot({
snapshot,
}: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<PlainText>> {
const { plaintext } = await this._traverseSnapshot(snapshot);
return {
file: plaintext,
assetsIds: [],
};
}
async fromDocSnapshot({
snapshot,
assets,
}: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<PlainText>> {
let buffer = '';
if (snapshot.meta.title) {
buffer += `${snapshot.meta.title}\n\n`;
}
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: snapshot.blocks,
assets,
});
buffer += file;
return {
file: buffer,
assetsIds,
};
}
async fromSliceSnapshot({
snapshot,
}: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<PlainText>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of snapshot.content) {
const { plaintext } = await this._traverseSnapshot(contentSlice);
buffer += plaintext;
}
const plaintext =
buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer;
return {
file: plaintext,
assetsIds: sliceAssetsIds,
};
}
toBlockSnapshot(payload: ToBlockSnapshotPayload<PlainText>): BlockSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
};
}
toDocSnapshot(payload: ToDocSnapshotPayload<PlainText>): DocSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'page',
meta: {
id: nanoid(),
title: 'Untitled',
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Untitled',
},
],
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
{
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
},
],
},
};
}
toSliceSnapshot(
payload: PlainTextToSliceSnapshotPayload
): SliceSnapshot | null {
if (payload.file.trim().length === 0) {
return null;
}
payload.file = payload.file.replaceAll('\r', '');
const contentSlice = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
} as BlockSnapshot;
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const PlainTextAdapterFactoryIdentifier =
AdapterFactoryIdentifier('PlainText');
export const PlainTextAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(PlainTextAdapterFactoryIdentifier, provider => ({
get: (job: Job) => new PlainTextAdapter(job, provider),
}));
},
};

View File

@@ -1 +0,0 @@
export type PlainText = string;

View File

@@ -1,4 +1,4 @@
import { createIdentifier } from '@blocksuite/global/di';
import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di';
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/inline';
import {
type AssetsManager,
@@ -38,6 +38,7 @@ export type AdapterContext<
job: Job;
deltaConverter: TConverter;
textBuffer: TextBuffer;
provider?: ServiceProvider;
assets?: AssetsManager;
pageMap?: Map<string, string>;
updateAssetIds?: (assetsId: string) => void;