From 2660ad261bcc33168d5f5dbf81da258c858171f1 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 15 Sep 2025 14:05:04 +0200 Subject: [PATCH 1/9] WIP styles API update --- .../html/util/serializeBlocksInternalHTML.ts | 38 ++++++-- packages/core/src/blocks/defaultBlocks.ts | 77 ++++++++++++++- packages/core/src/blocks/defaultProps.ts | 24 +---- packages/core/src/editor/Block.css | 18 ++++ packages/core/src/schema/styles/createSpec.ts | 96 +++++++++++++++---- packages/core/src/schema/styles/internal.ts | 7 +- packages/core/src/schema/styles/types.ts | 22 ++++- packages/react/src/editor/styles.css | 18 ++++ .../blocknoteHTML/paragraph/styled.html | 8 +- .../__snapshots__/html/paragraph/styled.html | 28 +++++- .../parse/parseTestInstances.ts | 4 +- 11 files changed, 278 insertions(+), 62 deletions(-) diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index bb158ad68c..12e3756a55 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -48,7 +48,6 @@ export function serializeInlineContentInternalHTML< // Check if this is a custom inline content node with toExternalHTML if ( node.type.name !== "text" && - node.type.name !== "link" && editor.schema.inlineContentSchema[node.type.name] ) { const inlineContentImplementation = @@ -90,14 +89,37 @@ export function serializeInlineContentInternalHTML< continue; } } - } + } else if (node.type.name === "text") { + // We serialize text nodes manually as we need to serialize the styles/ + // marks using `styleSpec.implementation.render`. When left up to + // ProseMirror, it'll use `toDOM` which is incorrect. + let dom: globalThis.Node | Text = document.createTextNode( + node.textContent, + ); + for (const mark of node.marks) { + if (mark.type.name in editor.schema.styleSpecs) { + const newDom = editor.schema.styleSpecs[ + mark.type.name + ].implementation.render(mark.attrs["stringValue"]); + newDom.contentDOM!.appendChild(dom); + dom = newDom.dom; + } else { + const domOutputSpec = mark.type.spec.toDOM!(mark, true); + const newDom = DOMSerializer.renderSpec(document, domOutputSpec); + newDom.contentDOM!.appendChild(dom); + dom = newDom.dom; + } + } - // Fall back to default serialization for this node - const nodeFragment = serializer.serializeFragment( - Fragment.from([node]), - options, - ); - fragment.appendChild(nodeFragment); + fragment.appendChild(dom); + } else { + // Fall back to default serialization for this node + const nodeFragment = serializer.serializeFragment( + Fragment.from([node]), + options, + ); + fragment.appendChild(nodeFragment); + } } return fragment; diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 916da1a7fe..a2d01b92df 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -16,9 +16,8 @@ import { createQuoteBlockSpec, createToggleListItemBlockSpec, createVideoBlockSpec, + defaultProps, } from "./index.js"; -import { BackgroundColor } from "../extensions/BackgroundColor/BackgroundColorMark.js"; -import { TextColor } from "../extensions/TextColor/TextColorMark.js"; import { BlockNoDefaults, BlockSchema, @@ -27,11 +26,13 @@ import { PartialBlockNoDefaults, StyleSchema, StyleSpecs, + createStyleSpec, createStyleSpecFromTipTapMark, getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; import { createTableBlockSpec } from "./Table/block.js"; +import { COLORS_DEFAULT } from "../editor/defaultColors.js"; export const defaultBlockSpecs = { audio: createAudioBlockSpec(), @@ -56,6 +57,78 @@ export type _DefaultBlockSchema = { }; export type DefaultBlockSchema = _DefaultBlockSchema; +const TextColor = createStyleSpec( + { + type: "textColor", + propSchema: "string", + }, + { + render: () => { + const span = document.createElement("span"); + + return { + dom: span, + contentDOM: span, + }; + }, + toExternalHTML: (value) => { + const span = document.createElement("span"); + if (value !== defaultProps.textColor.default) { + span.style.color = + value in COLORS_DEFAULT ? COLORS_DEFAULT[value].text : value; + } + + return { + dom: span, + contentDOM: span, + }; + }, + parse: (element) => { + if (element.tagName === "SPAN" && element.style.color) { + return element.style.color; + } + + return undefined; + }, + }, +); + +const BackgroundColor = createStyleSpec( + { + type: "backgroundColor", + propSchema: "string", + }, + { + render: () => { + const span = document.createElement("span"); + + return { + dom: span, + contentDOM: span, + }; + }, + toExternalHTML: (value) => { + const span = document.createElement("span"); + if (value !== defaultProps.backgroundColor.default) { + span.style.backgroundColor = + value in COLORS_DEFAULT ? COLORS_DEFAULT[value].background : value; + } + + return { + dom: span, + contentDOM: span, + }; + }, + parse: (element) => { + if (element.tagName === "SPAN" && element.style.backgroundColor) { + return element.style.backgroundColor; + } + + return undefined; + }, + }, +); + export const defaultStyleSpecs = { bold: createStyleSpecFromTipTapMark(Bold, "boolean"), italic: createStyleSpecFromTipTapMark(Italic, "boolean"), diff --git a/packages/core/src/blocks/defaultProps.ts b/packages/core/src/blocks/defaultProps.ts index 46c5417a85..5d55d21d35 100644 --- a/packages/core/src/blocks/defaultProps.ts +++ b/packages/core/src/blocks/defaultProps.ts @@ -92,20 +92,10 @@ export const getBackgroundColorAttribute = ( default: defaultProps.backgroundColor.default, parseHTML: (element) => { if (element.hasAttribute("data-background-color")) { - return element.getAttribute("data-background-color"); + return element.getAttribute("data-background-color")!; } if (element.style.backgroundColor) { - // Check if `element.style.backgroundColor` matches the string: - // `var(--blocknote-background-)`. If it does, return the color - // name only. Otherwise, return `element.style.backgroundColor`. - const match = element.style.backgroundColor.match( - /var\(--blocknote-background-(.+)\)/, - ); - if (match) { - return match[1]; - } - return element.style.backgroundColor; } @@ -128,18 +118,10 @@ export const getTextColorAttribute = ( default: defaultProps.textColor.default, parseHTML: (element) => { if (element.hasAttribute("data-text-color")) { - return element.getAttribute("data-text-color"); + return element.getAttribute("data-text-color")!; } if (element.style.color) { - // Check if `element.style.color` matches the string: - // `var(--blocknote-text-)`. If it does, return the color name - // only. Otherwise, return `element.style.color`. - const match = element.style.color.match(/var\(--blocknote-text-(.+)\)/); - if (match) { - return match[1]; - } - return element.style.color; } @@ -149,6 +131,7 @@ export const getTextColorAttribute = ( if (attributes[attributeName] === defaultProps.textColor.default) { return {}; } + return { "data-text-color": attributes[attributeName], }; @@ -174,6 +157,7 @@ export const getTextAlignmentAttribute = ( if (attributes[attributeName] === defaultProps.textAlignment.default) { return {}; } + return { "data-text-alignment": attributes[attributeName], }; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 6ed5361621..f882ba0559 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -559,92 +559,110 @@ NESTED BLOCKS /* TODO: should this be here? */ /* TEXT COLORS */ +[data-style-type="textColor"][data-value="gray"], [data-text-color="gray"], .bn-block:has(> .bn-block-content[data-text-color="gray"]) { color: #9b9a97; } +[data-style-type="textColor"][data-value="brown"], [data-text-color="brown"], .bn-block:has(> .bn-block-content[data-text-color="brown"]) { color: #64473a; } +[data-style-type="textColor"][data-value="red"], [data-text-color="red"], .bn-block:has(> .bn-block-content[data-text-color="red"]) { color: #e03e3e; } +[data-style-type="textColor"][data-value="orange"], [data-text-color="orange"], .bn-block:has(> .bn-block-content[data-text-color="orange"]) { color: #d9730d; } +[data-style-type="textColor"][data-value="yellow"], [data-text-color="yellow"], .bn-block:has(> .bn-block-content[data-text-color="yellow"]) { color: #dfab01; } +[data-style-type="textColor"][data-value="green"], [data-text-color="green"], .bn-block:has(> .bn-block-content[data-text-color="green"]) { color: #4d6461; } +[data-style-type="textColor"][data-value="blue"], [data-text-color="blue"], .bn-block:has(> .bn-block-content[data-text-color="blue"]) { color: #0b6e99; } +[data-style-type="textColor"][data-value="purple"], [data-text-color="purple"], .bn-block:has(> .bn-block-content[data-text-color="purple"]) { color: #6940a5; } +[data-style-type="textColor"][data-value="pink"], [data-text-color="pink"], .bn-block:has(> .bn-block-content[data-text-color="pink"]) { color: #ad1a72; } /* BACKGROUND COLORS */ +[data-style-type="backgroundColor"][data-value="gray"], [data-background-color="gray"], .bn-block:has(> .bn-block-content[data-background-color="gray"]) { background-color: #ebeced; } +[data-style-type="backgroundColor"][data-value="brown"], [data-background-color="brown"], .bn-block:has(> .bn-block-content[data-background-color="brown"]) { background-color: #e9e5e3; } +[data-style-type="backgroundColor"][data-value="red"], [data-background-color="red"], .bn-block:has(> .bn-block-content[data-background-color="red"]) { background-color: #fbe4e4; } +[data-style-type="backgroundColor"][data-value="orange"], [data-background-color="orange"], .bn-block:has(> .bn-block-content[data-background-color="orange"]) { background-color: #f6e9d9; } +[data-style-type="backgroundColor"][data-value="yellow"], [data-background-color="yellow"], .bn-block:has(> .bn-block-content[data-background-color="yellow"]) { background-color: #fbf3db; } +[data-style-type="backgroundColor"][data-value="green"], [data-background-color="green"], .bn-block:has(> .bn-block-content[data-background-color="green"]) { background-color: #ddedea; } +[data-style-type="backgroundColor"][data-value="blue"], [data-background-color="blue"], .bn-block:has(> .bn-block-content[data-background-color="blue"]) { background-color: #ddebf1; } +[data-style-type="backgroundColor"][data-value="purple"], [data-background-color="purple"], .bn-block:has(> .bn-block-content[data-background-color="purple"]) { background-color: #eae4f2; } +[data-style-type="backgroundColor"][data-value="pink"], [data-background-color="pink"], .bn-block:has(> .bn-block-content[data-background-color="pink"]) { background-color: #f4dfeb; diff --git a/packages/core/src/schema/styles/createSpec.ts b/packages/core/src/schema/styles/createSpec.ts index 8051f9f833..ad73d77169 100644 --- a/packages/core/src/schema/styles/createSpec.ts +++ b/packages/core/src/schema/styles/createSpec.ts @@ -1,7 +1,6 @@ import { Mark } from "@tiptap/core"; -import { ParseRule } from "@tiptap/pm/model"; -import { UnreachableCaseError } from "../../util/typescript.js"; +import { ParseRule, TagParseRule } from "@tiptap/pm/model"; import { addStyleAttributes, createInternalStyleSpec, @@ -19,12 +18,25 @@ export type CustomStyleImplementation = { dom: HTMLElement; contentDOM?: HTMLElement; }; + toExternalHTML?: T["propSchema"] extends "boolean" + ? () => { + dom: HTMLElement; + contentDOM?: HTMLElement; + } + : (value: string) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + parse?: T["propSchema"] extends "boolean" + ? (element: HTMLElement) => string | undefined + : (element: HTMLElement) => true | undefined; }; -// TODO: support serialization - -export function getStyleParseRules(config: StyleConfig): ParseRule[] { - return [ +export function getStyleParseRules( + config: T, + customParseFunction?: CustomStyleImplementation["parse"], +): ParseRule[] { + const rules: TagParseRule[] = [ { tag: `[data-style-type="${config.type}"]`, contentElement: (element) => { @@ -38,6 +50,26 @@ export function getStyleParseRules(config: StyleConfig): ParseRule[] { }, }, ]; + + if (customParseFunction) { + rules.push({ + tag: "*", + getAttrs(node: string | HTMLElement) { + if (typeof node === "string") { + return false; + } + + const stringValue = customParseFunction?.(node); + + if (stringValue === undefined) { + return false; + } + + return { stringValue }; + }, + }); + } + return rules; } export function createStyleSpec( @@ -52,22 +84,13 @@ export function createStyleSpec( }, parseHTML() { - return getStyleParseRules(styleConfig); + return getStyleParseRules(styleConfig, styleImplementation.parse); }, renderHTML({ mark }) { - let renderResult: { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; - - if (styleConfig.propSchema === "boolean") { - renderResult = styleImplementation.render(mark.attrs.stringValue); - } else if (styleConfig.propSchema === "string") { - renderResult = styleImplementation.render(mark.attrs.stringValue); - } else { - throw new UnreachableCaseError(styleConfig.propSchema); - } + const renderResult = ( + styleImplementation.toExternalHTML || styleImplementation.render + )(mark.attrs.stringValue); return addStyleAttributes( renderResult, @@ -76,9 +99,44 @@ export function createStyleSpec( styleConfig.propSchema, ); }, + + addMarkView() { + return ({ mark }) => { + const renderResult = styleImplementation.render(mark.attrs.stringValue); + + return addStyleAttributes( + renderResult, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema, + ); + }; + }, }); return createInternalStyleSpec(styleConfig, { mark, + render: (value) => { + const renderResult = styleImplementation.render(value); + + return addStyleAttributes( + renderResult, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, + toExternalHTML: (value) => { + const renderResult = ( + styleImplementation.toExternalHTML || styleImplementation.render + )(value); + + return addStyleAttributes( + renderResult, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, }); } diff --git a/packages/core/src/schema/styles/internal.ts b/packages/core/src/schema/styles/internal.ts index 0446db701b..289fb58102 100644 --- a/packages/core/src/schema/styles/internal.ts +++ b/packages/core/src/schema/styles/internal.ts @@ -66,7 +66,7 @@ export function addStyleAttributes< // config and implementation that conform to the type of Config export function createInternalStyleSpec( config: T, - implementation: StyleImplementation, + implementation: StyleImplementation, ) { return { config, @@ -85,6 +85,11 @@ export function createStyleSpecFromTipTapMark< }, { mark, + render: () => + mark.config.renderHTML!({ mark, HTMLAttributes: {} }) as { + dom: HTMLElement; + contentDOM: HTMLElement; + }, }, ); } diff --git a/packages/core/src/schema/styles/types.ts b/packages/core/src/schema/styles/types.ts index 1a44e9ffd3..c817dcefa8 100644 --- a/packages/core/src/schema/styles/types.ts +++ b/packages/core/src/schema/styles/types.ts @@ -11,15 +11,33 @@ export type StyleConfig = { // StyleImplementation contains the "implementation" info about a Style element. // Currently, the implementation is always a TipTap Mark -export type StyleImplementation = { +export type StyleImplementation = { mark: Mark; + render: T["propSchema"] extends "boolean" + ? () => { + dom: HTMLElement; + contentDOM?: HTMLElement; + } + : (value: string) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + toExternalHTML?: T["propSchema"] extends "boolean" + ? () => { + dom: HTMLElement; + contentDOM?: HTMLElement; + } + : (value: string) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; }; // Container for both the config and implementation of a Style, // and the type of `implementation` is based on that of the config export type StyleSpec = { config: T; - implementation: StyleImplementation; + implementation: StyleImplementation; }; // A Schema contains all the types (Configs) supported in an editor diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 37044b5df6..eb61a59fa2 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -140,74 +140,92 @@ } /* Highlight color styling */ +[data-style-type="textColor"][data-value="gray"], [data-text-color="gray"] { color: var(--bn-colors-highlights-gray-text); } +[data-style-type="textColor"][data-value="brown"], [data-text-color="brown"] { color: var(--bn-colors-highlights-brown-text); } +[data-style-type="textColor"][data-value="red"], [data-text-color="red"] { color: var(--bn-colors-highlights-red-text); } +[data-style-type="textColor"][data-value="orange"], [data-text-color="orange"] { color: var(--bn-colors-highlights-orange-text); } +[data-style-type="textColor"][data-value="yellow"], [data-text-color="yellow"] { color: var(--bn-colors-highlights-yellow-text); } +[data-style-type="textColor"][data-value="green"], [data-text-color="green"] { color: var(--bn-colors-highlights-green-text); } +[data-style-type="textColor"][data-value="blue"], [data-text-color="blue"] { color: var(--bn-colors-highlights-blue-text); } +[data-style-type="textColor"][data-value="purple"], [data-text-color="purple"] { color: var(--bn-colors-highlights-purple-text); } +[data-style-type="textColor"][data-value="pink"], [data-text-color="pink"] { color: var(--bn-colors-highlights-pink-text); } +[data-style-type="backgroundColor"][data-value="gray"], [data-background-color="gray"] { background-color: var(--bn-colors-highlights-gray-background); } +[data-style-type="backgroundColor"][data-value="brown"], [data-background-color="brown"] { background-color: var(--bn-colors-highlights-brown-background); } +[data-style-type="backgroundColor"][data-value="red"], [data-background-color="red"] { background-color: var(--bn-colors-highlights-red-background); } +[data-style-type="backgroundColor"][data-value="orange"], [data-background-color="orange"] { background-color: var(--bn-colors-highlights-orange-background); } +[data-style-type="backgroundColor"][data-value="yellow"], [data-background-color="yellow"] { background-color: var(--bn-colors-highlights-yellow-background); } +[data-style-type="backgroundColor"][data-value="green"], [data-background-color="green"] { background-color: var(--bn-colors-highlights-green-background); } +[data-style-type="backgroundColor"][data-value="blue"], [data-background-color="blue"] { background-color: var(--bn-colors-highlights-blue-background); } +[data-style-type="backgroundColor"][data-value="purple"], [data-background-color="purple"] { background-color: var(--bn-colors-highlights-purple-background); } +[data-style-type="backgroundColor"][data-value="pink"], [data-background-color="pink"] { background-color: var(--bn-colors-highlights-pink-background); } diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html index 699ad7f460..27f47f78b7 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html @@ -10,10 +10,10 @@ >

