diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index a65f262a..f7661757 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -1,4 +1,4 @@ -import { useEditor, EditorContent, Extension } from '@tiptap/react' +import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' @@ -10,6 +10,159 @@ import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' + +// Zero-width space used as invisible marker for blank lines +const BLANK_LINE_MARKER = '\u200B' + +// Pre-process markdown to preserve blank lines before parsing +function preprocessMarkdown(markdown: string): string { + // Convert sequences of 3+ newlines to paragraphs with zero-width space + // - 2 newlines = normal paragraph break (0 empty paragraphs) + // - 3 newlines = 1 blank line = 1 empty paragraph + // - 4 newlines = 2 blank lines = 2 empty paragraphs + // Formula: emptyParagraphs = totalNewlines - 2 + return markdown.replace(/\n{3,}/g, (match) => { + const totalNewlines = match.length + const emptyParagraphs = totalNewlines - 2 + let result = '\n\n' + for (let i = 0; i < emptyParagraphs; i++) { + result += BLANK_LINE_MARKER + '\n\n' + } + return result + }) +} + +// Post-process to clean up any zero-width spaces in the output +function postprocessMarkdown(markdown: string): string { + // Remove lines that contain only the zero-width space marker + return markdown.split('\n').map(line => { + if (line === BLANK_LINE_MARKER || line.trim() === BLANK_LINE_MARKER) { + return '' + } + // Also remove zero-width spaces from other content + return line.replace(new RegExp(BLANK_LINE_MARKER, 'g'), '') + }).join('\n') +} + +// Custom function to get markdown that preserves empty paragraphs as blank lines +function getMarkdownWithBlankLines(editor: Editor): string { + const json = editor.getJSON() + if (!json.content) return '' + + const blocks: string[] = [] + + // Helper to convert a node to markdown text + const nodeToText = (node: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }): string => { + if (!node.content) return '' + return node.content.map(child => { + if (child.type === 'text') { + let text = child.text || '' + // Apply marks (bold, italic, etc.) + if (child.marks) { + for (const mark of child.marks) { + if (mark.type === 'bold') text = `**${text}**` + else if (mark.type === 'italic') text = `*${text}*` + else if (mark.type === 'code') text = `\`${text}\`` + else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` + } + } + return text + } else if (child.type === 'hardBreak') { + return '\n' + } + return '' + }).join('') + } + + for (const node of json.content) { + if (node.type === 'paragraph') { + const text = nodeToText(node) + // If the paragraph contains only the blank line marker or is empty, it's a blank line + if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) { + // Push empty string to represent blank line - will add extra newline when joining + blocks.push('') + } else { + blocks.push(text) + } + } else if (node.type === 'heading') { + const level = (node.attrs?.level as number) || 1 + const text = nodeToText(node) + blocks.push('#'.repeat(level) + ' ' + text) + } else if (node.type === 'bulletList' || node.type === 'orderedList') { + // Handle lists - all items are part of one block + const listLines: string[] = [] + const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> + listItems.forEach((item, index) => { + const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- ' + const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> + itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { + const text = nodeToText(para) + if (paraIndex === 0) { + listLines.push(prefix + text) + } else { + listLines.push(' ' + text) + } + }) + }) + blocks.push(listLines.join('\n')) + } else if (node.type === 'taskList') { + const listLines: string[] = [] + const listItems = (node.content || []) as Array<{ content?: Array; attrs?: Record }> + listItems.forEach(item => { + const checked = item.attrs?.checked ? 'x' : ' ' + const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> + itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }, paraIndex: number) => { + const text = nodeToText(para) + if (paraIndex === 0) { + listLines.push(`- [${checked}] ${text}`) + } else { + listLines.push(' ' + text) + } + }) + }) + blocks.push(listLines.join('\n')) + } else if (node.type === 'codeBlock') { + const lang = (node.attrs?.language as string) || '' + blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') + } else if (node.type === 'blockquote') { + const content = node.content || [] + const quoteLines = content.map(para => '> ' + nodeToText(para)) + blocks.push(quoteLines.join('\n')) + } else if (node.type === 'horizontalRule') { + blocks.push('---') + } else if (node.type === 'wikiLink') { + const path = (node.attrs?.path as string) || '' + blocks.push(`[[${path}]]`) + } else if (node.type === 'image') { + const src = (node.attrs?.src as string) || '' + const alt = (node.attrs?.alt as string) || '' + blocks.push(`![${alt}](${src})`) + } + } + + // Custom join: content blocks get \n\n before them, empty blocks add \n each + // This produces: 1 empty paragraph = 3 newlines (1 blank line on disk) + if (blocks.length === 0) return '' + + let result = '' + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + const isContent = block !== '' + + if (i === 0) { + result = block + } else if (isContent) { + // Content block: add \n\n before it (standard paragraph break) + result += '\n\n' + block + } else { + // Empty block: just add \n (one extra newline for blank line) + result += '\n' + } + } + + return result +} import { EditorToolbar } from './editor-toolbar' import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' @@ -140,8 +293,9 @@ export function MarkdownEditor({ placeholder, }), Markdown.configure({ - html: false, + html: true, breaks: true, + tightLists: false, transformCopiedText: true, transformPastedText: true, }), @@ -150,8 +304,9 @@ export function MarkdownEditor({ content: '', onUpdate: ({ editor }) => { if (isInternalUpdate.current) return - const storage = editor.storage as unknown as Record string }> - const markdown = storage.markdown?.getMarkdown?.() ?? '' + let markdown = getMarkdownWithBlankLines(editor) + // Post-process to clean up any markers and ensure blank lines are preserved + markdown = postprocessMarkdown(markdown) onChange(markdown) }, editorProps: { @@ -249,11 +404,14 @@ export function MarkdownEditor({ // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { - const storage = editor.storage as unknown as Record string }> - const currentContent = storage.markdown?.getMarkdown?.() ?? '' - if (currentContent !== content) { + const currentContent = getMarkdownWithBlankLines(editor) + // Normalize for comparison (trim trailing whitespace from lines) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { isInternalUpdate.current = true - editor.commands.setContent(content) + // Pre-process to preserve blank lines + const preprocessed = preprocessMarkdown(content) + editor.commands.setContent(preprocessed) isInternalUpdate.current = false } }