Files
Tom Moor 5ea63aa1a2 fix: Editor math block parsing and NaN media dimensions (#12668)
* fix: Block math not closed by trailing $$ on a content line

The closing delimiter check compared a 3-character slice against the
2-character "$$" delimiter, so block math closed on the same line as
content (e.g. "c = d$$") was never detected and the block swallowed the
rest of the document. Use the delimiter length rather than a hardcoded
slice. Also fix the indexOf sentinel comparison (!== 1 instead of
!== -1) in inline math parsing, which terminated correctly only by
coincidence.

Adds tests for the math markdown rules and moves the findNodes test
helper into shared/test/editor for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: NaN width and height parsed for video and image nodes

Video parseDOM and parseMarkdown used parseInt on a missing attribute,
storing NaN instead of null and persisting it to markdown as NaNxNaN.
Image size syntax with a missing dimension (e.g. "=x100") hit the same
issue through optional regex groups. Parse dimensions only when
present, matching the existing guard in Image parseDOM, and correct the
video getAttrs element type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Normalize non-numeric video dimensions, avoid serializing nullxnull

Review feedback: parseInt could still produce NaN when the attribute
exists but is not numeric (e.g. width="auto"), and toMarkdown wrote
null dimensions as "nullxnull". Parse dimensions through a helper that
normalizes non-finite values to null, and serialize nullish dimensions
as empty strings, which still round-trips as a video node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:29:29 -04:00

273 lines
6.5 KiB
TypeScript

import { Schema } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
import type { Plugin } from "prosemirror-state";
import ExtensionManager from "../editor/lib/ExtensionManager";
import { richExtensions } from "../editor/nodes";
/**
* Extension manager using the full rich extensions from the editor.
*/
export const extensionManager = new ExtensionManager(richExtensions);
/**
* Schema using the actual rich extensions from the editor.
* This should be used for testing to ensure we're testing against real node definitions.
*/
export const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
/**
* Creates an editor state with the given document and plugins.
*
* @param doc - the document node.
* @param plugins - optional array of plugins to include.
* @returns editor state.
*/
export function createEditorState(
doc: ReturnType<typeof schema.node>,
plugins: Plugin[] = []
) {
return EditorState.create({
doc,
schema,
plugins,
});
}
/**
* Creates an editor state with the selection set inside a specific position.
* Useful for tests that require the selection to be inside a table or other node.
*
* @param doc - the document node.
* @param pos - position to set selection near.
* @param plugins - optional array of plugins to include.
* @returns editor state with selection at the specified position.
*/
export function createEditorStateWithSelection(
doc: ReturnType<typeof schema.node>,
pos: number,
plugins: Plugin[] = []
) {
const state = createEditorState(doc, plugins);
const $pos = state.doc.resolve(pos);
const selection = TextSelection.near($pos);
return state.apply(state.tr.setSelection(selection));
}
/**
* Creates a paragraph node with optional text content.
*
* @param text - the text content (empty string for empty paragraph).
* @returns paragraph node.
*/
export function p(text: string) {
return schema.nodes.paragraph.create(
null,
text ? schema.text(text) : undefined
);
}
/**
* Creates a table cell (td) node.
*
* @param content - the cell content text.
* @param attrs - optional cell attributes (colspan, rowspan, colwidth, alignment).
* @returns td node.
*/
export function td(
content: string,
attrs?: {
colspan?: number;
rowspan?: number;
colwidth?: number[] | null;
alignment?: string | null;
}
) {
return schema.nodes.td.create(attrs ?? null, p(content));
}
/**
* Creates a table header cell (th) node.
*
* @param content - the cell content text.
* @param attrs - optional cell attributes (colspan, rowspan, colwidth, alignment).
* @returns th node.
*/
export function th(
content: string,
attrs?: {
colspan?: number;
rowspan?: number;
colwidth?: number[] | null;
alignment?: string | null;
}
) {
return schema.nodes.th.create(attrs ?? null, p(content));
}
/**
* Creates a table row (tr) node.
*
* @param cells - array of cell nodes (td or th).
* @returns tr node.
*/
export function tr(cells: ReturnType<typeof td | typeof th>[]) {
return schema.nodes.tr.create(null, cells);
}
/**
* Creates a table node.
*
* @param rows - array of row nodes.
* @param attrs - optional table attributes.
* @returns table node.
*/
export function table(
rows: ReturnType<typeof tr>[],
attrs?: { layout?: string | null }
) {
return schema.nodes.table.create(attrs ?? null, rows);
}
/**
* Creates a heading node.
*
* @param text - the heading text.
* @param level - the heading level (1-6).
* @returns heading node.
*/
export function heading(text: string, level: 1 | 2 | 3 | 4 | 5 | 6 = 1) {
return schema.nodes.heading.create(
{ level },
text ? schema.text(text) : undefined
);
}
/**
* Creates a blockquote node.
*
* @param content - array of block nodes or a single paragraph text.
* @returns blockquote node.
*/
export function blockquote(
content: string | ReturnType<typeof p | typeof heading>[]
) {
const children = typeof content === "string" ? [p(content)] : content;
return schema.nodes.blockquote.create(null, children);
}
/**
* Creates a bullet list node.
*
* @param items - array of text strings for list items.
* @returns bullet_list node.
*/
export function bulletList(items: string[]) {
return schema.nodes.bullet_list.create(
null,
items.map((text) => schema.nodes.list_item.create(null, p(text)))
);
}
/**
* Creates an ordered list node.
*
* @param items - array of text strings for list items.
* @returns ordered_list node.
*/
export function orderedList(items: string[]) {
return schema.nodes.ordered_list.create(
null,
items.map((text) => schema.nodes.list_item.create(null, p(text)))
);
}
/**
* Creates a code block node.
*
* @param code - the code content.
* @param language - optional language for syntax highlighting.
* @returns code_block node.
*/
export function codeBlock(code: string, language?: string) {
return schema.nodes.code_block.create(
language ? { language } : null,
code ? schema.text(code) : undefined
);
}
/**
* Creates a horizontal rule node.
*
* @returns hr node.
*/
export function hr() {
return schema.nodes.hr.create();
}
/**
* Creates a document node with the given content.
*
* @param content - block node(s) to include in the document.
* @returns doc node.
*/
export function doc(
content:
| ReturnType<
| typeof p
| typeof table
| typeof heading
| typeof blockquote
| typeof bulletList
| typeof orderedList
| typeof codeBlock
| typeof hr
>
| ReturnType<
| typeof p
| typeof table
| typeof heading
| typeof blockquote
| typeof bulletList
| typeof orderedList
| typeof codeBlock
| typeof hr
>[]
) {
return schema.nodes.doc.create(null, content);
}
/**
* A plain-object representation of a ProseMirror node, as returned by
* `Node.toJSON()`.
*/
export interface JSONNode {
type: string;
content?: JSONNode[];
attrs?: Record<string, unknown>;
text?: string;
}
/**
* Recursively collects all nodes of the given type from a `Node.toJSON()`
* tree, including the root node itself.
*
* @param node - the JSON node to search, may be undefined for convenience.
* @param type - the node type name to match.
* @returns array of matching nodes in document order.
*/
export function findNodes(
node: JSONNode | undefined,
type: string
): JSONNode[] {
if (!node) {
return [];
}
return [
...(node.type === type ? [node] : []),
...(node.content ?? []).flatMap((child) => findNodes(child, type)),
];
}