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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 10 additions & 33 deletions packages/core/src/api/exporters/html/internalHTMLSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,10 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
import { addListAccessibilityAttributes } from "./util/addListAccessibilityAttributes.js";
import { addListItemIndexAttributes } from "./util/addListItemIndexAttributes.js";
import { serializeBlocksInternalHTML } from "./util/serializeBlocksInternalHTML.js";

// This is normally handled using decorations in the
// `NumberedListIndexingDecorationPlugin`. This does not run when exporting, so
// we have to add the necessary HTML attributes ourselves.
const addIndexToNumberedListItems = (element: HTMLElement) => {
const numberedListItems = element.querySelectorAll(
'[data-content-type="numberedListItem"]',
);
numberedListItems.forEach((numberedListItem) => {
const prevNumberedListItem = numberedListItem
.closest(".bn-block-outer")
?.previousElementSibling?.querySelector(
'[data-content-type="numberedListItem"]',
);

if (!prevNumberedListItem) {
numberedListItem.setAttribute(
"data-index",
numberedListItem.getAttribute("data-start") || "1",
);
} else {
const prevNumberedListItemIndex =
prevNumberedListItem.getAttribute("data-index");
numberedListItem.setAttribute(
"data-index",
(parseInt(prevNumberedListItemIndex || "0") + 1).toString(),
);
}
});

return element;
};

