Files
outline/shared/editor/nodes/Video.tsx
T
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

211 lines
5.9 KiB
TypeScript

import { t } from "i18next";
import type Token from "markdown-it/lib/token.mjs";
import type {
NodeSpec,
NodeType,
Node as ProsemirrorNode,
} from "prosemirror-model";
import { NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import type { Primitive } from "utility-types";
import { sanitizeUrl } from "../../utils/urls";
import toggleWrap from "../commands/toggleWrap";
import Caption from "../components/Caption";
import VideoComponent from "../components/Video";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import attachmentsRule from "../rules/links";
import type { ComponentProps } from "../types";
import Node from "./Node";
const parseDimension = (value: string | null): number | null => {
const parsed = parseInt(value ?? "", 10);
return Number.isFinite(parsed) ? parsed : null;
};
export default class Video extends Node {
get name() {
return "video";
}
get rulePlugins() {
return [attachmentsRule];
}
get schema(): NodeSpec {
return {
attrs: {
id: {
default: null,
},
src: {
default: null,
},
width: {
default: null,
},
height: {
default: null,
},
title: {
default: null,
validate: "string|null",
},
},
group: "block",
selectable: true,
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1289000
draggable: false,
defining: true,
atom: true,
parseDOM: [
{
priority: 100,
tag: "video",
getAttrs: (dom: HTMLVideoElement) => ({
id: dom.id,
title: dom.getAttribute("title"),
src: dom.getAttribute("src"),
width: parseDimension(dom.getAttribute("width")),
height: parseDimension(dom.getAttribute("height")),
}),
},
],
toDOM: (node) => [
"div",
{
class: "video",
},
[
"video",
{
id: node.attrs.id,
src: sanitizeUrl(node.attrs.src),
controls: true,
width: node.attrs.width,
height: node.attrs.height,
},
String(node.attrs.title),
],
],
leafText: (node) => node.attrs.title,
};
}
handleSelect =
({ getPos }: { getPos: () => number }) =>
() => {
const { view } = this.editor;
const $pos = view.state.doc.resolve(getPos());
const transaction = view.state.tr.setSelection(new NodeSelection($pos));
view.dispatch(transaction);
};
handleChangeSize =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
({ width, height }: { width: number; height?: number }) => {
const { view } = this.editor;
const { tr } = view.state;
const pos = getPos();
const transaction = tr
.setNodeMarkup(pos, undefined, {
...node.attrs,
width,
height,
})
.setMeta("addToHistory", true);
const $pos = transaction.doc.resolve(getPos());
view.dispatch(transaction.setSelection(new NodeSelection($pos)));
};
handleCaptionKeyDown =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(event: React.KeyboardEvent<HTMLParagraphElement>) => {
// Pressing Enter in the caption field should move the cursor/selection
// below the video
if (event.key === "Enter") {
event.preventDefault();
const { view } = this.editor;
const $pos = view.state.doc.resolve(getPos() + node.nodeSize);
view.dispatch(
view.state.tr.setSelection(TextSelection.near($pos)).scrollIntoView()
);
view.focus();
return;
}
// Pressing Backspace in an empty caption field focuses the video.
if (event.key === "Backspace" && event.currentTarget.innerText === "") {
event.preventDefault();
event.stopPropagation();
const { view } = this.editor;
const $pos = view.state.doc.resolve(getPos());
const tr = view.state.tr.setSelection(new NodeSelection($pos));
view.dispatch(tr);
view.focus();
return;
}
};
handleCaptionBlur =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(event: React.FocusEvent<HTMLParagraphElement>) => {
const caption = event.currentTarget.innerText;
if (caption === node.attrs.title) {
return;
}
const { view } = this.editor;
const { tr } = view.state;
// update meta on object
const pos = getPos();
const transaction = tr.setNodeMarkup(pos, undefined, {
...node.attrs,
title: caption,
});
view.dispatch(transaction);
};
component = (props: ComponentProps) => (
<VideoComponent {...props} onChangeSize={this.handleChangeSize(props)}>
<Caption
width={props.node.attrs.width}
onBlur={this.handleCaptionBlur(props)}
onKeyDown={this.handleCaptionKeyDown(props)}
isSelected={props.isSelected}
placeholder={t("Write a caption")}
>
{props.node.attrs.title}
</Caption>
</VideoComponent>
);
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, Primitive>) => toggleWrap(type, attrs);
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.ensureNewLine();
state.write(
`[${node.attrs.title} ${node.attrs.width ?? ""}x${
node.attrs.height ?? ""
}](${node.attrs.src})\n\n`
);
state.ensureNewLine();
}
parseMarkdown() {
return {
node: "video",
getAttrs: (tok: Token) => ({
src: tok.attrGet("src"),
title: tok.attrGet("title"),
width: parseDimension(tok.attrGet("width")),
height: parseDimension(tok.attrGet("height")),
}),
};
}
}