This commit is contained in:
Ramnique Singh 2026-04-14 13:47:11 +05:30
parent df1f788866
commit c8e707ffb7
3 changed files with 145 additions and 155 deletions

View file

@ -10,7 +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 { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
@ -44,20 +44,29 @@ 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.
// Convert track-target open/close HTML comment markers into placeholder divs
// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
// nodes. Content *between* the markers is left untouched — tiptap-markdown
// parses it naturally as whatever it is (paragraphs, lists, custom-block
// fences, etc.), all rendered live by the existing extension set.
//
// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag
// line until a blank line terminates it, and markdown inline rules (bold,
// italics, links) don't apply inside the block. Without surrounding blank
// lines, the line right after our placeholder div gets absorbed as HTML and
// its markdown is not parsed. We consume any adjacent newlines in the match
// and emit exactly `\n\n<div></div>\n\n` so the HTML block starts and ends on
// its own line.
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>`
},
)
return md
.replace(
/\n?<!--track-target:([^\s>]+)-->\n?/g,
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
)
.replace(
/\n?<!--\/track-target:([^\s>]+)-->\n?/g,
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
)
}
// Post-process to clean up any zero-width spaces in the output
@ -160,11 +169,10 @@ function blockToMarkdown(node: JsonNode): string {
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 'trackTargetOpen':
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'trackTargetClose':
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
@ -664,7 +672,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
ImageUploadPlaceholderExtension,
TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
TrackTargetExtension,
TrackTargetOpenExtension,
TrackTargetCloseExtension,
ImageBlockExtension,
EmbedBlockExtension,
ChartBlockExtension,

View file

@ -1,117 +1,90 @@
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.
* Track target markers two Tiptap atom nodes that represent the open and
* close HTML comment markers bracketing a track's output region on disk:
*
* 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.
* <!--track-target:ID--> TrackTargetOpenExtension
* content in between regular Tiptap nodes (paragraphs, lists,
* custom blocks, whatever tiptap-markdown parses)
* <!--/track-target:ID--> TrackTargetCloseExtension
*
* 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).
* The markers are *semantic boundaries*, not a UI container. Content between
* them is real, editable document content fully rendered by the existing
* extension set and freely editable by the user. The backend's updateContent()
* in fileops.ts still locates the region on disk by these comment markers.
*
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
* regex replace, converting each comment into a placeholder div that these
* extensions' parseHTML rules pick up. No content capture.
*
* Save path: both Tiptap's built-in markdown serializer
* (`addStorage().markdown.serialize`) AND the app's custom serializer
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
* back out they must stay in sync.
*/
// 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)))
type MarkerVariant = 'open' | 'close'
function buildMarkerExtension(variant: MarkerVariant) {
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
const commentFor = (id: string) =>
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
return Node.create({
name,
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
trackId: { default: '' },
}
},
parseHTML() {
return [
{
tag: `div[data-type="${htmlType}"]`,
getAttrs(el) {
if (!(el instanceof HTMLElement)) return false
return { trackId: el.getAttribute('data-track-id') ?? '' }
},
},
]
},
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': htmlType,
'data-track-id': (node.attrs.trackId as string) ?? '',
}),
]
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
node: { attrs: { trackId: string } },
) {
state.write(commentFor(node.attrs.trackId ?? ''))
state.closeBlock(node)
},
parse: {
// handled via preprocessTrackTargets → parseHTML
},
},
}
},
})
}
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
},
},
}
},
})
export const TrackTargetOpenExtension = buildMarkerExtension('open')
export const TrackTargetCloseExtension = buildMarkerExtension('close')

View file

@ -718,39 +718,47 @@
}
/* =============================================================
Track Target rendered output region below a track chip.
Display-only; the backend is the sole writer.
Track target markers thin visual bookends around a track's
output region. The content BETWEEN these markers is normal,
editable document content (rendered by the existing extensions).
============================================================= */
.tiptap-editor .ProseMirror .track-target-wrapper {
margin: 6px 0 10px 0;
}
.tiptap-editor .ProseMirror .track-target-box {
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
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;
height: 1px;
margin: 14px 0 6px 0;
background: color-mix(in srgb, var(--foreground) 15%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror .track-target-wrapper.ProseMirror-selectednode .track-target-box {
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
content: 'track: ' attr(data-track-id);
position: absolute;
top: -8px;
left: 8px;
padding: 0 6px;
background: var(--background, #fff);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
text-transform: none;
white-space: nowrap;
pointer-events: auto;
}
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
height: 1px;
margin: 6px 0 14px 0;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
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;
outline-offset: 1px;
pointer-events: auto;
}
/* Shared block styles (image, embed, chart, table) */