Skip to content

Commit 3e590bd

Browse files
YousefEDclaude
andcommitted
feat(core): support "plain" block content
Adds a "plain" block content type for blocks that hold an unstyled text string (rather than rich inline content), and migrates the code block to use it. Plain content is `text*` with `marks: ""` at the ProseMirror level and a `string` in the block model. - Propagate "plain" through the block/inline-content type system (BlockFromConfig, PartialBlock, createBlockSpec, schema helpers) so a block/inline-content spec with `content: "plain"` infers `string`. - Wire the runtime: node spec (text*, marks: ""), nodeToBlock, createSpec parse/render, schemaToJSONSchema (plain -> string). - Update the default exporters (docx/odt/pdf/email) and the shared test util to read plain content as a string. - Add a Yjs pre-sync migration (stripDisallowedMarks) so legacy code blocks that carry marks load gracefully instead of being deleted by y-prosemirror; covers both pre-mount and after-mount-sync loads and tears down once migration is done. - Type-check tests/ via vp lint and add a root contributing guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ade62f2 commit 3e590bd

41 files changed

Lines changed: 678 additions & 238 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CONTRIBUTING.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,29 @@ To run the project, open the command line in the project's root directory and en
2828
pnpm install
2929

3030
# Start the example project
31-
pnpm start
31+
pnpm dev
3232
```
3333

34+
## Commands
35+
36+
All commands are run from the project root with [`pnpm`](https://pnpm.io), which
37+
wraps the `vp` ([vite-plus](https://vite-plus.dev)) task runner. The ones you'll
38+
use day to day:
39+
40+
| Command | Description |
41+
| ---------------- | ---------------------------------------------------------- |
42+
| `pnpm install` | Install all dependencies. |
43+
| `pnpm dev` | Start the example editor with live reload. |
44+
| `pnpm start` | Build the packages, then preview the example editor. |
45+
| `pnpm test` | Run the unit tests across all packages. |
46+
| `pnpm lint` | Lint and type-check the codebase. Run this before pushing. |
47+
| `pnpm run check` | Auto-fix lint and formatting issues across the project. |
48+
| `pnpm build` | Build all packages. |
49+
| `pnpm e2e` | Run the Playwright end-to-end tests. |
50+
51+
To run the unit tests for a single package, run `pnpm test` from inside that
52+
package's directory; append `-u` to update snapshots.
53+
3454
## Adding packages
3555

3656
- Add the dependency to the relevant `package.json` file (packages/xxx/package.json)

‎packages/core/src/api/blockManipulation/selections/textCursorPosition.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function setTextCursorPosition(
7171

7272
const info = getBlockInfo(posInfo);
7373

74-
const contentType: "none" | "inline" | "table" =
74+
const contentType: "none" | "inline" | "table" | "plain" =
7575
schema.blockSchema[info.blockNoteType]!.content;
7676

7777
if (info.isBlockContainer) {
@@ -81,7 +81,7 @@ export function setTextCursorPosition(
8181
return;
8282
}
8383

84-
if (contentType === "inline") {
84+
if (contentType === "inline" || contentType === "plain") {
8585
if (placement === "start") {
8686
tr.setSelection(
8787
TextSelection.create(tr.doc, blockContent.beforePos + 1),

‎packages/core/src/api/nodeConversions/nodeToBlock.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,11 @@ export function nodeToBlock<
476476
inlineContentSchema,
477477
styleSchema,
478478
);
479+
} else if (blockConfig.content === "plain") {
480+
if (!blockInfo.isBlockContainer) {
481+
throw new Error("impossible");
482+
}
483+
content = blockInfo.blockContent.node.textContent;
479484
} else if (blockConfig.content === "none") {
480485
content = undefined;
481486
} else {

‎packages/core/src/blocks/Code/block.test.ts‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe("Code block input rule", () => {
125125

126126
const block = editor.document[0];
127127
expect(block.type).toBe("codeBlock");
128-
expect(block.content).toEqual([]);
128+
expect(block.content).toBe("");
129129
});
130130

131131
it("converts ```ts + Enter into a codeBlock", () => {
@@ -135,7 +135,7 @@ describe("Code block input rule", () => {
135135
const block = editor.document[0];
136136
expect(block.type).toBe("codeBlock");
137137
expect((block.props as any).language).toBe("ts");
138-
expect(block.content).toEqual([]);
138+
expect(block.content).toBe("");
139139
});
140140

