Skip to content

feat: Better parsing of external HTML attributes & inline styles #1605

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
30 changes: 20 additions & 10 deletions examples/03-ui-components/13-custom-ui/MUIFormattingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,18 +262,24 @@ function MUITextAlignButton(props: {
const editor = useBlockNoteEditor<TextBlockSchema>();

// The text alignment of the block currently containing the text cursor.
const [activeTextAlignment, setActiveTextAlignment] = useState(
() => editor.getTextCursorPosition().block.props.textAlignment
);
const [activeTextAlignment, setActiveTextAlignment] = useState(() => {
const blockProps = editor.getTextCursorPosition().block.props;

if ("textAlignment" in blockProps) {
return blockProps.textAlignment;
}

return undefined;
});

// Updates the text alignment when the editor content or selection changes.
useEditorContentOrSelectionChange(
() =>
setActiveTextAlignment(
editor.getTextCursorPosition().block.props.textAlignment
),
editor
);
useEditorContentOrSelectionChange(() => {
const blockProps = editor.getTextCursorPosition().block.props;

if ("textAlignment" in blockProps) {
setActiveTextAlignment(blockProps.textAlignment);
}
}, editor);

// Tooltip for the button.
const tooltip = useMemo(
Expand All @@ -293,6 +299,10 @@ function MUITextAlignButton(props: {
editor.focus();
}, [editor, props.textAlignment]);

if (!activeTextAlignment) {
return null;
}

return (
<MUIToolbarButton
tooltip={tooltip}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,28 +117,55 @@ function serializeBlock<
].implementation.toExternalHTML({ ...block, props } as any, editor as any);

const elementFragment = doc.createDocumentFragment();
if (ret.dom.classList.contains("bn-block-content")) {
const blockContentDataAttributes = [
...attrs,
...Array.from(ret.dom.attributes),
].filter(
(attr) =>
attr.name.startsWith("data") &&
attr.name !== "data-content-type" &&
attr.name !== "data-file-block" &&
attr.name !== "data-node-view-wrapper" &&
attr.name !== "data-node-type" &&
attr.name !== "data-id" &&
attr.name !== "data-index" &&
attr.name !== "data-editable"
);

// ret.dom = ret.dom.firstChild! as any;
for (const attr of blockContentDataAttributes) {
(ret.dom.firstChild! as HTMLElement).setAttribute(attr.name, attr.value);
let listType = undefined;
if (orderedListItemBlockTypes.has(block.type!)) {
listType = "OL";
} else if (unorderedListItemBlockTypes.has(block.type!)) {
listType = "UL";
}

const blockContentDataAttributes = [
...attrs,
...Array.from(ret.dom.attributes),
].filter(
(attr) =>
attr.name.startsWith("data") &&
attr.name !== "data-content-type" &&
attr.name !== "data-file-block" &&
attr.name !== "data-node-view-wrapper" &&
attr.name !== "data-node-type" &&
attr.name !== "data-id" &&
attr.name !== "data-index" &&
attr.name !== "data-editable"
);

if (ret.dom.classList.contains("bn-block-content")) {
// We wrap the output in an `li` element for list items, and so we want to
// add the attributes to that element instead as it is the "root".
if (!listType) {
// Copies the styles and prop-related attributes from the `blockContent`
// element onto its first child, as the `blockContent` element is omitted
// from external HTML. This is so prop data is preserved via `data-*`
// attributes or inline styles.
//
// The styles are specifically for default props on default blocks, as
// they get converted from `data-*` attributes for external HTML. Will
// need to revisit this when we convert default blocks to use the custom
// block API.
const style = ret.dom.getAttribute("style");
if (style) {
(ret.dom.firstChild! as HTMLElement).setAttribute("style", style);
}
for (const attr of blockContentDataAttributes) {
(ret.dom.firstChild! as HTMLElement).setAttribute(
attr.name,
attr.value
);
}
}

addAttributesAndRemoveClasses(ret.dom.firstChild! as HTMLElement);
addAttributesAndRemoveClasses(ret.dom.firstChild as HTMLElement);
elementFragment.append(...Array.from(ret.dom.childNodes));
} else {
elementFragment.append(ret.dom);
Expand All @@ -155,13 +182,6 @@ function serializeBlock<
ret.contentDOM.appendChild(ic);
}

let listType = undefined;
if (orderedListItemBlockTypes.has(block.type!)) {
listType = "OL";
} else if (unorderedListItemBlockTypes.has(block.type!)) {
listType = "UL";
}

if (listType) {
if (fragment.lastChild?.nodeName !== listType) {
const list = doc.createElement(listType);
Expand All @@ -172,6 +192,24 @@ function serializeBlock<
fragment.append(list);
}
const li = doc.createElement("li");

// Copies the styles and prop-related attributes from the `blockContent`
// element onto its first child, as the `blockContent` element is omitted
// from external HTML. This is so prop data is preserved via `data-*`
// attributes or inline styles.
//
// The styles are specifically for default props on default blocks, as
// they get converted from `data-*` attributes for external HTML. Will
// need to revisit this when we convert default blocks to use the custom
// block API.
const style = ret.dom.getAttribute("style");
if (style) {
li.setAttribute("style", style);
}
for (const attr of blockContentDataAttributes) {
li.setAttribute(attr.name, attr.value);
}

li.append(elementFragment);
fragment.lastChild!.appendChild(li);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
const startIndex =
parseInt(parent.getAttribute("start") || "1") || 1;

if (element.previousSibling || startIndex === 1) {
if (element.previousElementSibling || startIndex === 1) {
return {};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { updateBlockCommand } from "../../api/blockManipulation/commands/updateB
import { InputRule } from "@tiptap/core";

export const quotePropSchema = {
...defaultProps,
backgroundColor: defaultProps.backgroundColor,
textColor: defaultProps.textColor,
};

export const QuoteBlockContent = createStronglyTypedTiptapNode({
Expand Down
76 changes: 74 additions & 2 deletions packages/core/src/blocks/defaultBlockHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { blockToNode } from "../api/nodeConversions/blockToNode.js";
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { COLORS_DEFAULT } from "../editor/defaultColors.js";
import type {
BlockNoDefaults,
BlockSchema,
Expand Down Expand Up @@ -55,14 +56,17 @@ export function createDefaultBlockDOMOutputSpec(

// Function used to convert default blocks to HTML. It uses the corresponding
// node's `renderHTML` method to do the conversion by using a default
// `DOMSerializer`.
// `DOMSerializer`. The `external` flag is used to modify the resulting HTML for
// external use. This just involves changing props being rendered from `data-*`
// attributes to inline styles.
export const defaultBlockToHTML = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
>(
block: BlockNoDefaults<BSchema, I, S>,
editor: BlockNoteEditor<BSchema, I, S>
editor: BlockNoteEditor<BSchema, I, S>,
external = false
): {
dom: HTMLElement;
contentDOM?: HTMLElement;
Expand Down Expand Up @@ -90,6 +94,74 @@ export const defaultBlockToHTML = <
);
}

// When exporting to external HTML, we convert from `data-*` attributes to
// inline styles properties which can be understood by external applications.
//
// Note: This is a bit hacky to do this here as we're just hardcoding this for
// props on default blocks. We should revisit this when we migrate internal
// blocks to use the custom blocks API.
if (external) {
const dom = renderSpec.dom as HTMLElement;

if (dom.hasAttribute("data-background-color")) {
const backgroundColor = dom.getAttribute("data-background-color")!;

// If the background color is one of the default colors, we set the
// color's hex code from the default theme, as this will look nicer than
// using regular CSS colors. For example, instead of
// `background-color: red`, we use `background-color: #fbe4e4`.
if (backgroundColor in COLORS_DEFAULT) {
const cssVariableName =
`--blocknote-background-${backgroundColor}` as any;

dom.style.setProperty(
cssVariableName,
COLORS_DEFAULT[backgroundColor as keyof typeof COLORS_DEFAULT]
.background
);
dom.style.backgroundColor = `var(${cssVariableName})`;
} else {
dom.style.backgroundColor = backgroundColor;
}

dom.removeAttribute("data-background-color");
}

if (dom.hasAttribute("data-text-color")) {
const textColor = dom.getAttribute("data-text-color")!;

// If the text color is one of the default colors, we set the color's hex
// code from the default theme, as this will look nicer than using regular
// CSS colors. For example, instead of `color: red`, we use
// `color: #e03e3e`.
if (textColor in COLORS_DEFAULT) {
const cssVariableName = `--blocknote-text-${textColor}` as any;

dom.style.setProperty(
cssVariableName,
COLORS_DEFAULT[textColor as keyof typeof COLORS_DEFAULT].text
);
dom.style.color = `var(${cssVariableName})`;
} else {
dom.style.color = textColor;
}

dom.removeAttribute("data-text-color");
}

if (dom.hasAttribute("data-text-alignment")) {
dom.style.textAlign = dom.getAttribute("data-text-alignment")!;
dom.removeAttribute("data-text-alignment");
}

// We also remove the `data-level` attribute for heading blocks, as this
// information can be inferred from whether a `h1`, `h2`, or `h3 tag is
// used.
if (dom.hasAttribute("data-level")) {
dom.removeAttribute("data-level");
}
}

return renderSpec as {
dom: HTMLElement;
contentDOM?: HTMLElement;
Expand Down
103 changes: 99 additions & 4 deletions packages/core/src/blocks/defaultProps.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Attribute } from "@tiptap/core";

import type { Props, PropSchema } from "../schema/index.js";

// TODO: this system should probably be moved / refactored.
Expand All @@ -18,7 +20,100 @@ export const defaultProps = {

export type DefaultProps = Props<typeof defaultProps>;

// Default props which are set on `blockContainer` nodes rather than
// `blockContent` nodes. Ensures that they are not redundantly added to
// a custom block's TipTap node attributes.
export const inheritedProps = ["backgroundColor", "textColor"];
const getBackgroundColorAttribute = (
attributeName = "backgroundColor"
): Attribute => ({
default: defaultProps.backgroundColor.default,
parseHTML: (element) => {
if (element.hasAttribute("data-background-color")) {
return element.getAttribute("data-background-color");
}

if (element.style.backgroundColor) {
// Check if `element.style.backgroundColor` matches the string:
// `var(--blocknote-background-<color>)`. 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;
}

return defaultProps.backgroundColor.default;
},
renderHTML: (attributes) => {
if (attributes[attributeName] === defaultProps.backgroundColor.default) {
return {};
}

return {
"data-background-color": attributes[attributeName],
};
},
});

const getTextColorAttribute = (attributeName = "textColor"): Attribute => ({
default: defaultProps.textColor.default,
parseHTML: (element) => {
if (element.hasAttribute("data-text-color")) {
return element.getAttribute("data-text-color");
}

if (element.style.color) {
// Check if `element.style.color` matches the string:
// `var(--blocknote-text-<color>)`. 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;
}

return defaultProps.textColor.default;
},
renderHTML: (attributes) => {
if (attributes[attributeName] === defaultProps.textColor.default) {
return {};
}
return {
"data-text-color": attributes[attributeName],
};
},
});

const getTextAlignmentAttribute = (
attributeName = "textAlignment"
): Attribute => ({
default: defaultProps.textAlignment.default,
parseHTML: (element) => {
if (element.hasAttribute("data-text-alignment")) {
return element.getAttribute("data-text-alignment");
}

if (element.style.textAlign) {
return element.style.textAlign;
}

return defaultProps.textAlignment.default;
},
renderHTML: (attributes) => {
if (attributes[attributeName] === defaultProps.textAlignment.default) {
return {};
}
return {
"data-text-alignment": attributes[attributeName],
};
},
});

export const getAttributeFromDefaultProps = {
backgroundColor: getBackgroundColorAttribute,
textColor: getTextColorAttribute,
textAlignment: getTextAlignmentAttribute,
};
Loading
Loading