feat(editor): type safe draft model and transformer (#10486)

This commit is contained in:
Saul-Mirone
2025-02-27 09:19:49 +00:00
parent 272d41e32d
commit 4c736bc190
15 changed files with 125 additions and 48 deletions

View File

@@ -304,11 +304,12 @@ export function getDocContentWithMaxLength(doc: Store, maxlength = 500) {
export function getTitleFromSelectedModels(selectedModels: DraftModel[]) { export function getTitleFromSelectedModels(selectedModels: DraftModel[]) {
const firstBlock = selectedModels[0]; const firstBlock = selectedModels[0];
if ( const isParagraph = (
matchModels(firstBlock, [ParagraphBlockModel]) && model: DraftModel
firstBlock.type.startsWith('h') ): model is DraftModel<ParagraphBlockModel> =>
) { model.flavour === 'affine:paragraph';
return firstBlock.text.toString(); if (isParagraph(firstBlock) && firstBlock.type.startsWith('h')) {
return firstBlock.text?.toString();
} }
return undefined; return undefined;
} }
@@ -394,7 +395,7 @@ export async function convertSelectedBlocksToLinkedDoc(
'before' 'before'
); );
// delete selected elements // delete selected elements
models.forEach(model => doc.deleteBlock(model)); models.forEach(model => doc.deleteBlock(model.id));
return linkedDoc; return linkedDoc;
} }

View File

