From 61eca26f8854a62d86a4982fe5850356c28fa08a Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 10 Mar 2026 18:21:46 +0900 Subject: [PATCH] feat: prevent saving empty Tiptap editor content Add isEmptyTiptapContent utility to detect empty editor states and skip persistence when content contains only whitespace or empty paragraphs. This prevents unnecessary database writes and improves performance by avoiding saves of meaningless content changes in both raw and enhanced note editors. --- .../components/note-input/enhanced/editor.tsx | 17 +++++- .../src/session/components/note-input/raw.tsx | 7 ++- packages/tiptap/src/shared/utils.test.ts | 60 ++++++++++++++++++- packages/tiptap/src/shared/utils.ts | 35 +++++++++++ 4 files changed, 114 insertions(+), 5 deletions(-) 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;