diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 05338f5f97..c4fe9eb93b 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -8,40 +8,9 @@ import { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; +import { addListItemAttributes } from "./util/addListItemAttributes.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) => { @@ -152,7 +121,13 @@ export const createInternalHTMLSerializer = < // functionality so that the rendered HTML looks identical to that of a live // editor. const transforms: ((element: HTMLElement) => HTMLElement)[] = [ - addIndexToNumberedListItems, + // Static exports need list semantics even when the source editor is editable. + (element) => + addListItemAttributes( + element, + (type) => + !!editor.schema.blockSpecs[type]?.implementation.meta.listItem, + ), makeCheckListItemsReadOnly, forceToggleBlocksShow, addTableMinCellWidths, diff --git a/packages/core/src/api/exporters/html/util/addListItemAttributes.ts b/packages/core/src/api/exporters/html/util/addListItemAttributes.ts new file mode 100644 index 0000000000..10dd299d84 --- /dev/null +++ b/packages/core/src/api/exporters/html/util/addListItemAttributes.ts @@ -0,0 +1,58 @@ +import { + createListOwner, + walkListSiblings, +} from "../../../../extensions/ListItem/listItems.js"; + +/** Mirrors ListItemExtension for static full HTML, where plugins do not run. */ +export function addListItemAttributes( + element: HTMLElement, + isListItem: (type: string) => boolean, +) { + // A frame/layout can host siblings outside a blockGroup. Group by the actual + // parent instead of assuming all blocks are direct blockGroup children. + const parents = new Set( + Array.from( + element.querySelectorAll('[data-node-type="blockOuter"]'), + (block) => block.parentElement!, + ), + ); + for (const parent of parents) { + const siblings = Array.from(parent.children, (block) => { + // Do not mistake a nested child's content for this block's content. + const content = block.matches('[data-node-type="blockOuter"]') + ? Array.from( + block.querySelectorAll(".bn-block-content"), + ).find( + (content) => + content.closest('[data-node-type="blockOuter"]') === block, + ) + : undefined; + const type = content?.getAttribute("data-content-type") ?? undefined; + const start = content?.getAttribute("data-start"); + return { + item: { block, content }, + type, + start: start ? Number(start) : undefined, + listItem: !!type && isListItem(type), + }; + }); + walkListSiblings( + siblings, + ({ content }, index) => + content!.setAttribute("data-index", String(index)), + (items) => { + const ids = items.map(({ block }) => { + const id = encodeURIComponent(block.getAttribute("data-id")!); + block.setAttribute("id", id); + block.setAttribute("role", "listitem"); + return id; + }); + parent.insertBefore( + createListOwner(element.ownerDocument, ids), + items[0].block, + ); + }, + ); + } + return element; +} diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 3a5ed1e357..545dbcb71f 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -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 { @@ -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 @@ -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 { diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts index 6c791299fb..f2aa416b2c 100644 --- a/packages/core/src/blocks/ListItem/BulletListItem/block.ts +++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts @@ -33,6 +33,7 @@ export const createBulletListItemBlockSpec = createBlockSpec( emptyEnterResets: true, }, meta: { + listItem: true, isolating: false, }, parse(element) { diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts index 936efc3be0..d1a983dd88 100644 --- a/packages/core/src/blocks/ListItem/CheckListItem/block.ts +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts @@ -33,6 +33,7 @@ export const createCheckListItemBlockSpec = createBlockSpec( emptyEnterResets: true, }, meta: { + listItem: true, isolating: false, }, parse(element) { diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts deleted file mode 100644 index 0222fff46a..0000000000 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { Node } from "@tiptap/pm/model"; -import type { Transaction } from "@tiptap/pm/state"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; - -import { getBlockInfoFromNode } from "../../../api/getBlockInfoFromPos.js"; - -// Loosely based on https://github.com/ueberdosis/tiptap/blob/7ac01ef0b816a535e903b5ca92492bff110a71ae/packages/extension-mathematics/src/MathematicsPlugin.ts (MIT) - -type DecoSpec = { - index: number; - isFirst: boolean; - hasStart: boolean; - side: number; -}; - -type Deco = Omit & { spec: DecoSpec }; - -/** - * Calculate the index for a numbered list item based on its position and previous siblings. - * Iteratively walks backwards to find the start of the contiguous list (or a cached entry), - * then walks forward to populate the cache. This avoids deep recursion that would overflow - * the stack on large documents. - */ -function calculateListItemIndex( - node: Node, - pos: number, - tr: Transaction, - map: Map, -): { index: number; isFirst: boolean; hasStart: boolean } { - const hasStart = !!node.firstChild!.attrs["start"]; - - // Fast path: previous sibling already in cache - const blockInfo = getBlockInfoFromNode(node, pos); - if (!blockInfo.hasContent) { - throw new Error("impossible"); - } - const prevBlock = tr.doc.resolve(blockInfo.block.beforePos).nodeBefore; - const prevBlockIndex = prevBlock ? map.get(prevBlock) : undefined; - if (prevBlockIndex !== undefined) { - const index = prevBlockIndex + 1; - map.set(node, index); - return { index, isFirst: false, hasStart }; - } - - // Walk backwards iteratively to collect the chain of consecutive - // numbered list items until we hit a cached entry, a non-list block, - // or the start of the parent. - const chain: { node: Node; pos: number }[] = [{ node, pos }]; - let curNode = prevBlock; - let curBeforePos = blockInfo.block.beforePos; - - while (curNode) { - const cachedIndex = map.get(curNode); - if (cachedIndex !== undefined) { - // Found a cached predecessor — start counting from here - break; - } - const curInfo = getBlockInfoFromNode( - curNode, - curBeforePos - curNode.nodeSize, - ); - if (curInfo.blockNoteType !== "numberedListItem") { - break; - } - chain.push({ node: curNode, pos: curBeforePos - curNode.nodeSize }); - const nextPrev = tr.doc.resolve(curInfo.block.beforePos).nodeBefore; - curBeforePos = curInfo.block.beforePos; - curNode = nextPrev; - } - - // Walk forward (reverse of the collected chain) to assign indices - // The last element in chain is the furthest predecessor - let index: number; - let isFirst: boolean; - - // Determine starting index from the block just before the chain - const lastInChain = chain[chain.length - 1]; - const lastInfo = getBlockInfoFromNode(lastInChain.node, lastInChain.pos); - if (!lastInfo.hasContent) { - throw new Error("impossible"); - } - const predecessorNode = tr.doc.resolve(lastInfo.block.beforePos).nodeBefore; - const predecessorIndex = predecessorNode - ? map.get(predecessorNode) - : undefined; - - if (predecessorIndex !== undefined) { - index = predecessorIndex; - isFirst = false; - } else { - // Start of a new list - index = (lastInChain.node.firstChild!.attrs["start"] || 1) - 1; - isFirst = true; - } - - // Assign indices from the end of the chain (furthest back) to the front (original node) - for (let i = chain.length - 1; i >= 0; i--) { - const entry = chain[i]; - if (isFirst && i < chain.length - 1) { - // Only the very first item in the list gets isFirst - isFirst = false; - } - index++; - map.set(entry.node, index); - } - - // isFirst is true only for the very first item in a new list: - // chain.length > 1 means we found predecessor list items, so not first. - return { - index, - isFirst: - chain.length === 1 ? isFirst || predecessorIndex === undefined : false, - hasStart, - }; -} - -/** - * Get the decorations for the current state based on the previous state, - * and the transaction that was applied to get to the current state - */ -function getDecorations( - tr: Transaction, - previousPluginState: { decorations: DecorationSet }, -) { - const map = new Map(); - - const nextDecorationSet = previousPluginState.decorations.map( - tr.mapping, - tr.doc, - ); - - // Find the start of the first change to limit traversal scope. - // We only need to check from the change point forward, since earlier - // blocks are unaffected and their mapped decorations remain correct. - // On init (no steps), changedRange() returns null — fall back to a - // full scan so initial content gets indexed. - const range = tr.changedRange() ?? { from: 0, to: tr.doc.nodeSize - 2 }; - const decorationsToAdd = [] as Deco[]; - - // Track blockGroups where we've verified a decoration match past the - // changed range. Within a single blockGroup, indices are sequential — - // if one matches, all subsequent siblings must too. But sibling items - // in *other* blockGroups (e.g. nested lists) are independent. - const completedGroups = new Set(); - - tr.doc.nodesBetween(range.from, tr.doc.nodeSize - 2, (node, pos, parent) => { - if (parent && completedGroups.has(parent)) { - return false; - } - - if ( - node.type.name === "blockContainer" && - node.firstChild!.type.name === "numberedListItem" - ) { - const { index, isFirst, hasStart } = calculateListItemIndex( - node, - pos, - tr, - map, - ); - - // Search only the numberedListItem node range, not the full - // blockContainer (which includes nested blockGroups whose - // decorations could falsely match). - const blockNode = tr.doc.nodeAt(pos + 1)!; - const existingDecorations = nextDecorationSet.find( - pos + 1, - pos + 1 + blockNode.nodeSize, - (deco: DecoSpec) => - deco.index === index && - deco.isFirst === isFirst && - deco.hasStart === hasStart, - ); - - if (existingDecorations.length === 0) { - decorationsToAdd.push( - Decoration.node( - pos + 1, - pos + 1 + blockNode.nodeSize, - { "data-index": index.toString() }, - { index, isFirst, hasStart }, - ) as Deco, - ); - } else if (pos >= range.to && parent) { - // Past the changed range and decoration matches in this blockGroup: - // all subsequent siblings must also match. Mark group as done and - // skip this node's children (nested lists are unaffected too). - completedGroups.add(parent); - return false; - } - } - return undefined; - }); - - // Remove any decorations that exist at the same position, they will be replaced by the new decorations - const decorationsToRemove = decorationsToAdd.flatMap((deco) => - nextDecorationSet.find(deco.from, deco.to), - ); - - return { - decorations: nextDecorationSet - // Remove existing decorations that are going to be replaced - .remove(decorationsToRemove) - // Add any new decorations - .add(tr.doc, decorationsToAdd), - }; -} - -/** - * This plugin adds decorations to numbered list items to display their index. - */ -export const NumberedListIndexingDecorationPlugin = () => { - return new Plugin<{ decorations: DecorationSet }>({ - key: new PluginKey("numbered-list-indexing-decorations"), - - state: { - init(_config, state) { - // We create an empty transaction to get the decorations for the initial state based on the initial content - return getDecorations(state.tr, { - decorations: DecorationSet.empty, - }); - }, - apply(tr, previousPluginState) { - if (!tr.docChanged && previousPluginState.decorations) { - // Selection-only changes don't affect list indices, just reuse existing decorations - return previousPluginState; - } - return getDecorations(tr, previousPluginState); - }, - }, - - props: { - decorations(state) { - return this.getState(state)?.decorations ?? DecorationSet.empty; - }, - }, - }); -}; diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index 57530d741b..dc1a1f0f7a 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -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 @@ -35,6 +34,7 @@ export const createNumberedListItemBlockSpec = createBlockSpec( emptyEnterResets: true, }, meta: { + listItem: true, isolating: false, }, parse(element) { @@ -137,7 +137,6 @@ export const createNumberedListItemBlockSpec = createBlockSpec( return true; }, }, - prosemirrorPlugins: [NumberedListIndexingDecorationPlugin()], }), ], ); diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index b516cf9d9e..af1a9c44da 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -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, diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ddda8786f3..0db91668e0 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -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. diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index e7f95358d2..4795071207 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -17,6 +17,7 @@ import { HistoryExtension, InlineContentBoundaryEditExtension, LinkToolbarExtension, + ListItemExtension, NodeSelectionKeyboardExtension, PlaceholderExtension, PositionMappingExtension, @@ -174,6 +175,7 @@ export function getDefaultExtensions( : {}), }), LinkToolbarExtension(options), + ListItemExtension(), NodeSelectionKeyboardExtension(), PlaceholderExtension(options), ReadOnlyExtension({ editable: options._tiptapOptions?.editable }), diff --git a/packages/core/src/extensions/ListItem/ListItem.browser.test.ts b/packages/core/src/extensions/ListItem/ListItem.browser.test.ts new file mode 100644 index 0000000000..49912aba89 --- /dev/null +++ b/packages/core/src/extensions/ListItem/ListItem.browser.test.ts @@ -0,0 +1,304 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../schema/blocks/createSpec.js"; + +const customItem = createBlockSpec( + { type: "customItem", propSchema: {}, content: "inline" }, + { + meta: { listItem: true }, + render() { + const dom = document.createElement("p"); + return { dom, contentDOM: dom }; + }, + }, +)(); +const schema = BlockNoteSchema.create().extend({ blockSpecs: { customItem } }); +const cleanups: (() => void)[] = []; + +function createEditor(editable = false) { + const editor = BlockNoteEditor.create({ + schema, + _tiptapOptions: { editable }, + }); + const host = document.createElement("div"); + document.body.append(host); + editor.mount(host); + cleanups.push(() => { + editor.unmount(); + host.remove(); + }); + return { editor, host }; +} + +function lists(host: HTMLElement) { + return Array.from(host.querySelectorAll('[role="list"]')).map((owner) => { + const ids = owner.getAttribute("aria-owns")!.split(" "); + return ids.map((id) => { + const item = document.getElementById(id); + expect(item).not.toBeNull(); + expect(host.contains(item)).toBe(true); + expect(item!.getAttribute("role")).toBe("listitem"); + expect(item!.classList.contains("bn-block-outer")).toBe(true); + return item!.getAttribute("data-id"); + }); + }); +} + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); +}); + +describe("list items", () => { + it("exports numbering and ownership from an editable editor and round trips", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { + id: "a", + type: "numberedListItem", + props: { start: 4 }, + children: [ + { id: "note", type: "paragraph", content: "Explanation" }, + { id: "nested-a", type: "numberedListItem", props: { start: 8 } }, + { id: "nested-b", type: "numberedListItem" }, + ], + }, + { id: "b", type: "numberedListItem" }, + { + id: "break", + type: "paragraph", + children: [ + { id: "inside", type: "numberedListItem", props: { start: 20 } }, + ], + }, + { id: "restart", type: "numberedListItem" }, + { id: "bullet", type: "bulletListItem", content: "Bullet" }, + { id: "custom-a", type: "customItem", content: "Custom one" }, + { id: "custom-b", type: "customItem", content: "Custom two" }, + ]); + const original = editor.document; + const html = editor.blocksToFullHTML(original); + expect(host.querySelector("[aria-owns]")).toBeNull(); + const exported = document.createElement("div"); + document.body.append(exported); + cleanups.push(() => exported.remove()); + exported.innerHTML = html; + expect(lists(exported)).toEqual([ + ["a", "b"], + ["nested-a", "nested-b"], + ["inside"], + ["restart"], + ["bullet"], + ["custom-a", "custom-b"], + ]); + expect( + Array.from(exported.querySelectorAll("[data-index]"), (element) => + element.getAttribute("data-index"), + ), + ).toEqual(["4", "8", "9", "5", "20", "1"]); + expect(editor.tryParseHTMLToBlocks(html)).toEqual(original); + expect(editor.document).toEqual(original); + + exported.remove(); + editor.isEditable = false; + expect(lists(host)).toEqual([ + ["a", "b"], + ["nested-a", "nested-b"], + ["inside"], + ["restart"], + ["bullet"], + ["custom-a", "custom-b"], + ]); + expect(host.querySelector('[role="listitem"]')!.id).toBe("a"); + }); + + it("exports partial list blocks without IDs and preserves empty items", () => { + const { editor, host } = createEditor(true); + const html = editor.blocksToFullHTML([ + { type: "bulletListItem" }, + { type: "bulletListItem" }, + ]); + // The export assigns block IDs and reuses them for list ownership. + const exported = document.createElement("div"); + host.after(exported); + cleanups.push(() => exported.remove()); + exported.innerHTML = html; + expect(exported.querySelectorAll("[role=list]")).toHaveLength(1); + const ids = exported + .querySelector("[aria-owns]")! + .getAttribute("aria-owns")! + .split(" "); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + ids.forEach((id) => + expect(exported.contains(document.getElementById(id))).toBe(true), + ); + const blocks = editor.tryParseHTMLToBlocks(html); + expect(blocks.map((block) => block.type)).toEqual([ + "bulletListItem", + "bulletListItem", + ]); + expect(blocks.map((block) => block.content)).toEqual([[], []]); + expect(blocks.map((block) => encodeURIComponent(block.id))).toEqual(ids); + }); + + it("exposes read-only content as a document and restores input semantics", () => { + const { editor, host } = createEditor(true); + const root = host; + const readOnly = editor.getExtension(ReadOnlyExtension)!; + expect(root.getAttribute("role")).toBe("textbox"); + expect(root.getAttribute("aria-autocomplete")).toBe("list"); + editor.isEditable = false; + expect(root.getAttribute("role")).toBe("document"); + expect(root.hasAttribute("aria-autocomplete")).toBe(false); + expect(root.hasAttribute("aria-haspopup")).toBe(false); + readOnly.setReadOnly(true, "preview"); + editor.isEditable = true; + expect(root.getAttribute("role")).toBe("document"); + readOnly.setReadOnly(false, "preview"); + expect(root.getAttribute("role")).toBe("textbox"); + expect(root.getAttribute("aria-haspopup")).toBe("listbox"); + }); + + it("only exposes list ownership while read-only, including after edits", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { id: "a", type: "bulletListItem" }, + { id: "b", type: "bulletListItem" }, + ]); + expect(host.querySelector('[aria-owns], [role="listitem"]')).toBeNull(); + editor.isEditable = false; + expect(lists(host)).toEqual([["a", "b"]]); + editor.isEditable = true; + expect(host.querySelector('[aria-owns], [role="listitem"]')).toBeNull(); + expect(host.querySelector('[data-id="a"]')!.hasAttribute("id")).toBe(false); + editor.updateBlock("b", { type: "numberedListItem" }); + const readOnly = editor.getExtension(ReadOnlyExtension)!; + readOnly.setReadOnly(true, "preview"); + expect(lists(host)).toEqual([["a"], ["b"]]); + readOnly.setReadOnly(false, "preview"); + expect(host.querySelector('[aria-owns], [role="listitem"]')).toBeNull(); + }); + + it("keeps numbering in both modes while list boundaries and starts change", () => { + const { editor, host } = createEditor(true); + function indices() { + return Array.from(host.querySelectorAll("[data-index]"), (item) => + Number(item.getAttribute("data-index")), + ); + } + editor.replaceBlocks(editor.document, [ + { + id: "a", + type: "numberedListItem", + props: { start: 4 }, + children: [ + { type: "numberedListItem", props: { start: 8 } }, + { type: "numberedListItem" }, + ], + }, + { id: "b", type: "numberedListItem", props: { start: 20 } }, + { id: "c", type: "numberedListItem" }, + ]); + // A start on a later item only takes effect when it begins a new run. + expect(indices()).toEqual([4, 8, 9, 5, 6]); + editor.isEditable = false; + expect(indices()).toEqual([4, 8, 9, 5, 6]); + expect(lists(host)[0]).toEqual(["a", "b", "c"]); + editor.updateBlock("a", { type: "paragraph" }); + expect(indices()).toEqual([8, 9, 20, 21]); + expect(lists(host)).toHaveLength(2); + editor.isEditable = true; + expect(indices()).toEqual([8, 9, 20, 21]); + expect(host.querySelector('[aria-owns], [role="listitem"]')).toBeNull(); + editor.updateBlock("b", { props: { start: 3 } }); + expect(indices()).toEqual([8, 9, 3, 4]); + editor.isEditable = false; + expect(indices()).toEqual([8, 9, 3, 4]); + }); + + it("groups same-type siblings and supports custom list items", () => { + const { editor, host } = createEditor(); + editor.replaceBlocks(editor.document, [ + { id: "a", type: "bulletListItem" }, + { id: "b", type: "bulletListItem" }, + { id: "c", type: "numberedListItem" }, + { type: "paragraph" }, + { id: "d", type: "numberedListItem" }, + { id: "e", type: "checkListItem" }, + { id: "f", type: "toggleListItem" }, + { id: "g", type: "customItem" }, + { id: "h", type: "customItem" }, + ]); + expect(lists(host)).toEqual([ + ["a", "b"], + ["c"], + ["d"], + ["e"], + ["f"], + ["g", "h"], + ]); + const owner = host.querySelector('[role="list"]')!; + expect(owner.nextElementSibling?.getAttribute("data-id")).toBe("a"); + expect(getComputedStyle(owner).display).not.toBe("none"); + expect(getComputedStyle(owner).visibility).toBe("visible"); + expect(owner.hasAttribute("aria-hidden")).toBe(false); + }); + + it("keeps non-list children and nested lists inside their parent item", () => { + const { editor, host } = createEditor(); + editor.replaceBlocks(editor.document, [ + { + id: "parent", + type: "bulletListItem", + children: [ + { id: "explanation", type: "paragraph", content: "Explanation" }, + { id: "nested", type: "numberedListItem" }, + ], + }, + { id: "sibling", type: "bulletListItem" }, + ]); + expect(lists(host)).toEqual([["parent", "sibling"], ["nested"]]); + const parent = host.querySelector('[role="listitem"][data-id="parent"]')!; + expect(parent.querySelector('[data-id="explanation"]')).not.toBeNull(); + expect(parent.querySelector('[role="list"]')).not.toBeNull(); + expect( + host.querySelector('[data-id="explanation"]')!.hasAttribute("role"), + ).toBe(false); + }); + + it("splits, merges, and removes lists when blocks change", () => { + const { editor, host } = createEditor(); + editor.replaceBlocks(editor.document, [ + { id: "a", type: "bulletListItem" }, + { id: "b", type: "bulletListItem" }, + { id: "c", type: "bulletListItem" }, + ]); + const originalId = host.querySelector('[role="listitem"][data-id="a"]')!.id; + editor.updateBlock("b", { type: "paragraph" }); + expect(lists(host)).toEqual([["a"], ["c"]]); + expect(host.querySelector('[data-id="b"]')!.hasAttribute("role")).toBe( + false, + ); + editor.updateBlock("b", { type: "bulletListItem" }); + expect(lists(host)).toEqual([["a", "b", "c"]]); + expect(host.querySelector('[role="listitem"][data-id="a"]')!.id).toBe( + originalId, + ); + editor.removeBlocks(["a", "b", "c"]); + expect(lists(host)).toEqual([]); + expect(host.querySelector('[role="listitem"]')).toBeNull(); + }); + + it("uses block IDs for ownership and escapes whitespace", () => { + const { editor, host } = createEditor(); + editor.replaceBlocks(editor.document, [ + { id: "same id", type: "bulletListItem" }, + ]); + expect(lists(host)).toEqual([["same id"]]); + expect(editor.document[0].id).toBe("same id"); + expect(host.querySelector('[role="listitem"]')!.id).toBe("same%20id"); + }); +}); diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts b/packages/core/src/extensions/ListItem/ListItem.test.ts similarity index 94% rename from packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts rename to packages/core/src/extensions/ListItem/ListItem.test.ts index f3d24db68b..659579613d 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts +++ b/packages/core/src/extensions/ListItem/ListItem.test.ts @@ -1,13 +1,13 @@ import { Selection } from "prosemirror-state"; import { afterEach, describe, expect, it } from "vite-plus/test"; -import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; /** * @vitest-environment jsdom */ -const PLUGIN_KEY = "numbered-list-indexing-decorations$"; +import { listItemPluginKey } from "./ListItem.js"; // Track editors created in each test so we can unmount them in afterEach — // otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that @@ -30,11 +30,7 @@ function createEditor() { function getDecorationSet(editor: BlockNoteEditor) { const view = editor._tiptapEditor.view; - const plugin = view.state.plugins.find((p) => (p as any).key === PLUGIN_KEY); - if (!plugin) { - throw new Error("IndexingPlugin not found"); - } - return plugin.getState(view.state)!.decorations; + return listItemPluginKey.getState(view.state)!.decorations; } /** Returns all decoration specs in document order. */ @@ -71,7 +67,7 @@ function setBlocks( ); } -describe("IndexingPlugin: basic numbering", () => { +describe("ListItemExtension: basic numbering", () => { it("assigns sequential indices to a contiguous numbered list", () => { const editor = createEditor(); setBlocks(editor, [ @@ -118,7 +114,7 @@ describe("IndexingPlugin: basic numbering", () => { }); }); -describe("IndexingPlugin: updates on structural changes", () => { +describe("ListItemExtension: updates on structural changes", () => { it("updates indices when a block is deleted from the middle", () => { const editor = createEditor(); setBlocks(editor, [ @@ -233,7 +229,7 @@ describe("IndexingPlugin: updates on structural changes", () => { }); }); -describe("IndexingPlugin: typing preserves indices (early exit)", () => { +describe("ListItemExtension: typing preserves indices", () => { it("indices unchanged after typing in the first block", () => { const editor = createEditor(); setBlocks(editor, [ @@ -295,7 +291,7 @@ describe("IndexingPlugin: typing preserves indices (early exit)", () => { }); }); -describe("IndexingPlugin: decoration specs", () => { +describe("ListItemExtension: decoration specs", () => { it("decorations have correct spec with index, isFirst, hasStart", () => { const editor = createEditor(); setBlocks(editor, [ @@ -328,7 +324,7 @@ describe("IndexingPlugin: decoration specs", () => { }); }); -describe("IndexingPlugin: selection-only transactions", () => { +describe("ListItemExtension: selection-only transactions", () => { it("does not recompute decorations on selection change", () => { const editor = createEditor(); setBlocks(editor, [ diff --git a/packages/core/src/extensions/ListItem/ListItem.ts b/packages/core/src/extensions/ListItem/ListItem.ts new file mode 100644 index 0000000000..e1a0b1daa0 --- /dev/null +++ b/packages/core/src/extensions/ListItem/ListItem.ts @@ -0,0 +1,124 @@ +import type { Node } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +import { + createListOwner, + walkListSiblings, + type ListSibling, +} from "./listItems.js"; + +type ListDecorations = { + decorations: DecorationSet; + readOnlyDecorations: DecorationSet; +}; + +export const listItemPluginKey = new PluginKey( + "list-item-decorations", +); + +/** Numbers list items and supplies list ownership in read-only mode. */ +export const ListItemExtension = createExtension(({ editor }) => { + function decorate(doc: Node): ListDecorations { + const decorations: Decoration[] = []; + const accessibilityDecorations: Decoration[] = []; + + function visit(parent: Node, start: number) { + type Item = { node: Node; pos: number }; + function* siblings(): Iterable> { + let pos = start; + for (let i = 0; i < parent.childCount; i++) { + const node = parent.child(i); + const type = + node.type.name === "blockContainer" + ? node.firstChild?.type.name + : undefined; + yield { + item: { node, pos }, + type, + start: node.firstChild?.attrs.start, + listItem: !!( + type && + editor.schema.blockSpecs[type]?.implementation.meta.listItem + ), + }; + if (!node.isLeaf && !node.isTextblock) { + visit(node, pos + 1); + } + pos += node.nodeSize; + } + } + + walkListSiblings( + siblings(), + ({ node, pos }, index, isFirst, hasStart) => { + decorations.push( + Decoration.node( + pos + 1, + pos + 1 + node.firstChild!.nodeSize, + { "data-index": String(index) }, + { index, isFirst, hasStart }, + ), + ); + }, + (items) => { + const ids = items.map(({ node, pos }) => { + // aria-owns is space-separated, so escape caller-supplied IDs. + const id = + typeof node.attrs.id === "string" + ? encodeURIComponent(node.attrs.id) + : `bn-list-item-pos-${pos}`; + // Include children inside their parent list item's semantics. + accessibilityDecorations.push( + Decoration.node(pos, pos + node.nodeSize, { + id, + role: "listitem", + }), + ); + return id; + }); + accessibilityDecorations.push( + Decoration.widget( + items[0].pos, + (view) => createListOwner(view.dom.ownerDocument, ids), + { side: -1, key: ids.join(" "), ignoreSelection: true }, + ), + ); + }, + ); + } + + visit(doc, 0); + const indices = DecorationSet.create(doc, decorations); + return { + decorations: indices, + readOnlyDecorations: indices.add(doc, accessibilityDecorations), + }; + } + + return { + key: "listItem", + prosemirrorPlugins: [ + new Plugin({ + key: listItemPluginKey, + state: { + init: (_, state) => decorate(state.doc), + apply: (tr, previous) => + tr.docChanged ? decorate(tr.doc) : previous, + }, + props: { + // ReadOnlyExtension dispatches when editability changes. Keep + // numbering when editing resumes, but remove owners and item roles. + decorations(state) { + const decorations = listItemPluginKey.getState(state); + return editor.isEditable + ? decorations?.decorations + : decorations?.readOnlyDecorations; + }, + }, + }), + ], + }; +}); diff --git a/packages/core/src/extensions/ListItem/listItems.ts b/packages/core/src/extensions/ListItem/listItems.ts new file mode 100644 index 0000000000..2c8bc39d73 --- /dev/null +++ b/packages/core/src/extensions/ListItem/listItems.ts @@ -0,0 +1,67 @@ +/** One sibling in either a ProseMirror document or serialized block DOM. */ +export type ListSibling = { + item: T; + type: string | undefined; + start: unknown; + listItem: boolean; +}; + +/** Shared numbering and same-type list boundaries for views and full HTML. */ +export function walkListSiblings( + siblings: Iterable>, + onIndex: ( + item: T, + index: number, + isFirst: boolean, + hasStart: boolean, + ) => void, + onList: (items: T[]) => void, +) { + let previousIndex: number | undefined; + let run: { type: string; items: T[] } | undefined; + + function finishRun() { + if (run) { + onList(run.items); + run = undefined; + } + } + + for (const { item, type, start, listItem } of siblings) { + if (type === "numberedListItem") { + const isFirst = previousIndex === undefined; + const index: number = + previousIndex === undefined + ? typeof start === "number" && start + ? start + : 1 + : previousIndex + 1; + previousIndex = index; + onIndex(item, index, isFirst, !!start); + } else { + previousIndex = undefined; + } + + if (type && listItem) { + if (run && run.type !== type) { + finishRun(); + } + (run ??= { type, items: [] }).items.push(item); + } else { + finishRun(); + } + } + finishRun(); +} + +/** A visually hidden owner whose items remain in their original DOM. */ +export function createListOwner(doc: Document, ids: string[]) { + const owner = doc.createElement("div"); + owner.setAttribute("data-bn-list-owner", ""); + owner.setAttribute("role", "list"); + owner.setAttribute("aria-owns", ids.join(" ")); + // Keep it in the accessibility tree without affecting layout. + owner.style.cssText = + "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0"; + return owner; +} diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.ts index a15876bbb4..538dd8af73 100644 --- a/packages/core/src/extensions/ReadOnly/ReadOnly.ts +++ b/packages/core/src/extensions/ReadOnly/ReadOnly.ts @@ -42,6 +42,14 @@ export const ReadOnlyExtension = createExtension( new Plugin({ key: PLUGIN_KEY, props: { + attributes: (): Record => + store.state.isEditable && store.state.enabledSet.size === 0 + ? { + role: "textbox", + "aria-autocomplete": "list", + "aria-haspopup": "listbox", + } + : { role: "document" }, editable: () => store.state.isEditable && store.state.enabledSet.size === 0, }, diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index c9ed09e7b4..826329b3b0 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -5,6 +5,7 @@ export * from "./FormattingToolbar/FormattingToolbar.js"; export * from "./History/History.js"; export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js"; export * from "./LinkToolbar/LinkToolbar.js"; +export * from "./ListItem/ListItem.js"; export * from "./LinkToolbar/protocols.js"; export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; export * from "./Placeholder/Placeholder.js"; diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts index bba1d84f4d..b473bc78a7 100644 --- a/packages/core/src/pm-nodes/BlockGroup.ts +++ b/packages/core/src/pm-nodes/BlockGroup.ts @@ -15,6 +15,8 @@ export const BlockGroup = Node.create<{ }, parseHTML() { return [ + // Exported accessibility owners are chrome, not document blocks. + { tag: "div[data-bn-list-owner]", ignore: true }, { tag: "div", getAttrs: (element) => { diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 5af9f0eb23..4dd004470c 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -99,6 +99,14 @@ export interface BlockConfigMeta< * block's source is hidden behind its preview and edited via the popup. */ hasPreview?: boolean; + + /** + * Exposes this block as an accessible list item in read-only mode. Siblings of the + * same block type form a list. Does not change markers, keyboard behavior, + * or HTML export. + * @default false + */ + listItem?: boolean; } /** diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 89cc465424..e5f497b7ae 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -400,12 +400,6 @@ const ContentEditableElement = (props: { }) => { const { autoFocus, mount, contentEditableProps } = props; return ( -
+
); }; diff --git a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png index 502575500f..6c450d04cb 100644 Binary files a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png and b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png differ diff --git a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png index c15e55638f..fcea00e0d7 100644 Binary files a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png and b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png differ diff --git a/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap index b14833b3f9..f2a7925461 100644 --- a/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap +++ b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"