// Makes the checkboxes in check list items read-only, as the HTML should be
// static and therefore read-only when rendered.
const makeCheckListItemsReadOnly = (element: HTMLElement) => {
Expand Down Expand Up @@ -152,7 +122,14 @@ export const createInternalHTMLSerializer = <
// functionality so that the rendered HTML looks identical to that of a live
// editor.
const transforms: ((element: HTMLElement) => HTMLElement)[] = [
addIndexToNumberedListItems,
addListItemIndexAttributes,
// Static exports need list semantics even when the source editor is editable.
(element) =>
addListAccessibilityAttributes(
element,
(type) =>
!!editor.schema.blockSpecs[type]?.implementation.meta.listItem,
),
makeCheckListItemsReadOnly,
forceToggleBlocksShow,
addTableMinCellWidths,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createListOwner } from "../../../../extensions/ListItem/createListOwner.js";

function getBlockContent(block: Element | null) {
if (!block?.matches('[data-node-type="blockOuter"]')) {
return undefined;
}
const content = block.querySelector<HTMLElement>(".bn-block-content");
// A nested list item must not be mistaken for its parent's content.
return content?.closest('[data-node-type="blockOuter"]') === block
? content
: undefined;
}

/** Mirrors ListItemExtension for static full HTML, where plugins do not run. */
export function addListAccessibilityAttributes(
element: HTMLElement,
isListItem: (type: string) => boolean,
) {
const blocks = element.querySelectorAll('[data-node-type="blockOuter"]');
for (const block of blocks) {
const content = getBlockContent(block);
const type = content?.getAttribute("data-content-type");
if (!type || !isListItem(type)) {
continue;
}
const previousContent = getBlockContent(block.previousElementSibling);
if (previousContent?.getAttribute("data-content-type") === type) {
// This item was already handled with the preceding same-type siblings.
continue;
}

const ids: string[] = [];
let item: Element | null = block;
while (item) {
const itemContent = getBlockContent(item);
if (itemContent?.getAttribute("data-content-type") !== type) {
break;
}
const id = encodeURIComponent(item.getAttribute("data-id")!);
item.setAttribute("id", id);
item.setAttribute("role", "listitem");
ids.push(id);
item = item.nextElementSibling;
}
if (ids.length > 0) {
block.before(createListOwner(element.ownerDocument, ids));
}
}
return element;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Mirrors getListItemIndexDecorations when exporting internal HTML, where
// editor plugins do not run.
export function addListItemIndexAttributes(element: HTMLElement) {
const numberedListItems = element.querySelectorAll(
'[data-content-type="numberedListItem"]',
);
numberedListItems.forEach((numberedListItem) => {
const prevNumberedListItem = numberedListItem
.closest(".bn-block-outer")
?.previousElementSibling?.querySelector(
'[data-content-type="numberedListItem"]',
);

if (!prevNumberedListItem) {
numberedListItem.setAttribute(
"data-index",
numberedListItem.getAttribute("data-start") || "1",
);
} else {
const prevNumberedListItemIndex =
prevNumberedListItem.getAttribute("data-index");
numberedListItem.setAttribute(
"data-index",
(parseInt(prevNumberedListItemIndex || "0") + 1).toString(),
);
}
});

return element;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DOMSerializer, Fragment, Node } from "prosemirror-model";

import { UniqueID } from "../../../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
Expand Down Expand Up @@ -156,7 +157,12 @@ function serializeBlock<
const children = block.children || [];

const impl = editor.blockImplementations[block.type as any].implementation;
const fullBlock = { ...block, props, children } as any;
const fullBlock = {
...block,
id: block.id ?? UniqueID.options.generateID(),
props,
children,
} as any;

if (isLayoutNode(editor.pmSchema.nodes[block.type as any])) {
// A layout block is its frame. `renderFrameDOM` stamps the frame's root with
Expand Down Expand Up @@ -204,7 +210,7 @@ function serializeBlock<
// wrap the block in a blockContainer
const bc = BC_NODE.spec?.toDOM?.(
BC_NODE.create({
id: block.id,
id: fullBlock.id,
...props,
}),
) as {
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/blocks/Heading/a11y/block.a11y.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ test("read-only toggle heading: Tab reaches the toggle and Enter expands/collaps
const content = page.getByText("Nested read-only content", { exact: true });
await expect.element(content).not.toBeVisible();
await expect.element(toggle).toHaveAttribute("aria-expanded", "false");
await checkAriaSnapshot(page.getByRole("textbox"), "collapsed");
await checkAriaSnapshot(
page.getByRole("main").getByRole("document"),
"collapsed",
);

// Start at Before editor and reach the toggle through the page's tab order.
// Do not focus the button directly: that would hide a keyboard trap.
Expand All @@ -57,12 +60,18 @@ test("read-only toggle heading: Tab reaches the toggle and Enter expands/collaps
await pressKey("Enter");
await expect.element(content).toBeVisible();
await expect.element(toggle).toHaveAttribute("aria-expanded", "true");
await checkAriaSnapshot(page.getByRole("textbox"), "expanded");
await checkAriaSnapshot(
page.getByRole("main").getByRole("document"),
"expanded",
);
await expect.element(toggle).toHaveFocus();
await pressKey("Enter");
await expect.element(content).not.toBeVisible();
await expect.element(toggle).toHaveAttribute("aria-expanded", "false");
await checkAriaSnapshot(page.getByRole("textbox"), "collapsed again");
await checkAriaSnapshot(
page.getByRole("main").getByRole("document"),
"collapsed again",
);
await expect.element(toggle).toHaveFocus();
await pressKey("Tab");
await expect
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed 1`] = `
"- textbox:
"- document:
- button "Expand or collapse"
- heading "Details" [level=2]"
`;

exports[`read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed again 1`] = `
"- textbox:
"- document:
- button "Expand or collapse"
- heading "Details" [level=2]"
`;

exports[`read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > expanded 1`] = `
"- textbox:
"- document:
- button "Expand or collapse" [expanded]
- heading "Details" [level=2]
- paragraph: Nested read-only content"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/blocks/ListItem/BulletListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const createBulletListItemBlockSpec = createBlockSpec(
emptyEnterResets: true,
},
meta: {
listItem: true,
isolating: false,
},
parse(element) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/blocks/ListItem/CheckListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const createCheckListItemBlockSpec = createBlockSpec(
emptyEnterResets: true,
},
meta: {
listItem: true,
isolating: false,
},
parse(element) {
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/blocks/ListItem/NumberedListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
parseDefaultProps,
} from "../../defaultProps.js";
import { getListItemContent } from "../getListItemContent.js";
import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js";

export type NumberedListItemBlockConfig = ReturnType<
typeof createNumberedListItemBlockConfig
Expand Down Expand Up @@ -35,6 +34,7 @@ export const createNumberedListItemBlockSpec = createBlockSpec(
emptyEnterResets: true,
},
meta: {
listItem: true,
isolating: false,
},
parse(element) {
Expand Down Expand Up @@ -137,7 +137,6 @@ export const createNumberedListItemBlockSpec = createBlockSpec(
return true;
},
},
prosemirrorPlugins: [NumberedListIndexingDecorationPlugin()],
}),
],
);
1 change: 1 addition & 0 deletions packages/core/src/blocks/ListItem/ToggleListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const createToggleListItemBlockSpec = createBlockSpec(
};
},
meta: {
listItem: true,
isolating: false,
// A block dragged onto the toggle becomes its first child.
dropsIntoChildren: () => true,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/editor/Block.css
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ NESTED BLOCKS

/* Checked */
.bn-block-content[data-content-type="checkListItem"] > div:has(> input) {
/* Keep vertical caret navigation out of the non-editable checkbox wrapper. */
user-select: none;
height: 24px;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,10 @@ export class BlockNoteEditor<
scrollMargin: { top: 72, bottom: 72, left: 0, right: 0 },
...newOptions._tiptapOptions?.editorProps,
attributes: {
// Let ReadOnlyExtension supply the role dynamically instead of
// inheriting TipTap's unconditional textbox role. Explicit consumer
// roles below still take precedence.
role: "",
// As of TipTap v2.5.0 the tabIndex is removed when the editor is not
// editable, so you can't focus it. We want to revert this as we have
// UI behaviour that relies on it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
HistoryExtension,
InlineContentBoundaryEditExtension,
LinkToolbarExtension,
ListItemExtension,
NodeSelectionKeyboardExtension,
PlaceholderExtension,
PositionMappingExtension,
Expand Down Expand Up @@ -174,6 +175,7 @@ export function getDefaultExtensions(
: {}),
}),
LinkToolbarExtension(options),
ListItemExtension(),
NodeSelectionKeyboardExtension(),
PlaceholderExtension(options),
ReadOnlyExtension({ editable: options._tiptapOptions?.editable }),
Expand Down
Loading
Loading