From 716633c6df3f42fbda0d5a0501296ce49c23a289 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 13 Nov 2025 13:53:11 +0100 Subject: [PATCH] Remove trailing empty paragraphs --- common/src/util/parse.ts | 90 ++++++++++++++++++++++-------- web/pages/messages/[channelId].tsx | 7 ++- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/common/src/util/parse.ts b/common/src/util/parse.ts index d68228ab..b6fa2458 100644 --- a/common/src/util/parse.ts +++ b/common/src/util/parse.ts @@ -1,20 +1,15 @@ -import { - getText, - getSchema, - getTextSerializersFromSchema, - JSONContent, -} from '@tiptap/core' -import { Node as ProseMirrorNode } from '@tiptap/pm/model' -import { StarterKit } from '@tiptap/starter-kit' -import { Image } from '@tiptap/extension-image' -import { Link } from '@tiptap/extension-link' -import { Mention } from '@tiptap/extension-mention' +import {getSchema, getText, getTextSerializersFromSchema, JSONContent,} from '@tiptap/core' +import {Node as ProseMirrorNode} from '@tiptap/pm/model' +import {StarterKit} from '@tiptap/starter-kit' +import {Image} from '@tiptap/extension-image' +import {Link} from '@tiptap/extension-link' +import {Mention} from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' -import { find } from 'linkifyjs' -import { uniq } from 'lodash' -import { compareTwoStrings } from 'string-similarity' +import {find} from 'linkifyjs' +import {uniq} from 'lodash' +import {compareTwoStrings} from 'string-similarity' -/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ +/** get first url in text. like "notion.so " -> "http://notion.so" "notion" -> null */ export function getUrl(text: string) { const results = find(text, 'url') return results.length ? results[0].href : null @@ -48,10 +43,10 @@ export function parseMentions(data: JSONContent): string[] { export const extensions = [ StarterKit, Link, - Image.extend({ renderText: () => '[image]' }), + Image.extend({renderText: () => '[image]'}), Mention, // user @mention Iframe.extend({ - renderText: ({ node }) => + renderText: ({node}) => '[embed]' + node.attrs.src ? `(${node.attrs.src})` : '', }), ] @@ -78,8 +73,59 @@ export function parseJsonContentToText(content: JSONContent | string) { } export function urlBase64ToUint8Array(base64String: string) { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); - const rawData = window.atob(base64); - return new Uint8Array([...rawData].map(c => c.charCodeAt(0))); -} \ No newline at end of file + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/') + const rawData = window.atob(base64) + return new Uint8Array([...rawData].map(c => c.charCodeAt(0))) +} + +export function cleanDoc(doc: JSONContent) { + if (!doc || !Array.isArray(doc.content)) return doc; + + let content = [...doc.content]; + + const isEmptyParagraph = (node: JSONContent) => + node.type === "paragraph" && + (!node.content || node.content.length === 0); + + // Remove empty paragraphs at the start + while (content.length > 0 && isEmptyParagraph(content[0])) { + content.shift(); + } + + // Remove empty paragraphs at the end + while (content.length > 0 && isEmptyParagraph(content[content.length - 1])) { + content.pop(); + } + + // Trim leading/trailing hardBreaks within first and last paragraphs + const trimHardBreaks = (paragraph: JSONContent) => { + if (!paragraph.content) return paragraph; + + let nodes = [...paragraph.content]; + + // Remove hardBreaks at the start + while (nodes.length > 0 && nodes[0].type === "hardBreak") { + nodes.shift(); + } + + // Remove hardBreaks at the end + while (nodes.length > 0 && nodes[nodes.length - 1].type === "hardBreak") { + nodes.pop(); + } + + return { ...paragraph, content: nodes }; + }; + + if (content.length > 0) { + content[0] = trimHardBreaks(content[0]); + if (content.length > 1) { + content[content.length - 1] = trimHardBreaks(content[content.length - 1]); + } + } + + // Remove any now-empty paragraphs created by hardBreak trimming + content = content.filter(node => !(node.type === "paragraph" && (!node.content || node.content.length === 0))); + + return { ...doc, content }; +} diff --git a/web/pages/messages/[channelId].tsx b/web/pages/messages/[channelId].tsx index e4a84771..1590668f 100644 --- a/web/pages/messages/[channelId].tsx +++ b/web/pages/messages/[channelId].tsx @@ -32,7 +32,8 @@ import {useGroupedMessages, usePaginatedScrollingMessages,} from 'web/lib/supaba import {PrivateMessageChannel} from 'common/supabase/private-messages' import {ChatMessage} from 'common/chat-message' import {BackButton} from 'web/components/back-button' -import {SEO} from "web/components/SEO"; +import {SEO} from "web/components/SEO" +import {cleanDoc} from "common/util/parse"; export default function PrivateMessagesPage() { const router = useRouter() @@ -183,8 +184,8 @@ export const PrivateChat = (props: { setIsSubmitting(true) try { - const content = editor.getJSON(); - // console.log('editingMessage submitting message', {editingMessage}, JSON.stringify(content)) + const content = cleanDoc(editor.getJSON()) + // console.log('submitting message', JSON.stringify(content)) if (editingMessage) { // console.log('editingMessage edit-message', editingMessage) setMessages((prevMessages) =>