From 9ac29ec0c9f519e147d69e6f6a07997c10b6fdd0 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sun, 22 Mar 2026 12:04:43 -0700 Subject: [PATCH] fix: harden summary editor mount and markdown normalization - guard caret-position checks until the TipTap editor view is mounted - normalize parsed markdown and loaded TipTap JSON into schema-valid documents - validate shared TipTap content with ProseMirror check() and add regressions for list and image edge cases --- .../components/caret-position-context.tsx | 15 +- .../tiptap/src/shared/schema-validation.ts | 2 +- packages/tiptap/src/shared/utils.test.ts | 119 ++++++- packages/tiptap/src/shared/utils.ts | 290 +++++++++++++++++- 4 files changed, 402 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/session/components/caret-position-context.tsx b/apps/desktop/src/session/components/caret-position-context.tsx index c2c8d68c33..7cd5781062 100644 --- a/apps/desktop/src/session/components/caret-position-context.tsx +++ b/apps/desktop/src/session/components/caret-position-context.tsx @@ -44,6 +44,14 @@ export function useCaretPosition() { const BOTTOM_THRESHOLD = 70; +function getMountedEditorView(editor: TiptapEditor) { + const view = ( + editor as unknown as { editorView?: TiptapEditor["view"] | null } + ).editorView; + + return view?.dom?.isConnected ? view : null; +} + export function useCaretNearBottom({ editor, container, @@ -70,7 +78,12 @@ export function useCaretNearBottom({ return; } - const { view } = editor; + const view = getMountedEditorView(editor); + if (!view) { + setCaretNearBottom(false); + return; + } + const { from } = view.state.selection; const coords = view.coordsAtPos(from); diff --git a/packages/tiptap/src/shared/schema-validation.ts b/packages/tiptap/src/shared/schema-validation.ts index 8bc7f8faf8..b385133848 100644 --- a/packages/tiptap/src/shared/schema-validation.ts +++ b/packages/tiptap/src/shared/schema-validation.ts @@ -19,7 +19,7 @@ export type SchemaValidationResult = export function validateJsonContent(json: JSONContent): SchemaValidationResult { try { - getCachedSchema().nodeFromJSON(json); + getCachedSchema().nodeFromJSON(json).check(); return { valid: true }; } catch (error) { return { diff --git a/packages/tiptap/src/shared/utils.test.ts b/packages/tiptap/src/shared/utils.test.ts index fee5fbf6cb..e0f657a0af 100644 --- a/packages/tiptap/src/shared/utils.test.ts +++ b/packages/tiptap/src/shared/utils.test.ts @@ -3,7 +3,12 @@ import type { JSONContent } from "@tiptap/react"; import { describe, expect, test } from "vitest"; import { getExtensions } from "./extensions"; -import { isValidTiptapContent, json2md, md2json } from "./utils"; +import { + isValidTiptapContent, + json2md, + md2json, + parseJsonContent, +} from "./utils"; describe("json2md", () => { test("renders underline as html tags", () => { @@ -276,19 +281,19 @@ describe("md2json", () => { "Check out this image: ![cat](https://example.com/cat.png) and more text"; const json = md2json(markdown); - const paragraph = json.content![0]; - expect(paragraph.type).toBe("paragraph"); + expect(json.content).toHaveLength(3); + expect(json.content![0].type).toBe("paragraph"); + expect(json.content![1].type).toBe("image"); + expect(json.content![2].type).toBe("paragraph"); - const imageNode = paragraph.content!.find( - (node) => node.type === "image", - ); - expect(imageNode).toBeDefined(); + const imageNode = json.content![1]; expect(imageNode?.attrs?.src).toBe("https://example.com/cat.png"); expect(imageNode?.attrs?.alt).toBe("cat"); - const textNodes = paragraph.content!.filter( - (node) => node.type === "text", - ); + const textNodes = json + .content!.filter((node) => node.type === "paragraph") + .flatMap((node) => node.content ?? []) + .filter((node) => node.type === "text"); expect(textNodes.length).toBeGreaterThan(0); }); }); @@ -447,7 +452,7 @@ describe("md2json mark sanitization", () => { error?: string; } { try { - schema.nodeFromJSON(json); + schema.nodeFromJSON(json).check(); return { valid: true }; } catch (error) { return { @@ -510,7 +515,7 @@ describe("schema validation", () => { error?: string; } { try { - schema.nodeFromJSON(json); + schema.nodeFromJSON(json).check(); return { valid: true }; } catch (error) { return { @@ -614,6 +619,9 @@ More text.`; if (!validation.valid) { throw new Error(`Schema validation failed: ${validation.error}`); } + + expect(json.content).toHaveLength(3); + expect(json.content![1].type).toBe("image"); }); test("task list produces schema-valid JSON", () => { @@ -655,6 +663,72 @@ More text.`; throw new Error(`Schema validation failed: ${validation.error}`); } }); + + test("heading-first list items are normalized into paragraph-first list items", () => { + const json = md2json("- ## Heading"); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + + expect(json.content?.[0]?.type).toBe("bulletList"); + expect(json.content?.[0]?.content?.[0]?.type).toBe("listItem"); + expect(json.content?.[0]?.content?.[0]?.content?.[0]?.type).toBe( + "paragraph", + ); + }); + + test("nested-list-only list items are normalized with a leading paragraph", () => { + const json = md2json("-\n 1. Child"); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + + const listItem = json.content?.[0]?.content?.[0]; + expect(listItem?.type).toBe("listItem"); + expect(listItem?.content?.[0]?.type).toBe("paragraph"); + expect(listItem?.content?.[1]?.type).toBe("orderedList"); + }); + + test("stored invalid content is repaired on load", () => { + const raw = JSON.stringify({ + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Heading" }], + }, + ], + }, + ], + }, + ], + } satisfies JSONContent); + + const json = parseJsonContent(raw); + + const validation = validateJsonContent(json); + expect(validation.valid).toBe(true); + if (!validation.valid) { + throw new Error(`Schema validation failed: ${validation.error}`); + } + + expect(json.content?.[0]?.content?.[0]?.content?.[0]?.type).toBe( + "paragraph", + ); + }); }); describe("invalid content detection", () => { @@ -679,7 +753,7 @@ More text.`; } as JSONContent; const validation = validateJsonContent(invalidJson); - expect(validation.valid).toBe(true); + expect(validation.valid).toBe(false); }); test("validates image with src attribute (block-level)", () => { @@ -779,4 +853,23 @@ describe("isValidTiptapContent", () => { false, ); }); + + test("returns false for schema-invalid content", () => { + expect( + isValidTiptapContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "image", + attrs: { src: "https://example.com/image.png" }, + }, + ], + }, + ], + }), + ).toBe(false); + }); }); diff --git a/packages/tiptap/src/shared/utils.ts b/packages/tiptap/src/shared/utils.ts index 8c85820890..03df6d94e3 100644 --- a/packages/tiptap/src/shared/utils.ts +++ b/packages/tiptap/src/shared/utils.ts @@ -2,6 +2,7 @@ import { MarkdownManager } from "@tiptap/markdown"; import type { JSONContent } from "@tiptap/react"; import { getExtensions } from "./extensions"; +import { validateJsonContent } from "./schema-validation"; export const EMPTY_TIPTAP_DOC: JSONContent = { type: "doc", @@ -10,6 +11,15 @@ export const EMPTY_TIPTAP_DOC: JSONContent = { let _markdownManager: MarkdownManager | null = null; +function hasDocShape(content: unknown): content is JSONContent { + if (!content || typeof content !== "object") { + return false; + } + + const obj = content as Record; + return obj.type === "doc"; +} + function getMarkdownManager(): MarkdownManager { if (!_markdownManager) { _markdownManager = new MarkdownManager({ extensions: getExtensions() }); @@ -18,12 +28,7 @@ function getMarkdownManager(): MarkdownManager { } export function isValidTiptapContent(content: unknown): content is JSONContent { - if (!content || typeof content !== "object") { - return false; - } - - const obj = content as Record; - return obj.type === "doc" && Array.isArray(obj.content); + return hasDocShape(content) && validateJsonContent(content).valid; } export function parseJsonContent(raw: string | undefined | null): JSONContent { @@ -33,7 +38,7 @@ export function parseJsonContent(raw: string | undefined | null): JSONContent { try { const parsed = JSON.parse(raw); - return isValidTiptapContent(parsed) ? parsed : EMPTY_TIPTAP_DOC; + return hasDocShape(parsed) ? ensureValidContent(parsed) : EMPTY_TIPTAP_DOC; } catch { return EMPTY_TIPTAP_DOC; } @@ -46,20 +51,287 @@ export function json2md(jsonContent: JSONContent): string { export function md2json(markdown: string): JSONContent { try { const json = getMarkdownManager().parse(markdown); - return sanitizeMarks(json); + return ensureValidContent(sanitizeMarks(json), markdown); } catch (error) { console.error(error); + return createFallbackDoc(markdown); + } +} + +function ensureValidContent( + content: JSONContent, + fallbackText?: string, +): JSONContent { + const normalized = normalizeTiptapContent(content); + const validation = validateJsonContent(normalized); + + if (validation.valid) { + return normalized; + } + + console.error("Failed to normalize TipTap content:", validation.error); + return createFallbackDoc(fallbackText); +} + +function normalizeTiptapContent(content: JSONContent): JSONContent { + if (content.type !== "doc") { + return EMPTY_TIPTAP_DOC; + } + + return { + ...content, + content: normalizeBlockContainerContent(content.content), + }; +} + +function normalizeBlockContainerContent( + content: JSONContent[] | undefined, +): JSONContent[] { + const blocks = (content ?? []).flatMap(normalizeBlockNode); + return blocks.length > 0 ? blocks : [createParagraph()]; +} + +function normalizeBlockNode(node: JSONContent): JSONContent[] { + switch (node.type) { + case "paragraph": + return normalizeParagraph(node); + case "heading": + return [normalizeHeading(node)]; + case "bulletList": + case "orderedList": + case "taskList": + return [normalizeList(node)]; + case "blockquote": + return [ + { + ...node, + content: normalizeBlockContainerContent(node.content), + }, + ]; + case "codeBlock": + return [normalizeCodeBlock(node)]; + case "image": + case "horizontalRule": + return [node]; + case "text": + case "hardBreak": + return [createParagraph([sanitizeMarks(node)])]; + default: + if (node.type?.startsWith("mention-")) { + return [createParagraph([node])]; + } + return []; + } +} + +function normalizeParagraph(node: JSONContent): JSONContent[] { + const blocks: JSONContent[] = []; + let inlineContent: JSONContent[] = []; + + const flushParagraph = () => { + if (inlineContent.length > 0) { + blocks.push(createParagraph(inlineContent)); + inlineContent = []; + } + }; + + for (const child of node.content ?? []) { + if (isInlineNode(child)) { + inlineContent.push(child.type === "text" ? sanitizeMarks(child) : child); + continue; + } + + switch (child.type) { + case "image": + flushParagraph(); + blocks.push(child); + break; + case "heading": + flushParagraph(); + blocks.push(createParagraph(normalizeInlineContent(child.content))); + break; + case "paragraph": + flushParagraph(); + blocks.push(...normalizeParagraph(child)); + break; + case "bulletList": + case "orderedList": + case "taskList": + flushParagraph(); + blocks.push(normalizeList(child)); + break; + case "blockquote": + flushParagraph(); + blocks.push({ + ...child, + content: normalizeBlockContainerContent(child.content), + }); + break; + case "codeBlock": + flushParagraph(); + blocks.push(normalizeCodeBlock(child)); + break; + case "horizontalRule": + flushParagraph(); + blocks.push(child); + break; + default: + if (child.type?.startsWith("mention-")) { + inlineContent.push(child); + } + break; + } + } + + flushParagraph(); + + return blocks.length > 0 ? blocks : [createParagraph()]; +} + +function normalizeHeading(node: JSONContent): JSONContent { + const content = normalizeInlineContent(node.content); + return content.length > 0 ? { ...node, content } : { ...node }; +} + +function normalizeList(node: JSONContent): JSONContent { + const itemType = node.type === "taskList" ? "taskItem" : "listItem"; + const items = (node.content ?? []).map((child) => + normalizeListItem(child, itemType), + ); + + return { + ...node, + content: items.length > 0 ? items : [createListItem(itemType)], + }; +} + +function normalizeListItem( + node: JSONContent, + itemType: "listItem" | "taskItem", +): JSONContent { + const blocks = + node.type === itemType + ? (node.content ?? []).flatMap(normalizeListItemChild) + : normalizeListItemChild(node); + + if (blocks.length === 0 || blocks[0]?.type !== "paragraph") { + blocks.unshift(createParagraph()); + } + + return createListItem(itemType, blocks, node.attrs); +} + +function normalizeListItemChild(node: JSONContent): JSONContent[] { + switch (node.type) { + case "paragraph": + return normalizeParagraph(node); + case "heading": + return [createParagraph(normalizeInlineContent(node.content))]; + case "bulletList": + case "orderedList": + case "taskList": + return [normalizeList(node)]; + case "blockquote": + return [ + { + ...node, + content: normalizeBlockContainerContent(node.content), + }, + ]; + case "codeBlock": + return [normalizeCodeBlock(node)]; + case "image": + case "horizontalRule": + return [node]; + case "text": + case "hardBreak": + return [createParagraph([sanitizeMarks(node)])]; + default: + if (node.type?.startsWith("mention-")) { + return [createParagraph([node])]; + } + return []; + } +} + +function normalizeCodeBlock(node: JSONContent): JSONContent { + const content = (node.content ?? []).flatMap((child) => + child.type === "text" ? [{ ...child, marks: undefined }] : [], + ); + + return content.length > 0 ? { ...node, content } : { ...node, content: [] }; +} + +function normalizeInlineContent( + content: JSONContent[] | undefined, +): JSONContent[] { + const normalized: JSONContent[] = []; + + for (const child of content ?? []) { + if (isInlineNode(child)) { + normalized.push(child.type === "text" ? sanitizeMarks(child) : child); + continue; + } + + if (child.type === "image") { + const alt = child.attrs?.alt; + if (typeof alt === "string" && alt.length > 0) { + normalized.push({ type: "text", text: alt }); + } + } + } + + return normalized; +} + +function isInlineNode(node: JSONContent): boolean { + return ( + node.type === "text" || + node.type === "hardBreak" || + node.type?.startsWith("mention-") === true + ); +} + +function createParagraph(content: JSONContent[] = []): JSONContent { + return content.length > 0 + ? { type: "paragraph", content } + : { type: "paragraph" }; +} + +function createListItem( + itemType: "listItem" | "taskItem", + content: JSONContent[] = [createParagraph()], + attrs?: JSONContent["attrs"], +): JSONContent { + if (itemType === "taskItem") { + return { + type: "taskItem", + attrs: { checked: attrs?.checked === true }, + content, + }; + } + + return { + type: "listItem", + content, + }; +} + +function createFallbackDoc(text?: string): JSONContent { + if (typeof text === "string" && text.length > 0) { return { type: "doc", content: [ { type: "paragraph", - content: [{ type: "text", text: markdown }], + content: [{ type: "text", text }], }, ], }; } + + return EMPTY_TIPTAP_DOC; } /**