Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,29 @@ To run the project, open the command line in the project's root directory and en
pnpm install

# Start the example project
pnpm start
pnpm dev
```

## Commands

All commands are run from the project root with [`pnpm`](https://pnpm.io), which
wraps the `vp` ([vite-plus](https://vite-plus.dev)) task runner. The ones you'll
use day to day:

| Command | Description |
| ---------------- | ---------------------------------------------------------- |
| `pnpm install` | Install all dependencies. |
| `pnpm dev` | Start the example editor with live reload. |
| `pnpm start` | Build the packages, then preview the example editor. |
| `pnpm test` | Run the unit tests across all packages. |
| `pnpm lint` | Lint and type-check the codebase. Run this before pushing. |
| `pnpm run check` | Auto-fix lint and formatting issues across the project. |
| `pnpm build` | Build all packages. |
| `pnpm e2e` | Run the Playwright end-to-end tests. |

To run the unit tests for a single package, run `pnpm test` from inside that
package's directory; append `-u` to update snapshots.

## Adding packages

- Add the dependency to the relevant `package.json` file (packages/xxx/package.json)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function setTextCursorPosition(

const info = getBlockInfo(posInfo);

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

if (info.isBlockContainer) {
Expand All @@ -81,7 +81,7 @@ export function setTextCursorPosition(
return;
}

if (contentType === "inline") {
if (contentType === "inline" || contentType === "plain") {
if (placement === "start") {
tr.setSelection(
TextSelection.create(tr.doc, blockContent.beforePos + 1),
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/api/nodeConversions/nodeToBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ export function nodeToBlock<
inlineContentSchema,
styleSchema,
);
} else if (blockConfig.content === "plain") {
if (!blockInfo.isBlockContainer) {
throw new Error("impossible");
}
content = blockInfo.blockContent.node.textContent;
} else if (blockConfig.content === "none") {
content = undefined;
} else {
Expand Down
14 changes: 6 additions & 8 deletions packages/core/src/blocks/Code/block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe("Code block input rule", () => {

const block = editor.document[0];
expect(block.type).toBe("codeBlock");
expect(block.content).toEqual([]);
expect(block.content).toBe("");
});

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

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

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

it("Enter inside an existing code block does not retrigger conversion", () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/blocks/Code/block.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { HighlighterGeneric } from "@shikijs/types";
import { DOMParser } from "@tiptap/pm/model";
import { createExtension } from "../../editor/BlockNoteExtension.js";
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
import { lazyShikiPlugin } from "./shiki.js";
import { DOMParser } from "@tiptap/pm/model";

export type CodeBlockOptions = {
/**
Expand Down Expand Up @@ -62,7 +62,7 @@ export const createCodeBlockConfig = createBlockConfig(
default: defaultLanguage,
},
},
content: "inline",
content: "plain",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Update code-block input-rule payload to plain-string content.

After Line 65 switches code blocks to "plain", this file’s triple-backtick input-rule path still returns content: []. That no longer matches the code block contract and can leak array content into paths now expecting a string.

Suggested fix
 return {
   type: "codeBlock",
   props: {
     language: attributes.language,
   },
-  content: [],
+  content: "",
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/blocks/Code/block.ts` at line 65, The code block content
type has been updated to "plain" on line 65, but the triple-backtick input-rule
path still returns content as an array instead of a string, causing a mismatch
with the updated contract. Locate the triple-backtick input-rule handler in this
file (or in the input rules configuration) and update its content payload to
return a plain string value instead of an array, ensuring consistency with the
"plain" content type declaration and preventing array content from leaking into
string-expecting paths.

}) as const,
);

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/comments/mark.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Mark, mergeAttributes } from "@tiptap/core";

import { NON_FORMATTING_MARK_GROUP } from "../schema/markGroups.js";

