diff --git a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx index 1569966c3d..f3a0199f53 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx @@ -1,9 +1,9 @@ -import { forwardRef, useMemo } from "react"; +import { forwardRef, useCallback, useMemo } from "react"; import { commands as openerCommands } from "@hypr/plugin-opener2"; import { type JSONContent, TiptapEditor } from "@hypr/tiptap/editor"; import NoteEditor from "@hypr/tiptap/editor"; -import { parseJsonContent } from "@hypr/tiptap/shared"; +import { isEmptyTiptapContent, parseJsonContent } from "@hypr/tiptap/shared"; import { useSearchEngine } from "~/search/contexts/engine"; import { useImageUpload } from "~/shared/hooks/useImageUpload"; @@ -26,7 +26,7 @@ export const EnhancedEditor = forwardRef< [content], ); - const handleChange = main.UI.useSetPartialRowCallback( + const persistChange = main.UI.useSetPartialRowCallback( "enhanced_notes", enhancedNoteId, (input: JSONContent) => ({ content: JSON.stringify(input) }), @@ -34,6 +34,17 @@ export const EnhancedEditor = forwardRef< main.STORE_ID, ); + const handleChange = useCallback( + (input: JSONContent) => { + if (content === undefined && isEmptyTiptapContent(input)) { + return; + } + + persistChange(input); + }, + [content, persistChange], + ); + const { search } = useSearchEngine(); const sessions = main.UI.useResultTable( main.QUERIES.timelineSessions, diff --git a/apps/desktop/src/session/components/note-input/raw.tsx b/apps/desktop/src/session/components/note-input/raw.tsx index f082d748d2..02240b0470 100644 --- a/apps/desktop/src/session/components/note-input/raw.tsx +++ b/apps/desktop/src/session/components/note-input/raw.tsx @@ -7,6 +7,7 @@ import NoteEditor, { type TiptapEditor, } from "@hypr/tiptap/editor"; import { + isEmptyTiptapContent, parseJsonContent, type PlaceholderFunction, } from "@hypr/tiptap/shared"; @@ -50,6 +51,10 @@ export const RawEditor = forwardRef< const handleChange = useCallback( (input: JSONContent) => { + if (rawMd === undefined && isEmptyTiptapContent(input)) { + return; + } + persistChange(input); if (!hasTrackedWriteRef.current) { @@ -63,7 +68,7 @@ export const RawEditor = forwardRef< } } }, - [persistChange, hasNonEmptyText], + [hasNonEmptyText, persistChange, rawMd], ); const { search } = useSearchEngine(); diff --git a/packages/tiptap/src/shared/utils.test.ts b/packages/tiptap/src/shared/utils.test.ts index e21cf4dddd..0d23eed684 100644 --- a/packages/tiptap/src/shared/utils.test.ts +++ b/packages/tiptap/src/shared/utils.test.ts @@ -3,7 +3,65 @@ import type { JSONContent } from "@tiptap/react"; import { describe, expect, test } from "vitest"; import { getExtensions } from "./extensions"; -import { isValidTiptapContent, json2md, md2json } from "./utils"; +import { + isEmptyTiptapContent, + isValidTiptapContent, + json2md, + md2json, +} from "./utils"; + +describe("isEmptyTiptapContent", () => { + test("treats empty paragraphs as empty", () => { + expect( + isEmptyTiptapContent({ + type: "doc", + content: [{ type: "paragraph" }], + }), + ).toBe(true); + }); + + test("treats whitespace-only text as empty", () => { + expect( + isEmptyTiptapContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: " " }], + }, + ], + }), + ).toBe(true); + }); + + test("treats text content as non-empty", () => { + expect( + isEmptyTiptapContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "hello" }], + }, + ], + }), + ).toBe(false); + }); + + test("treats non-text nodes as non-empty", () => { + expect( + isEmptyTiptapContent({ + type: "doc", + content: [ + { + type: "image", + attrs: { src: "https://example.com/image.png" }, + }, + ], + }), + ).toBe(false); + }); +}); describe("json2md", () => { test("renders task items without escaping brackets", () => { diff --git a/packages/tiptap/src/shared/utils.ts b/packages/tiptap/src/shared/utils.ts index 8c85820890..f3df80f598 100644 --- a/packages/tiptap/src/shared/utils.ts +++ b/packages/tiptap/src/shared/utils.ts @@ -8,6 +8,23 @@ export const EMPTY_TIPTAP_DOC: JSONContent = { content: [{ type: "paragraph" }], }; +const EMPTY_CONTAINER_NODE_TYPES = new Set([ + "blockquote", + "bulletList", + "codeBlock", + "doc", + "heading", + "listItem", + "orderedList", + "paragraph", + "table", + "tableCell", + "tableHeader", + "tableRow", + "taskItem", + "taskList", +]); + let _markdownManager: MarkdownManager | null = null; function getMarkdownManager(): MarkdownManager { @@ -26,6 +43,24 @@ export function isValidTiptapContent(content: unknown): content is JSONContent { return obj.type === "doc" && Array.isArray(obj.content); } +export function isEmptyTiptapContent( + content: JSONContent | undefined, +): boolean { + if (!content) { + return true; + } + + if (content.type === "text") { + return !content.text?.trim(); + } + + if (content.content?.length) { + return !content.content.some((child) => !isEmptyTiptapContent(child)); + } + + return EMPTY_CONTAINER_NODE_TYPES.has(content.type ?? ""); +} + export function parseJsonContent(raw: string | undefined | null): JSONContent { if (typeof raw !== "string" || !raw.trim()) { return EMPTY_TIPTAP_DOC;