141141
it("converts ``` + Enter into a codeBlock with empty language", () => {
@@ -186,9 +186,8 @@ describe("Code block input rule", () => {
186186
const after = editor.document[0];
187187
expect(after.type).toBe("codeBlock");
188188
expect(after.id).toBe(block.id);
189-
expect(
190-
(after.content as Array<{ type: string; text: string }>)[0].text,
191-
).toBe("hello");
189+
// The code block holds plain (string) content.
190+
expect(after.content).toBe("hello");
192191
});
193192

194193
it("places cursor inside the new code block after Enter conversion", () => {
@@ -205,9 +204,8 @@ describe("Code block input rule", () => {
205204
const after = editor.document[0];
206205
expect(after.type).toBe("codeBlock");
207206
expect(after.id).toBe(block.id);
208-
expect(
209-
(after.content as Array<{ type: string; text: string }>)[0].text,
210-
).toBe("world");
207+
// The code block holds plain (string) content.
208+
expect(after.content).toBe("world");
211209
});
212210

213211
it("Enter inside an existing code block does not retrigger conversion", () => {

‎packages/core/src/blocks/Code/block.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { HighlighterGeneric } from "@shikijs/types";
2+
import { DOMParser } from "@tiptap/pm/model";
23
import { createExtension } from "../../editor/BlockNoteExtension.js";
34
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
45
import { lazyShikiPlugin } from "./shiki.js";
5-
import { DOMParser } from "@tiptap/pm/model";
66

77
export type CodeBlockOptions = {
88
/**
@@ -62,7 +62,7 @@ export const createCodeBlockConfig = createBlockConfig(
6262
default: defaultLanguage,
6363
},
6464
},
65-
content: "inline",
65+
content: "plain",
6666
}) as const,
6767
);
6868

‎packages/core/src/schema/blocks/createSpec.ts‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) {
4444
export function getParseRules<
4545
TName extends string,
4646
TProps extends PropSchema,
47-
TContent extends "inline" | "none" | "table",
47+
TContent extends "inline" | "none" | "table" | "plain",
4848
>(
4949
config: BlockConfig<TName, TProps, TContent>,
5050
implementation: BlockImplementation<TName, TProps, TContent>,
@@ -75,7 +75,9 @@ export function getParseRules<
7575
// Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed
7676
preserveWhitespace: true,
7777
getContent:
78-
config.content === "inline" || config.content === "none"
78+
config.content === "inline" ||
79+
config.content === "none" ||
80+
config.content === "plain"
7981
? (node, schema) => {
8082
if (implementation.parseContent) {
8183
const result = implementation.parseContent({
@@ -89,6 +91,13 @@ export function getParseRules<
8991
}
9092
}
9193

94+
if (config.content === "plain") {
95+
// Plain blocks hold unstyled text only, so we parse the
96+
// element's text content directly into a single text node.
97+
const text = (node as HTMLElement).textContent ?? "";
98+
return text ? Fragment.from(schema.text(text)) : Fragment.empty;
99+
}
100+
92101
if (config.content === "inline") {
93102
// Parse the inline content if it exists
94103
const element = node as HTMLElement;
@@ -140,7 +149,7 @@ export function getParseRules<
140149
export function addNodeAndExtensionsToSpec<
141150
TName extends string,
142151
TProps extends PropSchema,
143-
TContent extends "inline" | "none" | "table",
152+
TContent extends "inline" | "none" | "table" | "plain",
144153
>(
145154
blockConfig: BlockConfig<TName, TProps, TContent>,
146155
blockImplementation: BlockImplementation<TName, TProps, TContent>,
@@ -153,9 +162,18 @@ export function addNodeAndExtensionsToSpec<
153162
name: blockConfig.type,
154163
content: (blockConfig.content === "inline"
155164
? "inline*"
156-
: blockConfig.content === "none"
157-
? ""
158-
: blockConfig.content) as TContent extends "inline" ? "inline*" : "",
165+
: blockConfig.content === "plain"
166+
? "text*"
167+
: blockConfig.content === "none"
168+
? ""
169+
: blockConfig.content) as TContent extends "inline"
170+
? "inline*"
171+
: TContent extends "plain"
172+
? "text*"
173+
: "",
174+
// "plain" blocks hold unstyled text only, so disallow marks on them.
175+
// TODO: this might need to allow marks for comments and suggestions / diffs
176+
marks: blockConfig.content === "plain" ? "" : undefined,
159177
group: "blockContent",
160178
selectable: blockImplementation.meta?.selectable ?? true,
161179
isolating: blockImplementation.meta?.isolating ?? true,
@@ -180,7 +198,11 @@ export function addNodeAndExtensionsToSpec<
180198
return wrapInBlockStructure(
181199
{
182200
dom: div,
183-
contentDOM: blockConfig.content === "inline" ? div : undefined,
201+
contentDOM:
202+
blockConfig.content === "inline" ||
203+
blockConfig.content === "plain"
204+
? div
205+
: undefined,
184206
},
185207
blockConfig.type,
186208
{},
@@ -304,7 +326,7 @@ export function createBlockConfig<
304326
export function createBlockSpec<
305327
const TName extends string,
306328
const TProps extends PropSchema,
307-
const TContent extends "inline" | "none",
329+
const TContent extends "inline" | "none" | "plain",
308330
const TOptions extends Partial<Record<string, any>> | undefined = undefined,
309331
>(
310332
blockConfigOrCreator: BlockConfig<TName, TProps, TContent>,
@@ -323,7 +345,7 @@ export function createBlockSpec<
323345
export function createBlockSpec<
324346
const TName extends string,
325347
const TProps extends PropSchema,
326-
const TContent extends "inline" | "none",
348+
const TContent extends "inline" | "none" | "plain",
327349
const BlockConf extends BlockConfig<TName, TProps, TContent>,
328350
const TOptions extends Partial<Record<string, any>>,
329351
>(
@@ -349,7 +371,7 @@ export function createBlockSpec<
349371
export function createBlockSpec<
350372
const TName extends string,
351373
const TProps extends PropSchema,
352-
const TContent extends "inline" | "none",
374+
const TContent extends "inline" | "none" | "plain",
353375
const TOptions extends Partial<Record<string, any>> | undefined = undefined,
354376
>(
355377
blockConfigOrCreator: BlockConfigOrCreator<TName, TProps, TContent, TOptions>,

‎packages/core/src/schema/blocks/internal.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ export function createBlockSpecFromTiptapNode<
197197
const T extends {
198198
node: Node;
199199
type: string;
200-
content: "inline" | "table" | "none";
200+
content: "inline" | "table" | "none" | "plain";
201201
},
202202
P extends PropSchema,
203203
>(

‎packages/core/src/schema/blocks/types.ts‎

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ export interface BlockConfigMeta {
6767
export interface BlockConfig<
6868
T extends string = string,
6969
PS extends PropSchema = PropSchema,
70-
C extends "inline" | "none" | "table" = "inline" | "none" | "table",
70+
C extends "inline" | "none" | "table" | "plain" =
71+
| "inline"
72+
| "none"
73+
| "table"
74+
| "plain",
7175
> {
7276
/**
7377
* The type of the block (unique identifier within a schema)
@@ -93,7 +97,7 @@ export interface BlockConfig<
9397
export type BlockConfigOrCreator<
9498
TName extends string = string,
9599
TProps extends PropSchema = PropSchema,
96-
TContent extends "inline" | "none" = "inline" | "none",
100+
TContent extends "inline" | "none" | "plain" = "inline" | "none" | "plain",
97101
TOptions extends Record<string, any> | undefined =
98102
| Record<string, any>
99103
| undefined,
@@ -108,8 +112,10 @@ export type BlockConfigOrCreator<
108112
*/
109113
export type ExtractBlockConfigFromConfigOrCreator<
110114
ConfigOrCreator extends
111-
| BlockConfig<string, PropSchema, "inline" | "none">
112-
| ((...args: any[]) => BlockConfig<string, PropSchema, "inline" | "none">),
115+
| BlockConfig<string, PropSchema, "inline" | "none" | "plain">
116+
| ((
117+
...args: any[]
118+
) => BlockConfig<string, PropSchema, "inline" | "none" | "plain">),
113119
> = ConfigOrCreator extends (...args: any[]) => infer Config
114120
? Config
115121
: ConfigOrCreator;
@@ -118,14 +124,18 @@ export type ExtractBlockConfigFromConfigOrCreator<
118124
export type CustomBlockConfig<
119125
T extends string = string,
120126
PS extends PropSchema = PropSchema,
121-
C extends "inline" | "none" = "inline" | "none",
127+
C extends "inline" | "none" | "plain" = "inline" | "none" | "plain",
122128
> = BlockConfig<T, PS, C>;
123129

124130
// A Spec contains both the Config and Implementation
125131
export type BlockSpec<
126132
T extends string = string,
127133
PS extends PropSchema = PropSchema,
128-
C extends "inline" | "none" | "table" = "inline" | "none" | "table",
134+
C extends "inline" | "none" | "table" | "plain" =
135+
| "inline"
136+
| "none"
137+
| "table"
138+
| "plain",
129139
> = {
130140
config: BlockConfig<T, PS, C>;
131141
implementation: BlockImplementation<T, PS, C>;
@@ -139,7 +149,11 @@ export type BlockSpec<
139149
export type BlockSpecOrCreator<
140150
T extends string = string,
141151
PS extends PropSchema = PropSchema,
142-
C extends "inline" | "none" | "table" = "inline" | "none" | "table",
152+
C extends "inline" | "none" | "table" | "plain" =
153+
| "inline"
154+
| "none"
155+
| "table"
156+
| "plain",
143157
TOptions extends Record<string, any> | undefined =
144158
| Record<string, any>
145159
| undefined,
@@ -154,8 +168,10 @@ export type BlockSpecOrCreator<
154168
*/
155169
export type ExtractBlockSpecFromSpecOrCreator<
156170
SpecOrCreator extends
157-
| BlockSpec<string, PropSchema, "inline" | "none">
158-
| ((...args: any[]) => BlockSpec<string, PropSchema, "inline" | "none">),
171+
| BlockSpec<string, PropSchema, "inline" | "none" | "plain">
172+
| ((
173+
...args: any[]
174+
) => BlockSpec<string, PropSchema, "inline" | "none" | "plain">),
159175
> = SpecOrCreator extends (...args: any[]) => infer Spec ? Spec : SpecOrCreator;
160176

161177
/**
@@ -167,7 +183,11 @@ export type ExtractBlockSpecFromSpecOrCreator<
167183
export type LooseBlockSpec<
168184
T extends string = string,
169185
PS extends PropSchema = PropSchema,
170-
C extends "inline" | "none" | "table" = "inline" | "none" | "table",
186+
C extends "inline" | "none" | "table" | "plain" =
187+
| "inline"
188+
| "none"
189+
| "table"
190+
| "plain",
171191
> = {
172192
config: BlockConfig<T, PS, C>;
173193
implementation: Omit<
@@ -336,9 +356,11 @@ export type BlockFromConfigNoChildren<
336356
? InlineContent<I, S>[]
337357
: B["content"] extends "table"
338358
? TableContent<I, S>
339-
: B["content"] extends "none"
340-
? undefined
341-
: never;
359+
: B["content"] extends "plain"
360+
? string
361+
: B["content"] extends "none"
362+
? undefined
363+
: never;
342364
};
343365

344366
export type BlockFromConfig<
@@ -420,9 +442,11 @@ type PartialBlockFromConfigNoChildren<
420442
? PartialInlineContent<I, S>
421443
: B["content"] extends "table"
422444
? PartialTableContent<I, S>
423-
: B["content"] extends "none"
424-
? undefined
425-
: never;
445+
: B["content"] extends "plain"
446+
? string
447+
: B["content"] extends "none"
448+
? undefined
449+
: never;
426450
};
427451

428452
type PartialBlocksWithoutChildren<
@@ -472,7 +496,11 @@ export type BlockIdentifier = { id: string } | string;
472496
export type BlockImplementation<
473497
TName extends string = string,
474498
TProps extends PropSchema = PropSchema,
475-
TContent extends "inline" | "none" | "table" = "inline" | "none" | "table",
499+
TContent extends "inline" | "none" | "table" | "plain" =
500+
| "inline"
501+
| "none"
502+
| "table"
503+
| "plain",
476504
> = {
477505
/**
478506
* Metadata
@@ -589,10 +617,14 @@ export type BlockImplementationOrCreator<
589617
*/
590618
export type ExtractBlockImplementationFromImplementationOrCreator<
591619
ImplementationOrCreator extends
592-
| BlockImplementation<string, PropSchema, "inline" | "none">
620+
| BlockImplementation<string, PropSchema, "inline" | "none" | "plain">
593621
| ((
594622
...args: any[]
595-
) => BlockImplementation<string, PropSchema, "inline" | "none">),
623+
) => BlockImplementation<
624+
string,
625+
PropSchema,
626+
"inline" | "none" | "plain"
627+
>),
596628
> = ImplementationOrCreator extends (...args: any[]) => infer Implementation
597629
? Implementation
598630
: ImplementationOrCreator;
@@ -601,5 +633,5 @@ export type ExtractBlockImplementationFromImplementationOrCreator<
601633
export type CustomBlockImplementation<
602634
T extends string = string,
603635
PS extends PropSchema = PropSchema,
604-
C extends "inline" | "none" = "inline" | "none",
636+
C extends "inline" | "none" | "plain" = "inline" | "none" | "plain",
605637
> = BlockImplementation<T, PS, C>;

0 commit comments

Comments
 (0)