export const CommentMark = Mark.create({
name: "comment",
excludes: "",
inclusive: false,
keepOnSplit: true,
// Allowed on "plain" blocks (e.g. code blocks) via this group.
group: NON_FORMATTING_MARK_GROUP,

addAttributes() {
// Return an object with attribute configuration
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Mark } from "@tiptap/core";
import { MarkSpec } from "prosemirror-model";

import { NON_FORMATTING_MARK_GROUP } from "../../../schema/markGroups.js";

// This copies the marks from @handlewithcare/prosemirror-suggest-changes,
// but uses the Tiptap Mark API instead so we can use them in BlockNote

Expand All @@ -10,6 +12,7 @@ export const SuggestionAddMark = Mark.create({
name: "insertion",
inclusive: false,
excludes: "deletion modification insertion",
group: NON_FORMATTING_MARK_GROUP,
addAttributes() {
return {
id: { default: null, validate: "number" }, // note: validate is supported in prosemirror but not in tiptap, so this doesn't actually work (considered not critical)
Expand Down Expand Up @@ -55,6 +58,7 @@ export const SuggestionDeleteMark = Mark.create({
name: "deletion",
inclusive: false,
excludes: "insertion modification deletion",
group: NON_FORMATTING_MARK_GROUP,
addAttributes() {
return {
id: { default: null, validate: "number" }, // note: validate is supported in prosemirror but not in tiptap
Expand Down Expand Up @@ -103,6 +107,7 @@ export const SuggestionModificationMark = Mark.create({
name: "modification",
inclusive: false,
excludes: "deletion insertion",
group: NON_FORMATTING_MARK_GROUP,
addAttributes() {
// note: validate is supported in prosemirror but not in tiptap
return {
Expand Down
47 changes: 37 additions & 10 deletions packages/core/src/schema/blocks/createSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Extension,
ExtensionFactoryInstance,
} from "../../editor/BlockNoteExtension.js";
import { NON_FORMATTING_MARK_GROUP } from "../markGroups.js";
import { PropSchema } from "../propTypes.js";
import {
getBlockFromPos,
Expand Down Expand Up @@ -44,7 +45,7 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) {
export function getParseRules<
TName extends string,
TProps extends PropSchema,
TContent extends "inline" | "none" | "table",
TContent extends "inline" | "none" | "table" | "plain",
>(
config: BlockConfig<TName, TProps, TContent>,
implementation: BlockImplementation<TName, TProps, TContent>,
Expand Down Expand Up @@ -75,7 +76,9 @@ export function getParseRules<
// Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed
preserveWhitespace: true,
getContent:
config.content === "inline" || config.content === "none"
config.content === "inline" ||
config.content === "none" ||
config.content === "plain"
? (node, schema) => {
if (implementation.parseContent) {
const result = implementation.parseContent({
Expand All @@ -89,6 +92,13 @@ export function getParseRules<
}
}

if (config.content === "plain") {
// Plain blocks hold unstyled text only, so we parse the
// element's text content directly into a single text node.
const text = (node as HTMLElement).textContent ?? "";
return text ? Fragment.from(schema.text(text)) : Fragment.empty;
}

if (config.content === "inline") {
// Parse the inline content if it exists
const element = node as HTMLElement;
Expand Down Expand Up @@ -140,7 +150,7 @@ export function getParseRules<
export function addNodeAndExtensionsToSpec<
TName extends string,
TProps extends PropSchema,
TContent extends "inline" | "none" | "table",
TContent extends "inline" | "none" | "table" | "plain",
>(
blockConfig: BlockConfig<TName, TProps, TContent>,
blockImplementation: BlockImplementation<TName, TProps, TContent>,
Expand All @@ -153,9 +163,22 @@ export function addNodeAndExtensionsToSpec<
name: blockConfig.type,
content: (blockConfig.content === "inline"
? "inline*"
: blockConfig.content === "none"
? ""
: blockConfig.content) as TContent extends "inline" ? "inline*" : "",
: blockConfig.content === "plain"
? "text*"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noting down that it feels a bit weird that you can technically have multiple separate text nodes in the content, but doubt that this would realistically cause any issues.

: blockConfig.content === "none"
? ""
: blockConfig.content) as TContent extends "inline"
? "inline*"
: TContent extends "plain"
? "text*"
: "",
// "plain" blocks hold unstyled text, so they disallow formatting marks.
// They still allow the non-formatting marks (comments and
// suggestions/diffs) — those annotate content without changing it and are
// ignored by the block model. The group's always-present suggestion marks
// keep this reference valid even when comments aren't configured.
marks:
blockConfig.content === "plain" ? NON_FORMATTING_MARK_GROUP : undefined,
group: "blockContent",
selectable: blockImplementation.meta?.selectable ?? true,
isolating: blockImplementation.meta?.isolating ?? true,
Expand All @@ -180,7 +203,11 @@ export function addNodeAndExtensionsToSpec<
return wrapInBlockStructure(
{
dom: div,
contentDOM: blockConfig.content === "inline" ? div : undefined,
contentDOM:
blockConfig.content === "inline" ||
blockConfig.content === "plain"
? div
: undefined,
},
blockConfig.type,
{},
Expand Down Expand Up @@ -304,7 +331,7 @@ export function createBlockConfig<
export function createBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none",
const TContent extends "inline" | "none" | "plain",
const TOptions extends Partial<Record<string, any>> | undefined = undefined,
>(
blockConfigOrCreator: BlockConfig<TName, TProps, TContent>,
Expand All @@ -323,7 +350,7 @@ export function createBlockSpec<
export function createBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none",
const TContent extends "inline" | "none" | "plain",
const BlockConf extends BlockConfig<TName, TProps, TContent>,
const TOptions extends Partial<Record<string, any>>,
>(
Expand All @@ -349,7 +376,7 @@ export function createBlockSpec<
export function createBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none",
const TContent extends "inline" | "none" | "plain",
const TOptions extends Partial<Record<string, any>> | undefined = undefined,
>(
blockConfigOrCreator: BlockConfigOrCreator<TName, TProps, TContent, TOptions>,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/blocks/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function createBlockSpecFromTiptapNode<
const T extends {
node: Node;
type: string;
content: "inline" | "table" | "none";
content: "inline" | "table" | "none" | "plain";
},
P extends PropSchema,
>(
Expand Down
Loading
Loading