Plain - Red Text - Blue Background - - Mixed Colors + Red Text + Blue Background + + Mixed Colors

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/paragraph/styled.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/paragraph/styled.html index fd10eacf1a..637d51b2e0 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/paragraph/styled.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/paragraph/styled.html @@ -5,9 +5,29 @@ data-background-color="pink" > Plain - Red Text - Blue Background - - Mixed Colors + Red Text + Blue Background + + Mixed Colors

\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts index 0c89c74541..7a6781b938 100644 --- a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts +++ b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts @@ -870,14 +870,14 @@ With Hard Break

{ testCase: { name: "textColorStyle", - content: `

Blue Text Blue Text

`, + content: `

Blue Text Blue Text

`, }, executeTest: testParseHTML, }, { testCase: { name: "backgroundColorStyle", - content: `

Blue Background Blue Background

`, + content: `

Blue Background Blue Background

`, }, executeTest: testParseHTML, }, From d5611a9ed301c73ccd6e19d93848ddfec62def8e Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 15 Sep 2025 15:07:55 +0200 Subject: [PATCH 2/9] refactor: better typing --- packages/core/src/schema/styles/createSpec.ts | 40 ++++++++----------- packages/core/src/schema/styles/types.ts | 28 +++++-------- 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/packages/core/src/schema/styles/createSpec.ts b/packages/core/src/schema/styles/createSpec.ts index ad73d77169..4bd83b3ae0 100644 --- a/packages/core/src/schema/styles/createSpec.ts +++ b/packages/core/src/schema/styles/createSpec.ts @@ -9,27 +9,19 @@ import { import { StyleConfig, StyleSpec } from "./types.js"; export type CustomStyleImplementation = { - render: T["propSchema"] extends "boolean" - ? () => { - dom: HTMLElement; - contentDOM?: HTMLElement; - } - : (value: string) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; - toExternalHTML?: T["propSchema"] extends "boolean" - ? () => { - dom: HTMLElement; - contentDOM?: HTMLElement; - } - : (value: string) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; - parse?: T["propSchema"] extends "boolean" - ? (element: HTMLElement) => string | undefined - : (element: HTMLElement) => true | undefined; + render: (value: T["propSchema"] extends "boolean" ? undefined : string) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + toExternalHTML?: ( + value: T["propSchema"] extends "boolean" ? undefined : string, + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + parse?: ( + element: HTMLElement, + ) => (T["propSchema"] extends "boolean" ? true : string) | undefined; }; export function getStyleParseRules( @@ -72,7 +64,7 @@ export function getStyleParseRules( return rules; } -export function createStyleSpec( +export function createStyleSpec( styleConfig: T, styleImplementation: CustomStyleImplementation, ): StyleSpec { @@ -117,7 +109,7 @@ export function createStyleSpec( return createInternalStyleSpec(styleConfig, { mark, render: (value) => { - const renderResult = styleImplementation.render(value); + const renderResult = styleImplementation.render(value as any); return addStyleAttributes( renderResult, @@ -129,7 +121,7 @@ export function createStyleSpec( toExternalHTML: (value) => { const renderResult = ( styleImplementation.toExternalHTML || styleImplementation.render - )(value); + )(value as any); return addStyleAttributes( renderResult, diff --git a/packages/core/src/schema/styles/types.ts b/packages/core/src/schema/styles/types.ts index c817dcefa8..93aa192d82 100644 --- a/packages/core/src/schema/styles/types.ts +++ b/packages/core/src/schema/styles/types.ts @@ -13,24 +13,16 @@ export type StyleConfig = { // Currently, the implementation is always a TipTap Mark export type StyleImplementation = { mark: Mark; - render: T["propSchema"] extends "boolean" - ? () => { - dom: HTMLElement; - contentDOM?: HTMLElement; - } - : (value: string) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; - toExternalHTML?: T["propSchema"] extends "boolean" - ? () => { - dom: HTMLElement; - contentDOM?: HTMLElement; - } - : (value: string) => { - dom: HTMLElement; - contentDOM?: HTMLElement; - }; + render: (value: T["propSchema"] extends "boolean" ? undefined : string) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + toExternalHTML?: ( + value: T["propSchema"] extends "boolean" ? undefined : string, + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; }; // Container for both the config and implementation of a Style, From 433837550cd4e5b47f27a13a922eb699693c54d6 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 15 Sep 2025 16:13:40 +0200 Subject: [PATCH 3/9] fix: pass the editor instance & wire it up --- .../html/util/serializeBlocksInternalHTML.ts | 2 +- packages/core/src/schema/styles/internal.ts | 32 ++++++++++++++++--- packages/core/src/schema/styles/types.ts | 7 +++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 12e3756a55..48afe36337 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -100,7 +100,7 @@ export function serializeInlineContentInternalHTML< if (mark.type.name in editor.schema.styleSpecs) { const newDom = editor.schema.styleSpecs[ mark.type.name - ].implementation.render(mark.attrs["stringValue"]); + ].implementation.render(mark.attrs["stringValue"], editor); newDom.contentDOM!.appendChild(dom); dom = newDom.dom; } else { diff --git a/packages/core/src/schema/styles/internal.ts b/packages/core/src/schema/styles/internal.ts index 289fb58102..10b5e3a860 100644 --- a/packages/core/src/schema/styles/internal.ts +++ b/packages/core/src/schema/styles/internal.ts @@ -85,11 +85,35 @@ export function createStyleSpecFromTipTapMark< }, { mark, - render: () => - mark.config.renderHTML!({ mark, HTMLAttributes: {} }) as { + render(value, editor) { + const toDOM = editor.pmSchema.marks[mark.name].spec.toDOM; + + if (toDOM === undefined) { + throw new Error( + "This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`.", + ); + } + + const markInstance = editor.pmSchema.mark(mark.name, { + stringValue: value, + }); + + const renderSpec = toDOM(markInstance, true); + + if (typeof renderSpec !== "object" || !("dom" in renderSpec)) { + throw new Error( + "Cannot use this block's default HTML serialization as its corresponding TipTap node's `renderHTML` function does not return an object with the `dom` property.", + ); + } + + return renderSpec as { dom: HTMLElement; - contentDOM: HTMLElement; - }, + contentDOM?: HTMLElement; + }; + }, + toExternalHTML(value, editor) { + return this.render(value, editor); + }, }, ); } diff --git a/packages/core/src/schema/styles/types.ts b/packages/core/src/schema/styles/types.ts index 93aa192d82..3be7cb9d6e 100644 --- a/packages/core/src/schema/styles/types.ts +++ b/packages/core/src/schema/styles/types.ts @@ -1,4 +1,5 @@ import { Mark } from "@tiptap/core"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; export type StylePropSchema = "boolean" | "string"; // TODO: use PropSchema as name? Use objects as type similar to blocks? @@ -13,12 +14,16 @@ export type StyleConfig = { // Currently, the implementation is always a TipTap Mark export type StyleImplementation = { mark: Mark; - render: (value: T["propSchema"] extends "boolean" ? undefined : string) => { + render: ( + value: T["propSchema"] extends "boolean" ? undefined : string, + editor: BlockNoteEditor, + ) => { dom: HTMLElement; contentDOM?: HTMLElement; }; toExternalHTML?: ( value: T["propSchema"] extends "boolean" ? undefined : string, + editor: BlockNoteEditor, ) => { dom: HTMLElement; contentDOM?: HTMLElement; From 4a02f4614867eb7fb50a9a629d1d6bdf13e77589 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 15 Sep 2025 16:14:12 +0200 Subject: [PATCH 4/9] fix: serialize to external HTML --- .../html/util/serializeBlocksExternalHTML.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index ef1eefb176..4b52effebc 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -93,6 +93,29 @@ export function serializeInlineContentExternalHTML< continue; } } + } else if (node.type.name === "text") { + // We serialize text nodes manually as we need to serialize the styles/ + // marks using `styleSpec.implementation.render`. When left up to + // ProseMirror, it'll use `toDOM` which is incorrect. + let dom: globalThis.Node | Text = document.createTextNode( + node.textContent, + ); + for (const mark of node.marks) { + if (mark.type.name in editor.schema.styleSpecs) { + const newDom = editor.schema.styleSpecs[ + mark.type.name + ].implementation.render(mark.attrs["stringValue"], editor); + newDom.contentDOM!.appendChild(dom); + dom = newDom.dom; + } else { + const domOutputSpec = mark.type.spec.toDOM!(mark, true); + const newDom = DOMSerializer.renderSpec(document, domOutputSpec); + newDom.contentDOM!.appendChild(dom); + dom = newDom.dom; + } + } + + fragment.appendChild(dom); } // Fall back to default serialization for this node From 8e0db667d01041ce1904575a7b9b214215c6c3e4 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 15 Sep 2025 16:16:16 +0200 Subject: [PATCH 5/9] feat: support react style specs --- packages/react/src/schema/ReactStyleSpec.tsx | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/react/src/schema/ReactStyleSpec.tsx b/packages/react/src/schema/ReactStyleSpec.tsx index 53b9c85eba..b83f727319 100644 --- a/packages/react/src/schema/ReactStyleSpec.tsx +++ b/packages/react/src/schema/ReactStyleSpec.tsx @@ -117,5 +117,55 @@ export function createReactStyleSpec( return createInternalStyleSpec(styleConfig, { mark, + render(value, editor) { + const Content = styleImplementation.render; + const output = renderToDOMSpec( + (ref) => ( + { + ref(element); + if (element) { + element.dataset.editable = ""; + } + }} + /> + ), + editor, + ); + + return addStyleAttributes( + output, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, + toExternalHTML(value, editor) { + const Content = styleImplementation.render; + const output = renderToDOMSpec( + (ref) => ( + { + ref(element); + if (element) { + element.dataset.editable = ""; + } + }} + /> + ), + editor, + ); + + return addStyleAttributes( + output, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, }); } From b459270b76076e4e654dd918b375c3be52ab5cc9 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 15 Sep 2025 17:12:57 +0200 Subject: [PATCH 6/9] fix: use toExternalHTML --- .../exporters/html/util/serializeBlocksExternalHTML.ts | 8 +++++--- packages/core/src/schema/styles/internal.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 4b52effebc..16c8d7794a 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -102,9 +102,11 @@ export function serializeInlineContentExternalHTML< ); for (const mark of node.marks) { if (mark.type.name in editor.schema.styleSpecs) { - const newDom = editor.schema.styleSpecs[ - mark.type.name - ].implementation.render(mark.attrs["stringValue"], editor); + const newDom = ( + editor.schema.styleSpecs[mark.type.name].implementation + .toExternalHTML ?? + editor.schema.styleSpecs[mark.type.name].implementation.render + )(mark.attrs["stringValue"], editor); newDom.contentDOM!.appendChild(dom); dom = newDom.dom; } else { diff --git a/packages/core/src/schema/styles/internal.ts b/packages/core/src/schema/styles/internal.ts index 10b5e3a860..e2c53f082d 100644 --- a/packages/core/src/schema/styles/internal.ts +++ b/packages/core/src/schema/styles/internal.ts @@ -102,7 +102,7 @@ export function createStyleSpecFromTipTapMark< if (typeof renderSpec !== "object" || !("dom" in renderSpec)) { throw new Error( - "Cannot use this block's default HTML serialization as its corresponding TipTap node's `renderHTML` function does not return an object with the `dom` property.", + "Cannot use this block's default HTML serialization as its corresponding TipTap mark's `renderHTML` function does not return an object with the `dom` property.", ); } From e4fc5c5d7888527b06e8287baad866402935f8e5 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 16 Sep 2025 10:36:33 +0200 Subject: [PATCH 7/9] Fixed unit tests --- .../html/util/serializeBlocksExternalHTML.ts | 46 +++++++++++++------ .../html/util/serializeBlocksInternalHTML.ts | 3 +- packages/core/src/schema/styles/internal.ts | 34 +++++++++++++- .../blocknoteHTML/paragraph/styled.html | 4 +- .../blocknoteHTML/customParagraph/styled.html | 8 ++-- .../simpleCustomParagraph/styled.html | 8 ++-- .../html/simpleCustomParagraph/styled.html | 28 +++++++++-- 7 files changed, 99 insertions(+), 32 deletions(-) diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 16c8d7794a..17ab49fe69 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -60,11 +60,14 @@ export function serializeInlineContentExternalHTML< for (const node of nodes) { // Check if this is a custom inline content node with toExternalHTML - if (editor.schema.inlineContentSchema[node.type.name]) { + if ( + node.type.name !== "text" && + editor.schema.inlineContentSchema[node.type.name] + ) { const inlineContentImplementation = editor.schema.inlineContentSpecs[node.type.name].implementation; - if (inlineContentImplementation?.toExternalHTML) { + if (inlineContentImplementation) { // Convert the node to inline content format const inlineContent = nodeToCustomInlineContent( node, @@ -72,11 +75,23 @@ export function serializeInlineContentExternalHTML< editor.schema.styleSchema, ); - // Use the custom toExternalHTML method - const output = inlineContentImplementation.toExternalHTML( - inlineContent as any, - editor as any, - ); + // Use the custom toExternalHTML method or fallback to `render` + const output = inlineContentImplementation.toExternalHTML + ? inlineContentImplementation.toExternalHTML( + inlineContent as any, + editor as any, + ) + : inlineContentImplementation.render.call( + { + renderType: "dom", + props: undefined, + }, + inlineContent as any, + () => { + // No-op + }, + editor as any, + ); if (output) { fragment.appendChild(output.dom); @@ -100,7 +115,8 @@ export function serializeInlineContentExternalHTML< let dom: globalThis.Node | Text = document.createTextNode( node.textContent, ); - for (const mark of node.marks) { + // Reverse the order of marks to maintain the correct priority. + for (const mark of node.marks.toReversed()) { if (mark.type.name in editor.schema.styleSpecs) { const newDom = ( editor.schema.styleSpecs[mark.type.name].implementation @@ -118,14 +134,14 @@ export function serializeInlineContentExternalHTML< } fragment.appendChild(dom); + } else { + // Fall back to default serialization for this node + const nodeFragment = serializer.serializeFragment( + Fragment.from([node]), + options, + ); + fragment.appendChild(nodeFragment); } - - // Fall back to default serialization for this node - const nodeFragment = serializer.serializeFragment( - Fragment.from([node]), - options, - ); - fragment.appendChild(nodeFragment); } if ( diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 48afe36337..bc39d70008 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -96,7 +96,8 @@ export function serializeInlineContentInternalHTML< let dom: globalThis.Node | Text = document.createTextNode( node.textContent, ); - for (const mark of node.marks) { + // Reverse the order of marks to maintain the correct priority. + for (const mark of node.marks.toReversed()) { if (mark.type.name in editor.schema.styleSpecs) { const newDom = editor.schema.styleSpecs[ mark.type.name diff --git a/packages/core/src/schema/styles/internal.ts b/packages/core/src/schema/styles/internal.ts index e2c53f082d..9d5711bd6e 100644 --- a/packages/core/src/schema/styles/internal.ts +++ b/packages/core/src/schema/styles/internal.ts @@ -1,4 +1,5 @@ import { Attributes, Mark } from "@tiptap/core"; +import { DOMSerializer } from "@tiptap/pm/model"; import { StyleConfig, StyleImplementation, @@ -98,7 +99,10 @@ export function createStyleSpecFromTipTapMark< stringValue: value, }); - const renderSpec = toDOM(markInstance, true); + const renderSpec = DOMSerializer.renderSpec( + document, + toDOM(markInstance, true), + ); if (typeof renderSpec !== "object" || !("dom" in renderSpec)) { throw new Error( @@ -112,7 +116,33 @@ export function createStyleSpecFromTipTapMark< }; }, toExternalHTML(value, editor) { - return this.render(value, editor); + const toDOM = editor.pmSchema.marks[mark.name].spec.toDOM; + + if (toDOM === undefined) { + throw new Error( + "This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`.", + ); + } + + const markInstance = editor.pmSchema.mark(mark.name, { + stringValue: value, + }); + + const renderSpec = DOMSerializer.renderSpec( + document, + toDOM(markInstance, true), + ); + + if (typeof renderSpec !== "object" || !("dom" in renderSpec)) { + throw new Error( + "Cannot use this block's default HTML serialization as its corresponding TipTap mark's `renderHTML` function does not return an object with the `dom` property.", + ); + } + + return renderSpec as { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; }, }, ); diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html index 27f47f78b7..b0e77ba748 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html @@ -12,8 +12,8 @@ Plain Red Text Blue Background - - Mixed Colors + + Mixed Colors

diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html index 654ebfd316..df960b11f1 100644 --- a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html @@ -12,10 +12,10 @@ >

Plain - Red Text - Blue Background - - Mixed Colors + Red Text + Blue Background + + Mixed Colors

diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/simpleCustomParagraph/styled.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/simpleCustomParagraph/styled.html index 6a0a83a4cb..ef3d75ab28 100644 --- a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/simpleCustomParagraph/styled.html +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/simpleCustomParagraph/styled.html @@ -12,10 +12,10 @@ >

Plain - Red Text - Blue Background - - Mixed Colors + Red Text + Blue Background + + Mixed Colors

diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/html/simpleCustomParagraph/styled.html b/tests/src/unit/react/formatConversion/export/__snapshots__/html/simpleCustomParagraph/styled.html index c2a35ebaee..87efb71bf5 100644 --- a/tests/src/unit/react/formatConversion/export/__snapshots__/html/simpleCustomParagraph/styled.html +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/html/simpleCustomParagraph/styled.html @@ -5,9 +5,29 @@ data-background-color="pink" > Plain - Red Text - Blue Background - - Mixed Colors + Red Text + Blue Background + + Mixed Colors

\ No newline at end of file From 53defdd7aca225c94d997553c4569c93586b6d5b Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 16 Sep 2025 10:50:01 +0200 Subject: [PATCH 8/9] Removed AI package TODO --- .../formats/html-blocks/htmlBlocks.test.ts | 24 +++++++------------ .../src/api/formats/tests/sharedTestCases.ts | 2 -- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/xl-ai/src/api/formats/html-blocks/htmlBlocks.test.ts b/packages/xl-ai/src/api/formats/html-blocks/htmlBlocks.test.ts index aafb57b6ad..20e7914c69 100644 --- a/packages/xl-ai/src/api/formats/html-blocks/htmlBlocks.test.ts +++ b/packages/xl-ai/src/api/formats/html-blocks/htmlBlocks.test.ts @@ -120,21 +120,15 @@ describe("Models", () => { describe(`${params.model.provider}/${params.model.modelId} (${ params.stream ? "streaming" : "non-streaming" })`, () => { - generateSharedTestCases( - (editor, options) => - doLLMRequest(editor, { - ...options, - dataFormat: htmlBlockLLMFormat, - model: params.model, - maxRetries: 0, - stream: params.stream, - withDelays: false, - }), - // TODO: remove when matthew's parsing PR is merged - { - textAlignment: true, - blockColor: true, - }, + generateSharedTestCases((editor, options) => + doLLMRequest(editor, { + ...options, + dataFormat: htmlBlockLLMFormat, + model: params.model, + maxRetries: 0, + stream: params.stream, + withDelays: false, + }), ); }); } diff --git a/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts b/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts index a2026ff442..47bca14b01 100644 --- a/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts +++ b/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts @@ -38,8 +38,6 @@ export function generateSharedTestCases( ) => Promise, skipTestsRequiringCapabilities?: { mentions?: boolean; - textAlignment?: boolean; - blockColor?: boolean; }, ) { function skipIfUnsupported( From 22d06aa0910bd534e437f621acfef732402bf2f3 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 16 Sep 2025 10:50:59 +0200 Subject: [PATCH 9/9] Small fix --- packages/xl-ai/src/api/formats/tests/sharedTestCases.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts b/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts index 47bca14b01..a2026ff442 100644 --- a/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts +++ b/packages/xl-ai/src/api/formats/tests/sharedTestCases.ts @@ -38,6 +38,8 @@ export function generateSharedTestCases( ) => Promise, skipTestsRequiringCapabilities?: { mentions?: boolean; + textAlignment?: boolean; + blockColor?: boolean; }, ) { function skipIfUnsupported(