diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2e0099d73..5996b27f14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts index 83f5340698..8e5d4270fd 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -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) { @@ -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), diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 5048f91a2b..23570679e1 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -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 { diff --git a/packages/core/src/blocks/Code/block.test.ts b/packages/core/src/blocks/Code/block.test.ts index edc26da8b7..17628edf2a 100644 --- a/packages/core/src/blocks/Code/block.test.ts +++ b/packages/core/src/blocks/Code/block.test.ts @@ -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", () => { @@ -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", () => { @@ -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", () => { @@ -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", () => { diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index dbb7fc33a9..41fb17b61e 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -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 = { /** @@ -62,7 +62,7 @@ export const createCodeBlockConfig = createBlockConfig( default: defaultLanguage, }, }, - content: "inline", + content: "plain", }) as const, ); diff --git a/packages/core/src/comments/mark.ts b/packages/core/src/comments/mark.ts index 072f24c7c3..f2e97e86f8 100644 --- a/packages/core/src/comments/mark.ts +++ b/packages/core/src/comments/mark.ts @@ -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 diff --git a/packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts b/packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts index 1665c8e5bd..4e81c408e5 100644 --- a/packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts +++ b/packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts @@ -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 @@ -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) @@ -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 @@ -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 { diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..050bb8b20d 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -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, @@ -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, implementation: BlockImplementation, @@ -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({ @@ -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; @@ -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, blockImplementation: BlockImplementation, @@ -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*" + : 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, @@ -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, {}, @@ -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> | undefined = undefined, >( blockConfigOrCreator: BlockConfig, @@ -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, const TOptions extends Partial>, >( @@ -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> | undefined = undefined, >( blockConfigOrCreator: BlockConfigOrCreator, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index eed8cf9fa3..9ce1ab8cea 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -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, >( diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 97550ee331..642bed6f53 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -67,7 +67,11 @@ export interface BlockConfigMeta { export interface BlockConfig< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends "inline" | "none" | "table" | "plain" = + | "inline" + | "none" + | "table" + | "plain", > { /** * The type of the block (unique identifier within a schema) @@ -93,7 +97,7 @@ export interface BlockConfig< export type BlockConfigOrCreator< TName extends string = string, TProps extends PropSchema = PropSchema, - TContent extends "inline" | "none" = "inline" | "none", + TContent extends "inline" | "none" | "plain" = "inline" | "none" | "plain", TOptions extends Record | undefined = | Record | undefined, @@ -108,8 +112,10 @@ export type BlockConfigOrCreator< */ export type ExtractBlockConfigFromConfigOrCreator< ConfigOrCreator extends - | BlockConfig - | ((...args: any[]) => BlockConfig), + | BlockConfig + | (( + ...args: any[] + ) => BlockConfig), > = ConfigOrCreator extends (...args: any[]) => infer Config ? Config : ConfigOrCreator; @@ -118,14 +124,18 @@ export type ExtractBlockConfigFromConfigOrCreator< export type CustomBlockConfig< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" = "inline" | "none", + C extends "inline" | "none" | "plain" = "inline" | "none" | "plain", > = BlockConfig; // A Spec contains both the Config and Implementation export type BlockSpec< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends "inline" | "none" | "table" | "plain" = + | "inline" + | "none" + | "table" + | "plain", > = { config: BlockConfig; implementation: BlockImplementation; @@ -139,7 +149,11 @@ export type BlockSpec< export type BlockSpecOrCreator< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends "inline" | "none" | "table" | "plain" = + | "inline" + | "none" + | "table" + | "plain", TOptions extends Record | undefined = | Record | undefined, @@ -154,8 +168,10 @@ export type BlockSpecOrCreator< */ export type ExtractBlockSpecFromSpecOrCreator< SpecOrCreator extends - | BlockSpec - | ((...args: any[]) => BlockSpec), + | BlockSpec + | (( + ...args: any[] + ) => BlockSpec), > = SpecOrCreator extends (...args: any[]) => infer Spec ? Spec : SpecOrCreator; /** @@ -167,7 +183,11 @@ export type ExtractBlockSpecFromSpecOrCreator< export type LooseBlockSpec< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends "inline" | "none" | "table" | "plain" = + | "inline" + | "none" + | "table" + | "plain", > = { config: BlockConfig; implementation: Omit< @@ -336,9 +356,11 @@ export type BlockFromConfigNoChildren< ? InlineContent[] : B["content"] extends "table" ? TableContent - : B["content"] extends "none" - ? undefined - : never; + : B["content"] extends "plain" + ? string + : B["content"] extends "none" + ? undefined + : never; }; export type BlockFromConfig< @@ -420,9 +442,11 @@ type PartialBlockFromConfigNoChildren< ? PartialInlineContent : B["content"] extends "table" ? PartialTableContent - : B["content"] extends "none" - ? undefined - : never; + : B["content"] extends "plain" + ? string + : B["content"] extends "none" + ? undefined + : never; }; type PartialBlocksWithoutChildren< @@ -472,7 +496,11 @@ export type BlockIdentifier = { id: string } | string; export type BlockImplementation< TName extends string = string, TProps extends PropSchema = PropSchema, - TContent extends "inline" | "none" | "table" = "inline" | "none" | "table", + TContent extends "inline" | "none" | "table" | "plain" = + | "inline" + | "none" + | "table" + | "plain", > = { /** * Metadata @@ -589,10 +617,14 @@ export type BlockImplementationOrCreator< */ export type ExtractBlockImplementationFromImplementationOrCreator< ImplementationOrCreator extends - | BlockImplementation + | BlockImplementation | (( ...args: any[] - ) => BlockImplementation), + ) => BlockImplementation< + string, + PropSchema, + "inline" | "none" | "plain" + >), > = ImplementationOrCreator extends (...args: any[]) => infer Implementation ? Implementation : ImplementationOrCreator; @@ -601,5 +633,5 @@ export type ExtractBlockImplementationFromImplementationOrCreator< export type CustomBlockImplementation< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" = "inline" | "none", + C extends "inline" | "none" | "plain" = "inline" | "none" | "plain", > = BlockImplementation; diff --git a/packages/core/src/schema/contentTypePropagation.test.ts b/packages/core/src/schema/contentTypePropagation.test.ts new file mode 100644 index 0000000000..c77e236790 --- /dev/null +++ b/packages/core/src/schema/contentTypePropagation.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { createBlockSpec } from "./blocks/createSpec.js"; +import type { + BlockConfig, + BlockFromConfig, + PartialBlockFromConfig, +} from "./blocks/types.js"; +import { createInlineContentSpec } from "./inlineContent/createSpec.js"; +import type { + CustomInlineContentFromConfig, + PartialCustomInlineContentFromConfig, +} from "./inlineContent/types.js"; +import type { StyleSchema } from "./styles/types.js"; + +/** + * Type-level tests asserting that a block's / inline content's `content` field + * is inferred correctly from its config's `content` discriminant — in + * particular that the `"plain"` content type propagates to `string`, the same + * way `"inline"`/`"styled"` propagate to arrays and `"none"` to `undefined`. + * + * The assertions are the type annotations and `@ts-expect-error` directives: if + * propagation breaks, this file stops compiling, which `vp lint` / `tsgo` + * catches in CI (note that `vp test`, which strips types via esbuild, does not + * — these tests are guarded by the type-checker, not the runner). The `it` + * bodies otherwise contain no meaningful runtime logic, mirroring the existing + * typing tests in `updateBlock.test.ts`. + */ + +// Exact type-equality check: resolves to `true` only when X and Y are identical. +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; + +describe("block content type propagation", () => { + type PlainContent = BlockFromConfig< + BlockConfig<"plainBlock", {}, "plain">, + any, + any + >["content"]; + type NoneContent = BlockFromConfig< + BlockConfig<"noneBlock", {}, "none">, + any, + any + >["content"]; + type InlineContent = BlockFromConfig< + BlockConfig<"inlineBlock", {}, "inline">, + any, + any + >["content"]; + + it("'plain' content is a string", () => { + const isString: Equal = true; + expect(isString).toBe(true); + + // @ts-expect-error 'plain' content is a string, not an inline-content array + const bad: PlainContent = [{ type: "text", text: "x", styles: {} }]; + void bad; + }); + + it("'none' content is undefined", () => { + const isUndefined: Equal = true; + expect(isUndefined).toBe(true); + }); + + it("'inline' content is distinct from 'plain' (an array, not a string)", () => { + // @ts-expect-error 'inline' content is an array, not a string + const bad: InlineContent = "hello"; + void bad; + }); +}); + +describe("partial block content type propagation", () => { + type PartialPlainContent = PartialBlockFromConfig< + BlockConfig<"plainBlock", {}, "plain">, + any, + any + >["content"]; + + it("'plain' partial content is an optional string", () => { + const isOptionalString: Equal = + true; + expect(isOptionalString).toBe(true); + + // @ts-expect-error 'plain' content is a string, not an inline-content array + const bad: PartialPlainContent = [{ type: "text", text: "x", styles: {} }]; + void bad; + }); +}); + +describe("inline content type propagation", () => { + type PlainContent = CustomInlineContentFromConfig< + { type: "plainIC"; content: "plain"; readonly propSchema: {} }, + StyleSchema + >["content"]; + type NoneContent = CustomInlineContentFromConfig< + { type: "noneIC"; content: "none"; readonly propSchema: {} }, + StyleSchema + >["content"]; + type StyledContent = CustomInlineContentFromConfig< + { type: "styledIC"; content: "styled"; readonly propSchema: {} }, + StyleSchema + >["content"]; + + it("'plain' inline content is a string", () => { + const isString: Equal = true; + expect(isString).toBe(true); + + // @ts-expect-error 'plain' inline content is a string, not a StyledText array + const bad: PlainContent = []; + void bad; + }); + + it("'none' inline content is undefined", () => { + const isUndefined: Equal = true; + expect(isUndefined).toBe(true); + }); + + it("'styled' inline content is distinct from 'plain' (an array, not a string)", () => { + // @ts-expect-error 'styled' inline content is a StyledText array, not a string + const bad: StyledContent = "hello"; + void bad; + }); +}); + +describe("partial inline content type propagation", () => { + type PartialPlainContent = PartialCustomInlineContentFromConfig< + { type: "plainIC"; content: "plain"; readonly propSchema: {} }, + StyleSchema + >["content"]; + + it("'plain' partial inline content is an optional string", () => { + const isOptionalString: Equal = + true; + expect(isOptionalString).toBe(true); + }); +}); + +describe("content type propagation through the spec factories", () => { + it("createBlockSpec propagates 'plain' to the block's content type", () => { + const plainBlock = createBlockSpec( + { type: "plainBlock", propSchema: {}, content: "plain" }, + { render: () => ({ dom: undefined as unknown as HTMLElement }) }, + ); + + type Content = BlockFromConfig< + ReturnType["config"], + any, + any + >["content"]; + + const isString: Equal = true; + expect(isString).toBe(true); + }); + + it("createInlineContentSpec propagates 'plain' to the inline content's content type", () => { + const plainIC = createInlineContentSpec( + { type: "plainIC", propSchema: {}, content: "plain" }, + { render: () => ({ dom: undefined as unknown as HTMLElement }) }, + ); + + type Content = CustomInlineContentFromConfig< + (typeof plainIC)["config"], + StyleSchema + >["content"]; + + const isString: Equal = true; + expect(isString).toBe(true); + }); +}); diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index b8e922502a..e502e05ead 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -1,12 +1,12 @@ import { Node } from "@tiptap/core"; +import { ViewMutationRecord } from "prosemirror-view"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema, Styles } from "../styles/types.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { ViewMutationRecord } from "prosemirror-view"; export type CustomInlineContentConfig = { type: string; - content: "styled" | "none"; // | "plain" + content: "styled" | "none" | "plain"; readonly propSchema: PropSchema; }; // InlineContentConfig contains the "schema" info about an InlineContent type diff --git a/packages/core/src/schema/markGroups.ts b/packages/core/src/schema/markGroups.ts new file mode 100644 index 0000000000..6e1eed7a08 --- /dev/null +++ b/packages/core/src/schema/markGroups.ts @@ -0,0 +1,13 @@ +/** + * ProseMirror mark group for "non-formatting" marks: comments and + * suggestion/diff marks (`insertion`/`deletion`/`modification`). These annotate + * content without representing inline formatting and are ignored by BlockNote's + * content model (`blocknoteIgnore`). + * + * They are the only marks allowed on `"plain"` blocks (e.g. code blocks), which + * otherwise disallow all marks. A block references this group rather than the + * individual marks because the comment mark is only present when comments are + * configured — and the always-present suggestion marks keep the group non-empty + * so the reference never resolves to an unknown mark/group. + */ +export const NON_FORMATTING_MARK_GROUP = "annotation"; diff --git a/packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts b/packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts index 9d71c4b5ac..e6077bb0fe 100644 --- a/packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts +++ b/packages/core/src/yjs/extensions/schemaMigration/SchemaMigration.ts @@ -1,3 +1,4 @@ +import { Schema } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import * as Y from "yjs"; @@ -5,24 +6,80 @@ import { createExtension, ExtensionOptions, } from "../../../editor/BlockNoteExtension.js"; -import migrationRules from "./migrationRules/index.js"; +import migrationRules, { + preSyncMigrationRules, +} from "./migrationRules/index.js"; // This plugin allows us to update collaboration YDocs whenever BlockNote's -// underlying ProseMirror schema changes. The plugin reads the current Yjs -// fragment and dispatches additional transactions to the ProseMirror state, in -// case things are found in the fragment that don't adhere to the editor schema -// and need to be fixed. These fixes are defined as `MigrationRule`s within the -// `migrationRules` directory. +// underlying ProseMirror schema changes. There are two kinds of migration: +// +// - Pre-sync (`preSyncMigrationRules`): mutate the Yjs fragment directly and +// must run BEFORE y-prosemirror reconstructs the document. This is required +// when invalid content would make y-prosemirror reject a node — its error +// handler DELETES the whole node from the Yjs doc (propagating the deletion +// to all peers), so the fragment has to be fixed first. +// - Post-sync (`migrationRules`): repair the reconstructed ProseMirror document +// via an appended transaction, for changes where the node survives sync. export const SchemaMigration = createExtension( - ({ options }: ExtensionOptions<{ fragment: Y.XmlFragment }>) => { + ({ editor, options }: ExtensionOptions<{ fragment: Y.XmlFragment }>) => { + const { fragment } = options; + + // Runs the pre-sync rules over the whole fragment. Safe to call repeatedly: + // the rules are no-ops once the fragment is clean, so the transaction they + // run in produces no change (and thus doesn't re-trigger the observer). The + // rules need the ProseMirror schema, which only exists once the editor has + // been constructed — so callers pass it in. + const runPreSyncMigrations = (schema: Schema) => { + fragment.doc?.transact(() => { + for (const rule of preSyncMigrationRules) { + rule(fragment, schema); + } + }); + }; + // The observer is registered before the editor mounts (so it precedes + // y-prosemirror's), but only fires later — by which point `editor.pmSchema` + // is available. + const preSyncObserver = () => { + if (editor.pmSchema) { + runPreSyncMigrations(editor.pmSchema); + } + }; + // `unobserveDeep` is a no-op if the observer isn't (or is no longer) + // registered, so this is safe to call more than once. + const stopPreSyncObserver = () => fragment.unobserveDeep(preSyncObserver); + + // Migrate content that streams in from a provider after mount. Registered + // here — in the extension factory, before the editor mounts — so it runs + // before y-prosemirror's own observer and can clean the fragment first. + // Removed once migration is done (see `appendTransaction`). + fragment.observeDeep(preSyncObserver); + let migrationDone = false; const pluginKey = new PluginKey("schemaMigration"); return { key: "schemaMigration", + // Run before y-sync so the pre-sync migration (in this plugin's `view`) + // cleans the fragment before y-prosemirror reconstructs the document. + runsBefore: ["ySync"], prosemirrorPlugins: [ new Plugin({ key: pluginKey, + view: (view) => { + // Migrate content already present before mount (e.g. a document + // loaded from a database). `observeDeep` only fires on later + // changes, so existing content is handled explicitly here — in the + // plugin's view init, where the schema is available, and before + // y-prosemirror reconstructs the document. + if (fragment.firstChild) { + runPreSyncMigrations(view.state.schema); + } + return { + // Safety net: stop observing if the editor is destroyed before any + // content ever synced in (so `migrationDone` was never reached). + destroy: stopPreSyncObserver, + }; + }, appendTransaction: (transactions, _oldState, newState) => { if (migrationDone) { return undefined; @@ -45,6 +102,14 @@ export const SchemaMigration = createExtension( } migrationDone = true; + // The initial document has now synced in and been migrated by the + // pre-sync observer (which runs before this, during the y-sync). New + // edits can't introduce content the schema rejects, so the observer + // is no longer needed — stop it instead of running on every later + // transaction. (Like the post-sync migration, this is one-shot: + // disallowed content arriving from an old-version peer *after* the + // first sync would not be re-migrated.) + stopPreSyncObserver(); if (!tr.docChanged) { return undefined; diff --git a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts index 04957523f5..5012c66739 100644 --- a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts +++ b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/index.ts @@ -1,4 +1,14 @@ -import { MigrationRule } from "./migrationRule.js"; +import { MigrationRule, PreSyncMigrationRule } from "./migrationRule.js"; import { moveColorAttributes } from "./moveColorAttributes.js"; +import { stripDisallowedMarks } from "./stripDisallowedMarks.js"; -export default [moveColorAttributes] as MigrationRule[]; +// Rules that run AFTER y-prosemirror reconstructs the document (via a tr). +export const migrationRules = [moveColorAttributes] as MigrationRule[]; + +// Rules that run BEFORE y-prosemirror reconstructs the document (mutating the +// Yjs fragment directly). +export const preSyncMigrationRules = [ + stripDisallowedMarks, +] as PreSyncMigrationRule[]; + +export default migrationRules; diff --git a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts index ba0b77220f..0614af1078 100644 --- a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts +++ b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/migrationRule.ts @@ -1,4 +1,18 @@ +import { Schema } from "@tiptap/pm/model"; import { Transaction } from "@tiptap/pm/state"; import * as Y from "yjs"; +// Runs AFTER y-prosemirror has reconstructed the document, repairing it via a +// ProseMirror transaction. Suitable for schema changes where the node survives +// reconstruction (e.g. an attribute moved to a different node). export type MigrationRule = (fragment: Y.XmlFragment, tr: Transaction) => void; + +// Runs BEFORE y-prosemirror reconstructs the document, mutating the Yjs +// fragment directly. Needed for schema changes where invalid content would make +// y-prosemirror reject (and delete) a node during reconstruction — those must +// be fixed on the fragment first, so there is no ProseMirror transaction yet. +// Receives the ProseMirror schema so it can decide what is (in)valid. +export type PreSyncMigrationRule = ( + fragment: Y.XmlFragment, + schema: Schema, +) => void; diff --git a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts new file mode 100644 index 0000000000..fd315e1d5e --- /dev/null +++ b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Y from "yjs"; + +import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js"; +import { CommentsExtension } from "../../../../comments/extension.js"; +import { DefaultThreadStoreAuth } from "../../../../comments/threadstore/DefaultThreadStoreAuth.js"; +import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../../../schema/index.js"; +import { YjsThreadStore } from "../../../comments/YjsThreadStore.js"; +import { withCollaboration } from "../../index.js"; +import { stripDisallowedMarks } from "./stripDisallowedMarks.js"; + +/** + * @vitest-environment jsdom + */ + +// A "legacy" schema where the code block holds inline content — i.e. the schema +// from BEFORE the migration to "plain", where a code block could carry +// formatting marks. Used to produce fixtures via a real editor so that marks +// are encoded in Yjs exactly as y-prosemirror encodes them (rather than by +// hand, which could assume the wrong attribute names). +const legacySchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + codeBlock: createBlockSpec( + createBlockConfig(() => ({ + type: "codeBlock" as const, + propSchema: { language: { default: "" } }, + content: "inline" as const, + })), + { + render: () => { + const dom = document.createElement("pre"); + const code = document.createElement("code"); + dom.append(code); + return { dom, contentDOM: code }; + }, + }, + )(), + }, +}); + +const createThreadStore = (ydoc: Y.Doc) => + new YjsThreadStore( + "test-user", + ydoc.getMap("threads"), + new DefaultThreadStoreAuth("test-user", "editor"), + ); + +// Mounts a real collaborative editor on the legacy schema (code block = inline, +// so it allows marks), seeded with a bold-formatted code block. Returns the +// editor (typed loosely, since it doesn't use the default schema) so callers can +// apply extra marks before destroying it. Marks therefore land in Yjs exactly +// the way y-prosemirror writes them. +const createLegacyEditor = ( + fragment: Y.XmlFragment, + opts: { threadStore?: YjsThreadStore } = {}, +) => { + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment, + user: { color: "#0000ff", name: "Legacy User" }, + provider: undefined, + }, + schema: legacySchema, + ...(opts.threadStore && { + extensions: [ + CommentsExtension({ + threadStore: opts.threadStore, + resolveUsers: async () => [], + }), + ], + }), + }) as Parameters[0], + ) as unknown as BlockNoteEditor; + editor.mount(document.createElement("div")); + editor.replaceBlocks(editor.document, [ + { + type: "codeBlock", + props: { language: "javascript" }, + content: [{ type: "text", text: "const x = 1;", styles: { bold: true } }], + }, + ]); + return editor; +}; + +// Populates `fragment` with a bold-formatted code block (optionally also a +// suggestion/`insertion` mark). +const buildLegacyFormattedCodeBlock = ( + fragment: Y.XmlFragment, + opts: { withSuggestion?: boolean } = {}, +) => { + const editor = createLegacyEditor(fragment); + if (opts.withSuggestion) { + editor.transact((tr) => + tr.addMark( + 0, + tr.doc.content.size, + editor.pmSchema.marks.insertion.create({ id: 1 }), + ), + ); + } + editor._tiptapEditor.destroy(); +}; + +// Populates `ydoc` with a code block carrying a bold mark AND a real comment +// (created through the comment API, so the comment mark is encoded by +// y-prosemirror as `comment--`). +const buildLegacyCommentedCodeBlock = async (ydoc: Y.Doc) => { + const editor = createLegacyEditor(ydoc.getXmlFragment("doc"), { + threadStore: createThreadStore(ydoc), + }); + (editor as any)._tiptapEditor.commands.selectAll(); + await (editor.getExtension(CommentsExtension) as any).createThread({ + initialComment: { body: "review this" }, + }); + editor._tiptapEditor.destroy(); +}; + +// An editor (schema) WITH comments configured, so the comment mark exists and is +// allowed on the code block. +const commentsSchemaEditor = () => + BlockNoteEditor.create({ + extensions: [ + CommentsExtension({ + threadStore: createThreadStore(new Y.Doc()), + resolveUsers: async () => [], + }), + ], + }); + +const createCollabEditor = (fragment: Y.XmlFragment) => { + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment, + user: { color: "#ff0000", name: "Test User" }, + provider: undefined, + }, + }), + ); + editor.mount(document.createElement("div")); + return editor; +}; + +// Collects every mark-attribute key present on a fragment's text. +const markKeysIn = (fragment: Y.XmlFragment) => { + const keys = new Set(); + const walk = (node: Y.AbstractType) => { + if (node instanceof Y.XmlText) { + for (const op of node.toDelta() as { attributes?: object }[]) { + if (op.attributes) { + Object.keys(op.attributes).forEach((k) => keys.add(k)); + } + } + } else if (node instanceof Y.XmlElement || node instanceof Y.XmlFragment) { + node.toArray().forEach(walk); + } + }; + fragment.toArray().forEach(walk); + return keys; +}; + +describe("stripDisallowedMarks rule", () => { + it("removes formatting from a plain block's text while keeping the text", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + buildLegacyFormattedCodeBlock(fragment); + + // The fixture really does carry a bold mark, encoded by y-prosemirror. + expect(markKeysIn(fragment).has("bold")).toBe(true); + + stripDisallowedMarks(fragment, BlockNoteEditor.create().pmSchema); + + expect(markKeysIn(fragment).has("bold")).toBe(false); + expect(fragment.toJSON()).toContain("const x = 1;"); + }); + + it("keeps suggestion marks while stripping formatting", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + buildLegacyFormattedCodeBlock(fragment, { withSuggestion: true }); + + const before = markKeysIn(fragment); + expect(before.has("bold")).toBe(true); + expect(before.has("insertion")).toBe(true); + + stripDisallowedMarks(fragment, BlockNoteEditor.create().pmSchema); + + const after = markKeysIn(fragment); + expect(after.has("bold")).toBe(false); // formatting stripped + expect(after.has("insertion")).toBe(true); // suggestion (allowed) kept + expect(fragment.toJSON()).toContain("const x = 1;"); + }); + + it("keeps a real comment mark (encoded as comment--) while stripping formatting", async () => { + const ydoc = new Y.Doc(); + await buildLegacyCommentedCodeBlock(ydoc); + const fragment = ydoc.getXmlFragment("doc"); + + // The comment mark is stored under a hashed key, not just "comment". + const before = markKeysIn(fragment); + expect(before.has("bold")).toBe(true); + expect([...before].some((k) => k.startsWith("comment--"))).toBe(true); + + // Run the migration with a comments-configured schema, so the comment mark + // exists in the schema and is allowed on the code block. + stripDisallowedMarks(fragment, commentsSchemaEditor().pmSchema); + + const after = markKeysIn(fragment); + expect(after.has("bold")).toBe(false); // formatting stripped + // The hashed comment mark is resolved to "comment" and kept. + expect([...after].some((k) => k.startsWith("comment--"))).toBe(true); + expect(fragment.toJSON()).toContain("const x = 1;"); + }); + + it("leaves an already-clean code block untouched", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + // A plain code block produced by the current schema (no disallowed marks). + const editor = createCollabEditor(fragment); + editor.replaceBlocks(editor.document, [ + { type: "codeBlock", content: "const x = 1;" }, + ]); + editor._tiptapEditor.destroy(); + + const before = fragment.toJSON(); + stripDisallowedMarks(fragment, BlockNoteEditor.create().pmSchema); + + expect(fragment.toJSON()).toBe(before); + }); +}); + +describe("SchemaMigration: formatted code block backwards compatibility", () => { + it("preserves a formatted code block present before mount", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + buildLegacyFormattedCodeBlock(fragment); + + const editor = createCollabEditor(fragment); + + expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]); + expect(editor.document[0].content).toBe("const x = 1;"); + + editor._tiptapEditor.destroy(); + }); + + it("stops observing the fragment once migration is done", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + + // Mounts on an empty fragment: the pre-sync observer is registered and + // migration has not yet run (no content). + const editor = createCollabEditor(fragment); + + const deepObserverCount = () => + (fragment as unknown as { _dEH?: { l?: unknown[] } })._dEH?.l?.length ?? + 0; + const beforeSync = deepObserverCount(); + + // First content-bearing sync completes migration, which removes the + // observer. + const remoteDoc = new Y.Doc(); + buildLegacyFormattedCodeBlock(remoteDoc.getXmlFragment("doc")); + Y.applyUpdate(doc, Y.encodeStateAsUpdate(remoteDoc)); + + expect(deepObserverCount()).toBe(beforeSync - 1); + expect(editor.document[0].content).toBe("const x = 1;"); + + editor._tiptapEditor.destroy(); + }); + + it("preserves a formatted code block that syncs in after mount", () => { + const remoteDoc = new Y.Doc(); + buildLegacyFormattedCodeBlock(remoteDoc.getXmlFragment("doc")); + + const localDoc = new Y.Doc(); + const localFragment = localDoc.getXmlFragment("doc"); + + // Editor mounts on an empty fragment, before any content arrives. + const editor = createCollabEditor(localFragment); + expect(localFragment.toJSON()).toBe(""); + + // Provider delivers the initial state after mount. + Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(remoteDoc)); + + // The code block survives in the editor... + expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]); + expect(editor.document[0].content).toBe("const x = 1;"); + // ...and in the Yjs fragment — i.e. it was NOT deleted (which would + // otherwise propagate the deletion back to every peer)... + expect(localFragment.toJSON()).toContain("const x = 1;"); + // ...with its formatting dropped. + expect(markKeysIn(localFragment).has("bold")).toBe(false); + + editor._tiptapEditor.destroy(); + }); +}); diff --git a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts new file mode 100644 index 0000000000..484b76c70b --- /dev/null +++ b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/stripDisallowedMarks.ts @@ -0,0 +1,98 @@ +import { NodeType, Schema } from "@tiptap/pm/model"; +import * as Y from "yjs"; + +import { PreSyncMigrationRule } from "./migrationRule.js"; + +// Recursively traverses a `Y.XmlElement` and its descendant elements. +const traverseElement = ( + rootElement: Y.XmlElement, + cb: (element: Y.XmlElement) => void, +) => { + cb(rootElement); + rootElement.forEach((element) => { + if (element instanceof Y.XmlElement) { + traverseElement(element, cb); + } + }); +}; + +// Removes the marks that `nodeType` doesn't allow (and any marks that aren't in +// the schema at all) from a `Y.XmlText`. +const stripDisallowedFromText = ( + text: Y.XmlText, + nodeType: NodeType, + schema: Schema, +) => { + const markKeys = new Set(); + for (const op of text.toDelta() as { + attributes?: Record; + }[]) { + if (op.attributes) { + for (const key of Object.keys(op.attributes)) { + markKeys.add(key); + } + } + } + + const keysToStrip = [...markKeys].filter((key) => { + // y-prosemirror encodes "overlapping" marks (those that don't exclude + // themselves, e.g. comments) as `${markName}--${hash}` so multiple + // instances can coexist on a range. Resolve the base mark name before + // looking it up in the schema. + const markName = key.split("--")[0]; + const markType = schema.marks[markName]; + return !markType || !nodeType.allowsMarkType(markType); + }); + + if (keysToStrip.length === 0) { + return; + } + + // Setting each attribute to `null` removes that mark across the range. + text.format( + 0, + text.length, + Object.fromEntries(keysToStrip.map((key) => [key, null])), + ); +}; + +// Strips marks a node's ProseMirror type doesn't allow from its text. +// +// Older documents may contain blocks whose content was previously `"inline"` +// (and so could hold formatting marks like bold) but is now `"plain"` — whose +// node only allows the non-formatting marks (comments and suggestions/diffs, +// via the `NON_FORMATTING_MARK_GROUP`). y-prosemirror does not strip disallowed +// marks; instead it rejects the whole node (`createChecked` throws) and its +// error handler DELETES that node from the Yjs document, propagating the +// deletion to all peers. This rule removes the disallowed marks from the +// fragment so the node stays valid and survives reconstruction — preserving the +// text as well as any marks the node still allows. +// +// Whether a mark is allowed is read straight from the schema +// (`NodeType.allowsMarkType`), so no list of mark names needs to be maintained. +// +// Unlike the post-sync `MigrationRule`s, this must run BEFORE y-prosemirror +// reconstructs the document, so it mutates the Yjs fragment directly. +export const stripDisallowedMarks: PreSyncMigrationRule = ( + fragment, + schema, +) => { + fragment.forEach((element) => { + if (!(element instanceof Y.XmlElement)) { + return; + } + + traverseElement(element, (el) => { + const nodeType = schema.nodes[el.nodeName]; + if (!nodeType) { + return; + } + + el.forEach((child) => { + if (child instanceof Y.XmlText) { + stripDisallowedFromText(child, nodeType, schema); + } + }); + }); + }); +}; diff --git a/packages/core/src/yjs/utils.test.ts b/packages/core/src/yjs/utils.test.ts index 4db3dc57b3..5d093650a2 100644 --- a/packages/core/src/yjs/utils.test.ts +++ b/packages/core/src/yjs/utils.test.ts @@ -449,13 +449,7 @@ describe("Test yjs utils", () => { props: { language: "javascript", }, - content: [ - { - type: "text", - text: 'console.log("Hello, world!");', - styles: {}, - }, - ], + content: 'console.log("Hello, world!");', children: [], }, { @@ -464,13 +458,7 @@ describe("Test yjs utils", () => { props: { language: "typescript", }, - content: [ - { - type: "text", - text: "const x: number = 42;", - styles: {}, - }, - ], + content: "const x: number = 42;", children: [], }, ]; @@ -972,13 +960,7 @@ describe("Test yjs utils", () => { props: { language: "typescript", }, - content: [ - { - type: "text", - text: "const example = () => {\n return 'code';\n};", - styles: {}, - }, - ], + content: "const example = () => {\n return 'code';\n};", children: [], }, { diff --git a/packages/xl-ai/src/api/schema/__snapshots__/schemaToJSONSchema.test.ts.snap b/packages/xl-ai/src/api/schema/__snapshots__/schemaToJSONSchema.test.ts.snap index b40d8a32f7..a25dd15044 100644 --- a/packages/xl-ai/src/api/schema/__snapshots__/schemaToJSONSchema.test.ts.snap +++ b/packages/xl-ai/src/api/schema/__snapshots__/schemaToJSONSchema.test.ts.snap @@ -64,7 +64,7 @@ exports[`creates json schema 1`] = ` "additionalProperties": false, "properties": { "content": { - "$ref": "#/$defs/inlinecontent", + "type": "string", }, "props": { "additionalProperties": false, diff --git a/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts b/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts index 0ee05de3b1..97dae1fa67 100644 --- a/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts +++ b/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts @@ -217,9 +217,11 @@ function blockSchemaToJSONSchema(schema: BlockSchema) { content: val.content === "inline" ? { $ref: "#/$defs/inlinecontent" } - : val.content === "table" - ? { type: "object", properties: {} } // TODO - : undefined, + : val.content === "plain" + ? { type: "string" } + : val.content === "table" + ? { type: "object", properties: {} } // TODO + : undefined, // filter out default props (TODO: make option) props: propSchemaToJSONSchema(val.propSchema), // Object.fromEntries( diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 280a557725..dec1746797 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -4,7 +4,6 @@ import { createPageBreakBlockConfig, DefaultBlockSchema, DefaultProps, - StyledText, UnreachableCaseError, } from "@blocknote/core"; import { getImageDimensions } from "@shared/util/imageUtil.js"; @@ -163,7 +162,8 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< ]; }, codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; + // Code blocks hold plain (string) content. + const textContent = typeof block.content === "string" ? block.content : ""; return new Paragraph({ style: "SourceCode", diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx index a0befbcb32..b6b02f9bdb 100644 --- a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx @@ -3,7 +3,6 @@ import { createPageBreakBlockConfig, DefaultBlockSchema, mapTableCell, - StyledText, } from "@blocknote/core"; import { CodeBlock, @@ -260,7 +259,8 @@ export const createReactEmailBlockMappingForDefaultSchema = ( }, codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; + // Code blocks hold plain (string) content. + const textContent = typeof block.content === "string" ? block.content : ""; return ( { { id: "1", type: "codeBlock", - content: [ - { - type: "text", - text: "const hello = 'world';\nconsole.log(hello);", - styles: {}, - }, - ], + // Code blocks hold plain (string) content. + content: "const hello = 'world';\nconsole.log(hello);", children: [], props: { language: "javascript" }, }, diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx index 0889c2e2ac..0b323d9982 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx @@ -5,7 +5,6 @@ import { DefaultBlockSchema, DefaultProps, mapTableCell, - StyledText, TableCell, } from "@blocknote/core"; import { ODTExporter } from "../odtExporter.js"; @@ -501,7 +500,8 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< }, codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; + // Code blocks hold plain (string) content. + const textContent = typeof block.content === "string" ? block.content : ""; return ( diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx index 34985a1753..4a620c6255 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx @@ -3,7 +3,6 @@ import { DefaultBlockSchema, DefaultProps, createPageBreakBlockConfig, - StyledText, } from "@blocknote/core"; import { multiColumnSchema } from "@blocknote/xl-multi-column"; import { Image, Link, Path, Svg, Text, View } from "@react-pdf/renderer"; @@ -114,12 +113,8 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< ); }, codeBlock: (block) => { - // Code blocks should always contain a single `StyledText` inline content. - // However, if this is not the case for whatever reason, we can merge the - // text content of all `StyledText` content in them. - const textContent = (block.content as StyledText[]) - .map((item) => item.text) - .join(""); + // Code blocks hold plain (string) content. + const textContent = typeof block.content === "string" ? block.content : ""; const lines = textContent.split("\n").map((line, index) => { const indent = line.match(/^\s*/)?.[0].length || 0; diff --git a/shared/formatConversionTestUtil.ts b/shared/formatConversionTestUtil.ts index 565f0f1cfb..9ea5c9be6d 100644 --- a/shared/formatConversionTestUtil.ts +++ b/shared/formatConversionTestUtil.ts @@ -130,7 +130,7 @@ export function partialBlockToBlockForTesting< schema: BSchema, partialBlock: PartialBlock, ): Block { - const contentType: "inline" | "table" | "none" = + const contentType: "inline" | "table" | "none" | "plain" = schema[partialBlock.type!].content; const withDefaults: Block = { @@ -140,15 +140,17 @@ export function partialBlockToBlockForTesting< content: contentType === "inline" ? [] - : contentType === "table" - ? { - type: "tableContent", - columnWidths: undefined, - headerRows: undefined, - headerCols: undefined, - rows: [], - } - : (undefined as any), + : contentType === "plain" + ? "" + : contentType === "table" + ? { + type: "tableContent", + columnWidths: undefined, + headerRows: undefined, + headerCols: undefined, + rows: [], + } + : (undefined as any), children: [] as any, ...partialBlock, }; @@ -186,7 +188,12 @@ export function partialBlockToBlockForTesting< return { ...withDefaults, - content: partialContentToInlineContent(withDefaults.content), + // Plain content is a string and must be kept as-is (not converted into + // inline content). + content: + contentType === "plain" + ? (withDefaults.content ?? "") + : partialContentToInlineContent(withDefaults.content), children: withDefaults.children.map((c) => { return partialBlockToBlockForTesting(schema, c); }), diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/codeBlock.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/codeBlock.json index 63ebc503a5..fc815c0a7c 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/codeBlock.json +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/codeBlock.json @@ -1,13 +1,7 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "const x = 42;", - "type": "text", - }, - ], + "content": "const x = 42;", "id": "1", "props": { "language": "javascript", diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/complexDocument.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/complexDocument.json index 570c7e98d5..f082e77622 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/complexDocument.json +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/complexDocument.json @@ -90,13 +90,7 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "print('hello')", - "type": "text", - }, - ], + "content": "print('hello')", "id": "6", "props": { "language": "python", diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/defaultBlocks.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/defaultBlocks.json index 69bef1f3f3..9929a135f0 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/defaultBlocks.json +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/defaultBlocks.json @@ -177,13 +177,7 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "console.log('Hello, world!');", - "type": "text", - }, - ], + "content": "console.log('Hello, world!');", "id": "11", "props": { "language": "javascript", diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json index 0ede1c2000..9a2a180a18 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json @@ -107,14 +107,8 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "const x = `template ${literal}`; + "content": "const x = `template ${literal}`; const y = '```triple backticks```';", - "type": "text", - }, - ], "id": "5", "props": { "language": "text", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json index f9bd791440..6652033447 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json @@ -1,13 +1,7 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "console.log("Should default to JS")", - "type": "text", - }, - ], + "content": "console.log("Should default to JS")", "id": "1", "props": { "language": "text", @@ -16,13 +10,7 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "console.log("Should parse TS from data-language")", - "type": "text", - }, - ], + "content": "console.log("Should parse TS from data-language")", "id": "2", "props": { "language": "typescript", @@ -31,13 +19,7 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "print("Should parse Python from language- class")", - "type": "text", - }, - ], + "content": "print("Should parse Python from language- class")", "id": "3", "props": { "language": "python", @@ -46,13 +28,7 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "console.log("Should prioritize TS from data-language over language- class")", - "type": "text", - }, - ], + "content": "console.log("Should prioritize TS from data-language over language- class")", "id": "4", "props": { "language": "typescript", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json index 6cb94084f1..e9408973b7 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json @@ -1,15 +1,9 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "console.log("First Line") + "content": "console.log("First Line") console.log("Second Line") console.log("Third Line")", - "type": "text", - }, - ], "id": "1", "props": { "language": "text", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json index cf59869a6f..4cb72d808e 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json @@ -1,13 +1,7 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "console.log('Hello');", - "type": "text", - }, - ], + "content": "console.log('Hello');", "id": "1", "props": { "language": "text", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockIndented.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockIndented.json index 475525d84e..1016d2d867 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockIndented.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockIndented.json @@ -1,13 +1,7 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "const x = 1;", - "type": "text", - }, - ], + "content": "const x = 1;", "id": "1", "props": { "language": "ts", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockPython.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockPython.json index 78cd6179bf..e708f01337 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockPython.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockPython.json @@ -1,14 +1,8 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "def hello(): + "content": "def hello(): print("Hello, world!")", - "type": "text", - }, - ], "id": "1", "props": { "language": "python", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json index 1a656bd726..a106ccd66d 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json @@ -1,13 +1,7 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "code with tildes", - "type": "text", - }, - ], + "content": "code with tildes", "id": "1", "props": { "language": "text", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithLanguage.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithLanguage.json index 90eb554680..d1b791e511 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithLanguage.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithLanguage.json @@ -1,14 +1,8 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "const x = 42; + "content": "const x = 42; console.log(x);", - "type": "text", - }, - ], "id": "1", "props": { "language": "javascript", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithSpecialChars.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithSpecialChars.json index bdf7b585f8..c294772330 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithSpecialChars.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockWithSpecialChars.json @@ -1,15 +1,9 @@ [ { "children": [], - "content": [ - { - "styles": {}, - "text": "
+ "content": "

Hello **not bold**

", - "type": "text", - }, - ], "id": "1", "props": { "language": "html", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/complexDocument.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/complexDocument.json index 972d1d02df..b918b42b3f 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/complexDocument.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/complexDocument.json @@ -167,15 +167,9 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "function hello() { + "content": "function hello() { return "world"; }", - "type": "text", - }, - ], "id": "9", "props": { "language": "javascript", diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/headingThenCode.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/headingThenCode.json index d32797e0b3..9b61434488 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/headingThenCode.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/headingThenCode.json @@ -20,13 +20,7 @@ }, { "children": [], - "content": [ - { - "styles": {}, - "text": "x = 42", - "type": "text", - }, - ], + "content": "x = 42", "id": "2", "props": { "language": "python", diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 142a5e7771..f0c513b02f 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -118,7 +118,7 @@ }, "codeBlock": { "config": { - "content": "inline", + "content": "plain", "propSchema": { "language": { "default": "text", diff --git a/tests/src/utils/positionalMouse.ts b/tests/src/utils/positionalMouse.ts index 794a6dd76b..92a865e6d8 100644 --- a/tests/src/utils/positionalMouse.ts +++ b/tests/src/utils/positionalMouse.ts @@ -37,8 +37,8 @@ export type MouseAction = * `vite.config.browser.ts` as that's the only way to have access to the internal Playwright * context. */ -// eslint-disable-next-line @typescript-eslint/unbound-method -- destructuring page/frame from parameter object, not a class export const positionalMouse: BrowserCommand = async ( + // eslint-disable-next-line typescript/unbound-method -- destructuring page/frame from parameter object, not a class { page, frame }, ...actions ) => { diff --git a/vite.config.ts b/vite.config.ts index 144fb4d064..270db7a025 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -100,14 +100,14 @@ export default defineConfig({ "**/ui/**", "**/.source/**", // Non-library directories: skip all linting here. - // - docs/ needs Next.js type generation (next-env.d.ts) to typecheck - // - examples/, playground/, tests/ have 91+ separate tsconfigs that - // each spin up a tsgolint instance, adding ~20s to the type-aware pass. - // These are consumer/demo code — library packages are what matter. + // - docs/ and tests/nextjs-test-app/ need Next.js type generation + // (next-env.d.ts / .next) before they can be type-checked. + // - examples/ has 91 separate tsconfigs that each spin up a tsgolint + // instance, adding ~20s to the type-aware pass; it's demo code. "docs/**", "examples/**", "playground/**", - "tests/**", + "tests/nextjs-test-app/**", "fumadocs/**", ], },