From df1f788866f0ce78742eb7ed0c343244548b9a90 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:47:37 +0530 Subject: [PATCH] fix --- .../src/components/markdown-editor.tsx | 30 ++++- .../renderer/src/extensions/track-target.tsx | 117 ++++++++++++++++++ apps/x/apps/renderer/src/styles/editor.css | 36 ++++++ 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 apps/x/apps/renderer/src/extensions/track-target.tsx diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 33e9b507..ee838d41 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -10,6 +10,7 @@ import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' import { TrackBlockExtension } from '@/extensions/track-block' +import { TrackTargetExtension } from '@/extensions/track-target' import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' import { ChartBlockExtension } from '@/extensions/chart-block' @@ -43,6 +44,22 @@ function preprocessMarkdown(markdown: string): string { }) } +// Convert each `...` region to a +// placeholder HTML element that Tiptap's TrackTargetExtension parses into a +// `trackTarget` node. Content is base64-encoded so any characters survive. +// Without this step, tiptap-markdown strips the comment markers and leaves the +// inner content as loose paragraphs, which then duplicates when the backend +// re-inserts fresh markers around new content. +function preprocessTrackTargets(md: string): string { + return md.replace( + /\n?([\s\S]*?)\n?/g, + (_match, id: string, content: string) => { + const b64 = btoa(unescape(encodeURIComponent(content))) + return `
` + }, + ) +} + // 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 @@ -141,6 +158,13 @@ function blockToMarkdown(node: JsonNode): string { return serializeList(node, 0).join('\n') case 'taskBlock': return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'trackBlock': + return '```track\n' + (node.attrs?.data as string || '') + '\n```' + case 'trackTarget': { + const id = (node.attrs?.trackId as string) ?? '' + const content = (node.attrs?.content as string) ?? '' + return `\n${content}\n` + } case 'imageBlock': return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' case 'embedBlock': @@ -640,6 +664,7 @@ export const MarkdownEditor = forwardRef s.split('\n').map(line => line.trimEnd()).join('\n').trim() if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { isInternalUpdate.current = true - // Pre-process to preserve blank lines - const preprocessed = preprocessMarkdown(content) + // Pre-process to preserve blank lines, then wrap track-target comment + // regions into placeholder divs so TrackTargetExtension can pick them up. + const preprocessed = preprocessMarkdown(preprocessTrackTargets(content)) // Treat tab-open content as baseline: do not add hydration to undo history. editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() isInternalUpdate.current = false diff --git a/apps/x/apps/renderer/src/extensions/track-target.tsx b/apps/x/apps/renderer/src/extensions/track-target.tsx new file mode 100644 index 00000000..98d5541a --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/track-target.tsx @@ -0,0 +1,117 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { Streamdown } from 'streamdown' + +/** + * TrackTargetExtension — a Tiptap atom node that owns a + * ... + * region. Content is display-only; the backend is the sole writer. + * + * Parse path: `markdown-editor.tsx#preprocessTrackTargets` converts each + * comment-wrapped region into a placeholder + *
+ * which the `parseHTML` rule below picks up. + * + * Serialize path: round-trips back to the exact comment-wrapped form via + * `addStorage().markdown.serialize` AND via the custom + * `blockToMarkdown` switch in `markdown-editor.tsx` (both save paths must + * handle it identically). + */ + +// Unicode-safe base64 helpers — content may contain any char, including +// emoji and CJK. btoa/atob alone only handle Latin-1. +function encode(s: string): string { + return btoa(unescape(encodeURIComponent(s))) +} + +function decode(s: string): string { + try { + return decodeURIComponent(escape(atob(s))) + } catch { + return '' + } +} + +function TrackTargetView({ node }: { + node: { attrs: Record } +}) { + const trackId = (node.attrs.trackId as string) ?? '' + const content = (node.attrs.content as string) ?? '' + + return ( + +
e.stopPropagation()}> + {content.trim() + ? {content} + : No output yet — run the track to populate this area.} +
+
+ ) +} + +export const TrackTargetExtension = Node.create({ + name: 'trackTarget', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + trackId: { default: '' }, + content: { default: '' }, + } + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="track-target"]', + getAttrs(el) { + if (!(el instanceof HTMLElement)) return false + const trackId = el.getAttribute('data-track-id') ?? '' + const b64 = el.getAttribute('data-content') ?? '' + return { trackId, content: b64 ? decode(b64) : '' } + }, + }, + ] + }, + + renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record; node: { attrs: Record } }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-type': 'track-target', + 'data-track-id': (node.attrs.trackId as string) ?? '', + 'data-content': encode((node.attrs.content as string) ?? ''), + }), + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(TrackTargetView) + }, + + addStorage() { + return { + markdown: { + serialize( + state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, + node: { attrs: { trackId: string; content: string } }, + ) { + const id = node.attrs.trackId ?? '' + const content = node.attrs.content ?? '' + state.write(`\n${content}\n`) + state.closeBlock(node) + }, + parse: { + // handled by parseHTML after preprocessTrackTargets runs + }, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 57e8dd73..21f8956a 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -717,6 +717,42 @@ outline-offset: 2px; } +/* ============================================================= + Track Target — rendered output region below a track chip. + Display-only; the backend is the sole writer. + ============================================================= */ + +.tiptap-editor .ProseMirror .track-target-wrapper { + margin: 6px 0 10px 0; +} + +.tiptap-editor .ProseMirror .track-target-box { + position: relative; + padding: 10px 12px 10px 16px; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-left: 3px solid color-mix(in srgb, var(--foreground) 30%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--foreground) 3%, transparent); + user-select: text; + font-size: 13.5px; + line-height: 1.55; +} + +.tiptap-editor .ProseMirror .track-target-wrapper.ProseMirror-selectednode .track-target-box { + outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent); + outline-offset: 2px; +} + +.tiptap-editor .ProseMirror .track-target-markdown > *:first-child { margin-top: 0; } +.tiptap-editor .ProseMirror .track-target-markdown > *:last-child { margin-bottom: 0; } + +.tiptap-editor .ProseMirror .track-target-empty { + display: block; + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 45%, transparent); + font-style: italic; +} + /* Shared block styles (image, embed, chart, table) */ .tiptap-editor .ProseMirror .image-block-wrapper, .tiptap-editor .ProseMirror .embed-block-wrapper,