This commit is contained in:
Ramnique Singh 2026-04-14 12:47:37 +05:30
parent c20c12ef64
commit df1f788866
3 changed files with 181 additions and 2 deletions

View file

@ -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 `<!--track-target:ID-->...<!--/track-target:ID-->` 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(
/<!--track-target:([^\s>]+)-->\n?([\s\S]*?)\n?<!--\/track-target:\1-->/g,
(_match, id: string, content: string) => {
const b64 = btoa(unescape(encodeURIComponent(content)))
return `<div data-type="track-target" data-track-id="${id}" data-content="${b64}"></div>`
},
)
}
// 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 `<!--track-target:${id}-->\n${content}\n<!--/track-target:${id}-->`
}
case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
@ -640,6 +664,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
ImageUploadPlaceholderExtension,
TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
TrackTargetExtension,
ImageBlockExtension,
EmbedBlockExtension,
ChartBlockExtension,
@ -1034,8 +1059,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
const normalizeForCompare = (s: string) => 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

View file

@ -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
* <!--track-target:ID-->...<!--/track-target:ID-->
* 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
* <div data-type="track-target" data-track-id="..." data-content="<base64>"></div>
* 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<string, unknown> }
}) {
const trackId = (node.attrs.trackId as string) ?? ''
const content = (node.attrs.content as string) ?? ''
return (
<NodeViewWrapper
className="track-target-wrapper"
data-type="track-target"
data-track-id={trackId}
>
<div className="track-target-box" onMouseDown={(e) => e.stopPropagation()}>
{content.trim()
? <Streamdown className="track-target-markdown">{content}</Streamdown>
: <span className="track-target-empty">No output yet run the track to populate this area.</span>}
</div>
</NodeViewWrapper>
)
}
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<string, unknown>; node: { attrs: Record<string, unknown> } }) {
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(`<!--track-target:${id}-->\n${content}\n<!--/track-target:${id}-->`)
state.closeBlock(node)
},
parse: {
// handled by parseHTML after preprocessTrackTargets runs
},
},
}
},
})

View file

@ -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,