Skip to content

feat: list "start" index and add optional props #1326

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

Merged
merged 20 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function fragmentToExternalHTML<
editor.schema.styleSchema
);

// Wrap in table to ensure correct parsing by spreadsheet applications
externalHTML = `<table>${externalHTMLExporter.exportInlineContent(
ic as any,
{}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ function serializeBlock<
for (const [name, spec] of Object.entries(
editor.schema.blockSchema[block.type as any].propSchema
)) {
(props as any)[name] = spec.default;
if (spec.default !== undefined) {
(props as any)[name] = spec.default;
}
}
}

Expand Down Expand Up @@ -172,6 +174,10 @@ function serializeBlock<
if (listType) {
if (fragment.lastChild?.nodeName !== listType) {
const list = doc.createElement(listType);

if (listType === "OL" && props?.start && props?.start !== 1) {
list.setAttribute("start", props.start + "");
}
fragment.append(list);
}
const li = doc.createElement("li");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ function serializeBlock<
for (const [name, spec] of Object.entries(
editor.schema.blockSchema[block.type as any].propSchema
)) {
(props as any)[name] = spec.default;
if (spec.default !== undefined) {
(props as any)[name] = spec.default;
}
}
}

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/api/nodeConversions/nodeToBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,10 @@ export function nodeToBlock<
})) {
const propSchema = blockSpec.propSchema;

if (attr in propSchema) {
if (
attr in propSchema &&
!(propSchema[attr].default === undefined && value === undefined)
) {
props[attr] = value;
}
}
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/api/testUtil/partialBlockTestUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ export function partialBlockToBlockForTesting<

Object.entries(schema[partialBlock.type!].propSchema).forEach(
([propKey, propValue]) => {
if (withDefaults.props[propKey] === undefined) {
if (
withDefaults.props[propKey] === undefined &&
propValue.default !== undefined
) {
(withDefaults.props as any)[propKey] = propValue.default;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PropSchema,
createBlockSpecFromStronglyTypedTiptapNode,
createStronglyTypedTiptapNode,
propsToAttributes,
} from "../../schema/index.js";
import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
import { defaultProps } from "../defaultProps.js";
Expand All @@ -18,26 +19,9 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
name: "heading",
content: "inline*",
group: "blockContent",

addAttributes() {
return {
level: {
default: 1,
// instead of "level" attributes, use "data-level"
parseHTML: (element) => {
const attr = element.getAttribute("data-level")!;
const parsed = parseInt(attr);
if (isFinite(parsed)) {
return parsed;
}
return undefined;
},
renderHTML: (attributes) => {
return {
"data-level": (attributes.level as number).toString(),
};
},
},
};
return propsToAttributes(headingPropSchema);
},

addInputRules() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PropSchema,
createBlockSpecFromStronglyTypedTiptapNode,
createStronglyTypedTiptapNode,
propsToAttributes,
} from "../../../schema/index.js";
import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
import { defaultProps } from "../../defaultProps.js";
Expand All @@ -24,22 +25,9 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
name: "checkListItem",
content: "inline*",
group: "blockContent",

addAttributes() {
return {
checked: {
default: false,
// instead of "checked" attributes, use "data-checked"
parseHTML: (element) =>
element.getAttribute("data-checked") === "true" || undefined,
renderHTML: (attributes) => {
return attributes.checked
? {
"data-checked": (attributes.checked as boolean).toString(),
}
: {};
},
},
};
return propsToAttributes(checkListItemPropSchema);
},

addInputRules() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const NumberedListIndexingPlugin = () => {
node.type.name === "blockContainer" &&
node.firstChild!.type.name === "numberedListItem"
) {
let newIndex = "1";
let newIndex = `${node.firstChild!.attrs["start"] || 1}`;

const blockInfo = getBlockInfo({
posBeforeNode: pos,
Expand Down Expand Up @@ -60,13 +60,21 @@ export const NumberedListIndexingPlugin = () => {

const contentNode = blockInfo.blockContent.node;
const index = contentNode.attrs["index"];
const isFirst =
prevBlock?.firstChild?.type.name !== "numberedListItem";

if (index !== newIndex) {
if (index !== newIndex || (contentNode.attrs.start && !isFirst)) {
modified = true;

const { start, ...attrs } = contentNode.attrs;

tr.setNodeMarkup(blockInfo.blockContent.beforePos, undefined, {
...contentNode.attrs,
...attrs,
index: newIndex,
...(typeof start === "number" &&
isFirst && {
start,
}),
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PropSchema,
createBlockSpecFromStronglyTypedTiptapNode,
createStronglyTypedTiptapNode,
propsToAttributes,
} from "../../../schema/index.js";
import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
import { defaultProps } from "../../defaultProps.js";
Expand All @@ -13,6 +14,7 @@ import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin.js";

export const numberedListItemPropSchema = {
...defaultProps,
start: { default: undefined, type: "number" },
} satisfies PropSchema;

const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
Expand All @@ -22,6 +24,9 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
priority: 90,
addAttributes() {
return {
...propsToAttributes(numberedListItemPropSchema),
// the index attribute is only used internally (it's not part of the blocknote schema)
// that's why it's defined explicitly here, and not part of the prop schema
index: {
default: null,
parseHTML: (element) => element.getAttribute("data-index"),
Expand All @@ -38,15 +43,17 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
return [
// Creates an ordered list when starting with "1.".
new InputRule({
find: new RegExp(`^1\\.\\s$`),
handler: ({ state, chain, range }) => {
find: new RegExp(`^(\\d+)\\.\\s$`),
handler: ({ state, chain, range, match }) => {
const blockInfo = getBlockInfoFromSelection(state);
if (
!blockInfo.isBlockContainer ||
blockInfo.blockContent.node.type.spec.content !== "inline*"
blockInfo.blockContent.node.type.spec.content !== "inline*" ||
blockInfo.blockNoteType === "numberedListItem"
) {
return;
}
const startIndex = parseInt(match[1]);

chain()
.command(
Expand All @@ -55,7 +62,11 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
blockInfo.bnBlock.beforePos,
{
type: "numberedListItem",
props: {},
props:
(startIndex === 1 && {}) ||
({
start: startIndex,
} as any),
}
)
)
Expand Down Expand Up @@ -116,7 +127,16 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
parent.tagName === "OL" ||
(parent.tagName === "DIV" && parent.parentElement!.tagName === "OL")
) {
return {};
const startIndex =
parseInt(parent.getAttribute("start") || "1") || 1;

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

return {
start: startIndex,
};
}

return false;
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/editor/Block.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,13 @@ NESTED BLOCKS
}

/* HEADINGS*/
[data-level="1"] {
[data-content-type="heading"] {
--level: 3em;
}
[data-level="2"] {
[data-content-type="heading"][data-level="2"] {
--level: 2em;
}
[data-level="3"] {
[data-content-type="heading"][data-level="3"] {
--level: 1.3em;
}

Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/editor/BlockNoteEditor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ it("adds id attribute when requested", async () => {
);
editor.replaceBlocks(editor.document, blocks);
expect(await editor.blocksToFullHTML(editor.document)).toMatchInlineSnapshot(
`"<div class="bn-block-group" data-node-type="blockGroup"><div class="bn-block-outer" data-node-type="blockOuter" data-id="1" id="1"><div class="bn-block" data-node-type="blockContainer" data-id="1" id="1"><div class="bn-block-content" data-content-type="paragraph"><p class="bn-inline-content">This is a normal text</p></div></div></div><div class="bn-block-outer" data-node-type="blockOuter" data-id="2" id="2"><div class="bn-block" data-node-type="blockContainer" data-id="2" id="2"><div class="bn-block-content" data-content-type="heading" data-level="1"><h1 class="bn-inline-content">And this is a large heading</h1></div></div></div></div>"`
`"<div class="bn-block-group" data-node-type="blockGroup"><div class="bn-block-outer" data-node-type="blockOuter" data-id="1" id="1"><div class="bn-block" data-node-type="blockContainer" data-id="1" id="1"><div class="bn-block-content" data-content-type="paragraph"><p class="bn-inline-content">This is a normal text</p></div></div></div><div class="bn-block-outer" data-node-type="blockOuter" data-id="2" id="2"><div class="bn-block" data-node-type="blockContainer" data-id="2" id="2"><div class="bn-block-content" data-content-type="heading"><h1 class="bn-inline-content">And this is a large heading</h1></div></div></div></div>"`
);
});

it("block prop types", () => {
// this test checks whether the block props are correctly typed in typescript
const editor = BlockNoteEditor.create();
const block = editor.document[0];
if (block.type === "paragraph") {
// @ts-expect-error
const level = block.props.level; // doesn't have level prop

// eslint-disable-next-line
expect(level).toBe(undefined);
}

if (block.type === "heading") {
const level = block.props.level; // does have level prop

// eslint-disable-next-line
expect(level).toBe(1);
}
});
22 changes: 16 additions & 6 deletions packages/core/src/schema/blocks/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export function propsToAttributes(propSchema: PropSchema): Attributes {
return null;
}

if (typeof spec.default === "boolean") {
if (
(spec.default === undefined && spec.type === "boolean") ||
(spec.default !== undefined && typeof spec.default === "boolean")
) {
if (value === "true") {
return true;
}
Expand All @@ -58,7 +61,10 @@ export function propsToAttributes(propSchema: PropSchema): Attributes {
return null;
}

if (typeof spec.default === "number") {
if (
(spec.default === undefined && spec.type === "number") ||
(spec.default !== undefined && typeof spec.default === "number")
) {
const asNumber = parseFloat(value);
const isNumeric =
!Number.isNaN(asNumber) && Number.isFinite(asNumber);
Expand All @@ -72,12 +78,14 @@ export function propsToAttributes(propSchema: PropSchema): Attributes {

return value;
},
renderHTML: (attributes) =>
attributes[name] !== spec.default
renderHTML: (attributes) => {
// don't render to html if the value is the same as the default
return attributes[name] !== spec.default
? {
[camelToDataKebab(name)]: attributes[name],
}
: {},
: {};
},
};
});

Expand Down Expand Up @@ -173,7 +181,9 @@ export function wrapInBlockStructure<
// which are already added as HTML attributes to the parent `blockContent`
// element (inheritedProps) and props set to their default values.
for (const [prop, value] of Object.entries(blockProps)) {
if (!inheritedProps.includes(prop) && value !== propSchema[prop].default) {
const spec = propSchema[prop];
const defaultValue = spec.default;
if (!inheritedProps.includes(prop) && value !== defaultValue) {
blockContent.setAttribute(camelToDataKebab(prop), value);
}
}
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/schema/inlineContent/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export function addInlineContentAttributes<
// Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props
// set to their default values.
Object.entries(inlineContentProps)
.filter(([prop, value]) => value !== propSchema[prop].default)
.filter(([prop, value]) => {
const spec = propSchema[prop];
return value !== spec.default;
})
.map(([prop, value]) => {
return [camelToDataKebab(prop), value];
})
Expand Down
Loading
Loading