@@ -9,6 +9,7 @@ import {
getSelectedModelsCommand, getSelectedModelsCommand,
} from '@blocksuite/affine-shared/commands'; } from '@blocksuite/affine-shared/commands';
import type { BlockStdScope } from '@blocksuite/block-std'; import type { BlockStdScope } from '@blocksuite/block-std';
import { toDraftModel } from '@blocksuite/store';
export interface QuickActionConfig { export interface QuickActionConfig {
id: string; id: string;
@@ -45,7 +46,9 @@ export const quickActionConfig: QuickActionConfig[] = [
std.selection.clear(); std.selection.clear();
const doc = std.store; const doc = std.store;
const autofill = getTitleFromSelectedModels(selectedModels); const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill) promptDocTitle(std, autofill)
.then(title => { .then(title => {
if (title === null) return; if (title === null) return;

View File

@@ -17,6 +17,7 @@ import {
type UIEventHandler, type UIEventHandler,
} from '@blocksuite/block-std'; } from '@blocksuite/block-std';
import { IS_MAC, IS_WINDOWS } from '@blocksuite/global/env'; import { IS_MAC, IS_WINDOWS } from '@blocksuite/global/env';
import { toDraftModel } from '@blocksuite/store';
export class PageKeyboardManager { export class PageKeyboardManager {
private readonly _handleDelete: UIEventHandler = ctx => { private readonly _handleDelete: UIEventHandler = ctx => {
@@ -143,7 +144,9 @@ export class PageKeyboardManager {
} }
const doc = rootComponent.host.doc; const doc = rootComponent.host.doc;
const autofill = getTitleFromSelectedModels(selectedModels); const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(rootComponent.std, autofill) promptDocTitle(rootComponent.std, autofill)
.then(title => { .then(title => {
if (title === null) return; if (title === null) return;

View File

@@ -64,7 +64,7 @@ import type {
import { tableViewMeta } from '@blocksuite/data-view/view-presets'; import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit'; import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store'; import { Slice, toDraftModel } from '@blocksuite/store';
import { html, type TemplateResult } from 'lit'; import { html, type TemplateResult } from 'lit';
import { FormatBarContext } from './context.js'; import { FormatBarContext } from './context.js';
@@ -230,7 +230,9 @@ export function toolbarDefaultConfig(toolbar: AffineFormatBarWidget) {
host.selection.clear(); host.selection.clear();
const doc = host.doc; const doc = host.doc;
const autofill = getTitleFromSelectedModels(selectedModels); const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill) promptDocTitle(std, autofill)
.then(async title => { .then(async title => {
if (title === null) return; if (title === null) return;

View File

@@ -1,4 +1,4 @@
import { RootBlockModel } from '@blocksuite/affine-model'; import type { RootBlockModel } from '@blocksuite/affine-model';
import { import {
type BlockStdScope, type BlockStdScope,
type EditorHost, type EditorHost,
@@ -12,7 +12,9 @@ import type {
TransformerSlots, TransformerSlots,
} from '@blocksuite/store'; } from '@blocksuite/store';
import { matchModels } from '../../utils'; const isRootDraftModel = (
model: DraftModel
): model is DraftModel<RootBlockModel> => model.flavour === 'affine:root';
const handlePoint = ( const handlePoint = (
point: TextRangePoint, point: TextRangePoint,
@@ -20,7 +22,7 @@ const handlePoint = (
model: DraftModel model: DraftModel
) => { ) => {
const { index, length } = point; const { index, length } = point;
if (matchModels(model, [RootBlockModel])) { if (isRootDraftModel(model)) {
if (length === 0) return; if (length === 0) return;
(snapshot.props.title as Record<string, unknown>).delta = (snapshot.props.title as Record<string, unknown>).delta =
model.title.sliceToDelta(index, length + index); model.title.sliceToDelta(index, length + index);

View File

@@ -11,7 +11,7 @@ type ModelList<T> =
export function matchModels< export function matchModels<
const Model extends ConstructorType<BlockModel>[], const Model extends ConstructorType<BlockModel>[],
U extends ModelList<Model>[number] = ModelList<Model>[number], U extends ModelList<Model>[number] = ModelList<Model>[number],
>(model: unknown, expected: Model): model is U { >(model: BlockModel | null, expected: Model): model is U {
return ( return (
!!model && expected.some(expectedModel => model instanceof expectedModel) !!model && expected.some(expectedModel => model instanceof expectedModel)
); );

View File

@@ -1139,7 +1139,7 @@ export class DragEventWatcher {
block.flavour === 'affine:bookmark' || block.flavour === 'affine:bookmark' ||
block.flavour.startsWith('affine:embed-')) block.flavour.startsWith('affine:embed-'))
) { ) {
store.updateBlock(block as BlockModel, { store.updateBlock(block.id, {
xywh: content[idx].props.xywh, xywh: content[idx].props.xywh,
style: content[idx].props.style, style: content[idx].props.style,
}); });
@@ -1164,7 +1164,7 @@ export class DragEventWatcher {
block.flavour === 'affine:attachment' || block.flavour === 'affine:attachment' ||
block.flavour.startsWith('affine:embed-') block.flavour.startsWith('affine:embed-')
) { ) {
store.updateBlock(block as BlockModel, { store.updateBlock(block.id, {
xywh: content[idx].props.xywh, xywh: content[idx].props.xywh,
style: content[idx].props.style, style: content[idx].props.style,
}); });

View File

@@ -1,6 +1,11 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions'; import { BlockSuiteError } from '@blocksuite/global/exceptions';
import type { DraftModel, Store } from '../model/index.js'; import {
BlockModel,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index.js';
import type { AssetsManager } from '../transformer/assets.js'; import type { AssetsManager } from '../transformer/assets.js';
import type { Slice, Transformer } from '../transformer/index.js'; import type { Slice, Transformer } from '../transformer/index.js';
import type { import type {
@@ -72,9 +77,11 @@ export abstract class BaseAdapter<AdapterTarget = unknown> {
this.job = job; this.job = job;
} }
async fromBlock(model: DraftModel) { async fromBlock(model: BlockModel | DraftModel) {
try { try {
const blockSnapshot = this.job.blockToSnapshot(model); const draftModel =
model instanceof BlockModel ? toDraftModel(model) : model;
const blockSnapshot = this.job.blockToSnapshot(draftModel);
if (!blockSnapshot) return; if (!blockSnapshot) return;
return await this.fromBlockSnapshot({ return await this.fromBlockSnapshot({
snapshot: blockSnapshot, snapshot: blockSnapshot,

View File

@@ -4,12 +4,16 @@ type PropsInDraft = 'version' | 'flavour' | 'role' | 'id' | 'keys' | 'text';
type ModelProps<Model> = Model extends BlockModel<infer U> ? U : never; type ModelProps<Model> = Model extends BlockModel<infer U> ? U : never;
const draftModelSymbol = Symbol('draftModel');
export type DraftModel<Model extends BlockModel = BlockModel> = Pick< export type DraftModel<Model extends BlockModel = BlockModel> = Pick<
Model, Model,
PropsInDraft PropsInDraft
> & { > & {
children: DraftModel[]; children: DraftModel[];
} & ModelProps<Model>; } & ModelProps<Model> & {
[draftModelSymbol]: true;
};
export function toDraftModel<Model extends BlockModel = BlockModel>( export function toDraftModel<Model extends BlockModel = BlockModel>(
origin: Model origin: Model

View File

@@ -16,7 +16,6 @@ import {
type BlockModel, type BlockModel,
type BlockOptions, type BlockOptions,
type BlockProps, type BlockProps,
type DraftModel,
} from '../block/index.js'; } from '../block/index.js';
import type { Doc } from '../doc.js'; import type { Doc } from '../doc.js';
import { DocCRUD } from './crud.js'; import { DocCRUD } from './crud.js';
@@ -100,10 +99,10 @@ export class Store {
}; };
updateBlock: { updateBlock: {
<T extends Partial<BlockProps>>(model: BlockModel, props: T): void; <T extends Partial<BlockProps>>(model: BlockModel | string, props: T): void;
(model: BlockModel, callback: () => void): void; (model: BlockModel | string, callback: () => void): void;
} = ( } = (
model: BlockModel, modelOrId: BlockModel | string,
callBackOrProps: (() => void) | Partial<BlockProps> callBackOrProps: (() => void) | Partial<BlockProps>
) => { ) => {
if (this.readonly) { if (this.readonly) {
@@ -113,6 +112,17 @@ export class Store {
const isCallback = typeof callBackOrProps === 'function'; const isCallback = typeof callBackOrProps === 'function';
const model =
typeof modelOrId === 'string'
? this.getBlock(modelOrId)?.model
: modelOrId;
if (!model) {
throw new BlockSuiteError(
ErrorCode.ModelCRUDError,
`updating block: ${modelOrId} not found`
);
}
if (!isCallback) { if (!isCallback) {
const parent = this.getParent(model); const parent = this.getParent(model);
this.schema.validate( this.schema.validate(
@@ -549,7 +559,7 @@ export class Store {
} }
deleteBlock( deleteBlock(
model: DraftModel, model: BlockModel | string,
options: { options: {
bringChildrenTo?: BlockModel; bringChildrenTo?: BlockModel;
deleteChildren?: boolean; deleteChildren?: boolean;
@@ -575,7 +585,10 @@ export class Store {
}; };
this.transact(() => { this.transact(() => {
this._crud.deleteBlock(model.id, opts); this._crud.deleteBlock(
typeof model === 'string' ? model : model.id,
opts
);
}); });
} }

View File

@@ -1,6 +1,6 @@
import type { Slot } from '@blocksuite/global/utils'; import type { Slot } from '@blocksuite/global/utils';
import type { DraftModel, Store } from '../model/index.js'; import type { BlockModel, DraftModel, Store } from '../model/index.js';
import type { AssetsManager } from './assets.js'; import type { AssetsManager } from './assets.js';
import type { Slice } from './slice.js'; import type { Slice } from './slice.js';
import type { import type {
@@ -48,7 +48,7 @@ export type BeforeExportPayload =
type: 'info'; type: 'info';
}; };
export type FinalPayload = export type AfterExportPayload =
| { | {
snapshot: BlockSnapshot; snapshot: BlockSnapshot;
type: 'block'; type: 'block';
@@ -71,11 +71,34 @@ export type FinalPayload =
type: 'info'; type: 'info';
}; };
export type AfterImportPayload =
| {
snapshot: BlockSnapshot;
type: 'block';
model: BlockModel;
parent?: string;
index?: number;
}
| {
snapshot: DocSnapshot;
type: 'page';
page: Store;
}
| {
snapshot: SliceSnapshot;
type: 'slice';
slice: Slice;
}
| {
snapshot: CollectionInfoSnapshot;
type: 'info';
};
export type TransformerSlots = { export type TransformerSlots = {
beforeImport: Slot<BeforeImportPayload>; beforeImport: Slot<BeforeImportPayload>;
afterImport: Slot<FinalPayload>; afterImport: Slot<AfterImportPayload>;
beforeExport: Slot<BeforeExportPayload>; beforeExport: Slot<BeforeExportPayload>;
afterExport: Slot<FinalPayload>; afterExport: Slot<AfterExportPayload>;
}; };
type TransformerMiddlewareOptions = { type TransformerMiddlewareOptions = {

View File

@@ -1,4 +1,9 @@
import type { DraftModel, Store } from '../model/index'; import {
BlockModel,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index';
type SliceData = { type SliceData = {
content: DraftModel[]; content: DraftModel[];
@@ -21,9 +26,15 @@ export class Slice {
constructor(readonly data: SliceData) {} constructor(readonly data: SliceData) {}
static fromModels(doc: Store, models: DraftModel[]) { static fromModels(doc: Store, models: DraftModel[] | BlockModel[]) {
const draftModels = models.map(model => {
if (model instanceof BlockModel) {
return toDraftModel(model);
}
return model;
});
return new Slice({ return new Slice({
content: models, content: draftModels,
workspaceId: doc.workspace.id, workspaceId: doc.workspace.id,
pageId: doc.id, pageId: doc.id,
}); });

View File

@@ -1,19 +1,21 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { nextTick, Slot } from '@blocksuite/global/utils'; import { nextTick, Slot } from '@blocksuite/global/utils';
import type { import {
BlockModel, BlockModel,
BlockSchemaType, type BlockSchemaType,
DraftModel, type DraftModel,
Store, type Store,
toDraftModel,
} from '../model/index.js'; } from '../model/index.js';
import type { Schema } from '../schema/index.js'; import type { Schema } from '../schema/index.js';
import { AssetsManager } from './assets.js'; import { AssetsManager } from './assets.js';
import { BaseBlockTransformer } from './base.js'; import { BaseBlockTransformer } from './base.js';
import type { import type {
AfterExportPayload,
AfterImportPayload,
BeforeExportPayload, BeforeExportPayload,
BeforeImportPayload, BeforeImportPayload,
FinalPayload,
TransformerMiddleware, TransformerMiddleware,
TransformerSlots, TransformerSlots,
} from './middleware.js'; } from './middleware.js';
@@ -66,14 +68,19 @@ export class Transformer {
private readonly _slots: TransformerSlots = { private readonly _slots: TransformerSlots = {
beforeImport: new Slot<BeforeImportPayload>(), beforeImport: new Slot<BeforeImportPayload>(),
afterImport: new Slot<FinalPayload>(), afterImport: new Slot<AfterImportPayload>(),
beforeExport: new Slot<BeforeExportPayload>(), beforeExport: new Slot<BeforeExportPayload>(),
afterExport: new Slot<FinalPayload>(), afterExport: new Slot<AfterExportPayload>(),
}; };
blockToSnapshot = (model: DraftModel): BlockSnapshot | undefined => { blockToSnapshot = (
model: DraftModel | BlockModel
): BlockSnapshot | undefined => {
try { try {
const snapshot = this._blockToSnapshot(model); const draftModel =
model instanceof BlockModel ? toDraftModel(model) : model;
const snapshot = this._blockToSnapshot(draftModel);
if (!snapshot) { if (!snapshot) {
return; return;
@@ -103,7 +110,7 @@ export class Transformer {
'Root block not found in doc' 'Root block not found in doc'
); );
} }
const blocks = this.blockToSnapshot(rootModel); const blocks = this.blockToSnapshot(toDraftModel(rootModel));
if (!blocks) { if (!blocks) {
return; return;
} }
@@ -286,7 +293,8 @@ export class Transformer {
const contentBlocks = blockTree.children const contentBlocks = blockTree.children
.map(tree => doc.getBlockById(tree.draft.id)) .map(tree => doc.getBlockById(tree.draft.id))
.filter(Boolean) as DraftModel[]; .filter((x): x is BlockModel => x !== null)
.map(model => toDraftModel(model));
const slice = new Slice({ const slice = new Slice({
content: contentBlocks, content: contentBlocks,

View File

@@ -24,7 +24,7 @@ import type {
Store, Store,
TransformerMiddleware, TransformerMiddleware,
} from '@blocksuite/affine/store'; } from '@blocksuite/affine/store';
import { Transformer } from '@blocksuite/affine/store'; import { toDraftModel, Transformer } from '@blocksuite/affine/store';
const updateSnapshotText = ( const updateSnapshotText = (
point: TextRangePoint, point: TextRangePoint,
@@ -51,10 +51,10 @@ function processSnapshot(
const modelId = model.id; const modelId = model.id;
if (text.from.blockId === modelId) { if (text.from.blockId === modelId) {
updateSnapshotText(text.from, snapshot, model); updateSnapshotText(text.from, snapshot, toDraftModel(model));
} }
if (text.to && text.to.blockId === modelId) { if (text.to && text.to.blockId === modelId) {
updateSnapshotText(text.to, snapshot, model); updateSnapshotText(text.to, snapshot, toDraftModel(model));
} }
// If the snapshot has children, handle them recursively // If the snapshot has children, handle them recursively

View File

@@ -147,7 +147,7 @@ function generateMarkdownPreviewBuilder(
keys: Array.from(yblock.keys()) keys: Array.from(yblock.keys())
.filter(key => key.startsWith('prop:')) .filter(key => key.startsWith('prop:'))
.map(key => key.substring(5)), .map(key => key.substring(5)),
}; } as DraftModel;
} }
const titleMiddleware: TransformerMiddleware = ({ adapterConfigs }) => { const titleMiddleware: TransformerMiddleware = ({ adapterConfigs }) => {