@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
210 lines (194 loc) • 6.86 kB
text/typescript
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { createDependencyGraph, toposortReverse } from "../util/topo-sort.js";
import {
BlockNoDefaults,
BlockSchema,
BlockSpecs,
InlineContentConfig,
InlineContentSchema,
InlineContentSpec,
InlineContentSpecs,
LooseBlockSpec,
PartialBlockNoDefaults,
StyleSchema,
StyleSpecs,
addNodeAndExtensionsToSpec,
getInlineContentSchemaFromSpecs,
getStyleSchemaFromSpecs,
} from "./index.js";
function removeUndefined<T extends Record<string, any> | undefined>(obj: T): T {
if (!obj) {
return obj;
}
return Object.fromEntries(
Object.entries(obj).filter(([, value]) => value !== undefined),
) as T;
}
export class CustomBlockNoteSchema<
BSchema extends BlockSchema,
ISchema extends InlineContentSchema,
SSchema extends StyleSchema,
> {
// Helper so that you can use typeof schema.BlockNoteEditor
public readonly BlockNoteEditor: BlockNoteEditor<BSchema, ISchema, SSchema> =
"only for types" as any;
public readonly Block: BlockNoDefaults<BSchema, ISchema, SSchema> =
"only for types" as any;
public readonly PartialBlock: PartialBlockNoDefaults<
BSchema,
ISchema,
SSchema
> = "only for types" as any;
public inlineContentSpecs: InlineContentSpecs;
public styleSpecs: StyleSpecs;
public blockSpecs: {
[K in keyof BSchema]: K extends string
? LooseBlockSpec<K, BSchema[K]["propSchema"], BSchema[K]["content"]>
: never;
};
public blockSchema: BSchema;
public inlineContentSchema: ISchema;
public styleSchema: SSchema;
constructor(
private opts: {
blockSpecs: BlockSpecs;
inlineContentSpecs: InlineContentSpecs;
styleSpecs: StyleSpecs;
},
) {
const {
blockSpecs,
inlineContentSpecs,
styleSpecs,
blockSchema,
inlineContentSchema,
styleSchema,
} = this.init();
this.blockSpecs = blockSpecs;
this.styleSpecs = styleSpecs;
this.styleSchema = styleSchema;
this.inlineContentSpecs = inlineContentSpecs;
this.blockSchema = blockSchema;
this.inlineContentSchema = inlineContentSchema;
}
private init() {
const dag = createDependencyGraph();
const defaultSet = new Set<string>();
dag.set("default", defaultSet);
for (const [key, specDef] of Object.entries(this.opts.blockSpecs)) {
if (specDef.implementation.runsBefore) {
dag.set(key, new Set(specDef.implementation.runsBefore));
} else {
defaultSet.add(key);
}
}
const sortedSpecs = toposortReverse(dag);
const defaultIndex = sortedSpecs.findIndex((set) => set.has("default"));
/**
* The priority of a block is described relative to the "default" block (an arbitrary block which can be used as the reference)
*
* Since blocks are topologically sorted, we can see what their relative position is to the "default" block
* Each layer away from the default block is 10 priority points (arbitrarily chosen)
* The default block is fixed at 101 (1 point higher than any tiptap extension, giving priority to custom blocks than any defaults)
*
* This is a bit of a hack, but it's a simple way to ensure that custom blocks are always rendered with higher priority than default blocks
* and that custom blocks are rendered in the order they are defined in the schema
*/
const getPriority = (key: string) => {
const index = sortedSpecs.findIndex((set) => set.has(key));
// the default index should map to 101
// one before the default index is 91
// one after is 111
return 91 + (index + defaultIndex) * 10;
};
const blockSpecs = Object.fromEntries(
Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => {
return [
key,
addNodeAndExtensionsToSpec(
blockSpec.config,
blockSpec.implementation,
blockSpec.extensions,
getPriority(key),
),
];
}),
) as {
[K in keyof BSchema]: K extends string
? LooseBlockSpec<K, BSchema[K]["propSchema"], BSchema[K]["content"]>
: never;
};
return {
blockSpecs,
blockSchema: Object.fromEntries(
Object.entries(blockSpecs).map(([key, blockDef]) => {
return [key, blockDef.config];
}),
) as any,
inlineContentSpecs: removeUndefined(this.opts.inlineContentSpecs),
styleSpecs: removeUndefined(this.opts.styleSpecs),
inlineContentSchema: getInlineContentSchemaFromSpecs(
this.opts.inlineContentSpecs,
) as any,
styleSchema: getStyleSchemaFromSpecs(this.opts.styleSpecs) as any,
};
}
/**
* Adds additional block specs to the current schema in a builder pattern.
* This method allows extending the schema after it has been created.
*
* @param additionalBlockSpecs - Additional block specs to add to the schema
* @returns The current schema instance for chaining
*/
public extend<
AdditionalBlockSpecs extends BlockSpecs = Record<string, never>,
AdditionalInlineContentSpecs extends Record<
string,
InlineContentSpec<InlineContentConfig>
> = Record<string, never>,
AdditionalStyleSpecs extends StyleSpecs = Record<string, never>,
>(opts: {
blockSpecs?: AdditionalBlockSpecs;
inlineContentSpecs?: AdditionalInlineContentSpecs;
styleSpecs?: AdditionalStyleSpecs;
}): CustomBlockNoteSchema<
AdditionalBlockSpecs extends undefined | Record<string, never>
? BSchema
: BSchema & {
[K in keyof AdditionalBlockSpecs]: K extends string
? AdditionalBlockSpecs[K]["config"]
: never;
},
AdditionalInlineContentSpecs extends undefined | Record<string, never>
? ISchema
: ISchema & {
[K in keyof AdditionalInlineContentSpecs]: AdditionalInlineContentSpecs[K]["config"];
},
AdditionalStyleSpecs extends undefined | Record<string, never>
? SSchema
: SSchema & {
[K in keyof AdditionalStyleSpecs]: AdditionalStyleSpecs[K]["config"];
}
> {
// Merge the new specs with existing ones
Object.assign(this.opts.blockSpecs, opts.blockSpecs);
Object.assign(this.opts.inlineContentSpecs, opts.inlineContentSpecs);
Object.assign(this.opts.styleSpecs, opts.styleSpecs);
// Reinitialize the block specs with the merged specs
const {
blockSpecs,
inlineContentSpecs,
styleSpecs,
blockSchema,
inlineContentSchema,
styleSchema,
} = this.init();
this.blockSpecs = blockSpecs;
this.styleSpecs = styleSpecs;
this.styleSchema = styleSchema;
this.inlineContentSpecs = inlineContentSpecs;
this.blockSchema = blockSchema;
this.inlineContentSchema = inlineContentSchema;
return this as any;
}
}