Heading 2

Paragraph

list item

Example
Caption

Example

Caption
"`; +exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"

Heading 2

Paragraph

list item

Example
Caption

Example

Caption
"`; exports[`Test ServerBlockNoteEditor > converts to and from HTML (blocksToHTMLLossy) 1`] = `"

Heading 2

Paragraph

  • list item

Example
Caption
Example

Caption

"`; diff --git a/tests/a11y/commands.ts b/tests/a11y/commands.ts index f18d57ef10..b9f6c468a1 100644 --- a/tests/a11y/commands.ts +++ b/tests/a11y/commands.ts @@ -49,3 +49,9 @@ export const readTranscript: BrowserCommand<[]> = async () => (await reader()).spokenPhraseLog(); export const stopReader: BrowserCommand<[]> = async () => (await reader()).stop(); + +export const nextReader: BrowserCommand<[]> = async () => { + const screenReader = await reader(); + await screenReader.next(); + return screenReader.lastSpokenPhrase(); +}; diff --git a/tests/a11y/documents.ts b/tests/a11y/documents.ts index 186e5b0284..67d7f54537 100644 --- a/tests/a11y/documents.ts +++ b/tests/a11y/documents.ts @@ -100,7 +100,57 @@ export const blockExamples = { export const defaultSchemaDocument: PartialBlock[] = [ { id: "start", type: "paragraph", content: "Start of document" }, - ...Object.entries(blockExamples).map(([id, block]) => ({ ...block, id })), + ...Object.entries(blockExamples).flatMap(([id, block]): PartialBlock[] => { + const first = { ...block, id }; + // Adjacent items exercise list ownership/counts rather than only isolated + // one-item lists. Switching types also exercises list boundaries. + switch (block.type) { + case "bulletListItem": + return [ + { + ...first, + children: [ + { + type: "paragraph", + content: "Explanation inside the list item", + }, + { type: "bulletListItem", content: "First nested bullet" }, + { type: "bulletListItem", content: "Second nested bullet" }, + ], + }, + { + id: `${id}-second`, + type: block.type, + content: "Second bullet list item", + }, + ]; + case "numberedListItem": + return [ + first, + { + id: `${id}-second`, + type: block.type, + content: "Second numbered list item", + }, + ]; + case "checkListItem": + return [ + first, + { id: `${id}-second`, type: block.type, content: "Second task" }, + ]; + case "toggleListItem": + return [ + first, + { + id: `${id}-second`, + type: block.type, + content: "Second toggle list item", + }, + ]; + default: + return [first]; + } + }), ...[2, 3, 4, 5, 6].map((level) => ({ id: `heading-${level}`, type: "heading" as const, diff --git a/tests/a11y/helpers.tsx b/tests/a11y/helpers.tsx index 18ec817990..a1096de38c 100644 --- a/tests/a11y/helpers.tsx +++ b/tests/a11y/helpers.tsx @@ -4,6 +4,7 @@ import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; import { render } from "vitest-browser-react"; +export { cleanup } from "vitest-browser-react"; import axe from "axe-core"; import { expect, type TestContext } from "vite-plus/test"; import { commands, page, server } from "vite-plus/test/browser"; @@ -18,6 +19,7 @@ export const selectToLineEndKey = // typed RPC boundary here, as the existing browser test utilities do. const browserCommands = commands as typeof commands & { startReader(): Promise; + nextReader(): Promise; pressKey(key: string): Promise; readTranscript(): Promise; stopReader(): Promise; @@ -58,9 +60,11 @@ export async function openEditor( theme={theme} />, ); - await expect.element(page.getByRole("textbox")).toBeVisible(); await expect - .element(page.getByRole("textbox")) + .element(page.getByRole(editable ? "textbox" : "document")) + .toBeVisible(); + await expect + .element(page.getByRole(editable ? "textbox" : "document")) .toHaveAttribute("contenteditable", String(editable)); await document.fonts.ready; if (screenReader) { @@ -108,6 +112,10 @@ export function readSelection() { }; } +export async function nextReader() { + return browserCommands.nextReader(); +} + export async function pressKey(key: string) { return browserCommands.pressKey(key); } diff --git a/tests/a11y/vite.config.ts b/tests/a11y/vite.config.ts index fd0e8eb02c..be1706e05a 100644 --- a/tests/a11y/vite.config.ts +++ b/tests/a11y/vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { defineConfig } from "vite-plus"; import { playwright } from "vite-plus/test/browser/providers/playwright"; import { + nextReader, startReader, pressKey, readTranscript, @@ -78,7 +79,13 @@ export default defineConfig({ name: reader ?? "a11y-browser", }, ], - commands: { startReader, pressKey, readTranscript, stopReader }, + commands: { + nextReader, + startReader, + pressKey, + readTranscript, + stopReader, + }, expect: { toMatchScreenshot: { comparatorName: "pixelmatch", diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html index b87505e81f..8f5e7630d2 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html @@ -40,14 +40,32 @@

Section 1

-
+
+

First point

-
+

Second point

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html index b9dfbb8404..9143e36f92 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html @@ -41,7 +41,19 @@

-
+
+

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html index 521c42d103..1d38990199 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html @@ -1,19 +1,49 @@

-
+
+

Bullet List Item 1

-
+

Bullet List Item 2

-
+
+
-
+
-
+
+
@@ -45,7 +93,13 @@
-
+
-
+
+