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 { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block' import { TaskBlockExtension } from '@/extensions/task-block'
import { TrackBlockExtension } from '@/extensions/track-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 { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block' import { EmbedBlockExtension } from '@/extensions/embed-block'
import { ChartBlockExtension } from '@/extensions/chart-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 // Convert track-target open/close HTML comment markers into placeholder divs
// placeholder HTML element that Tiptap's TrackTargetExtension parses into a // that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
// `trackTarget` node. Content is base64-encoded so any characters survive. // nodes. Content *between* the markers is left untouched — tiptap-markdown
// Without this step, tiptap-markdown strips the comment markers and leaves the // parses it naturally as whatever it is (paragraphs, lists, custom-block
// inner content as loose paragraphs, which then duplicates when the backend // fences, etc.), all rendered live by the existing extension set.
// re-inserts fresh markers around new content. //
// 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 { function preprocessTrackTargets(md: string): string {
return md.replace( return md
/<!--track-target:([^\s>]+)-->\n?([\s\S]*?)\n?<!--\/track-target:\1-->/g, .replace(
(_match, id: string, content: string) => { /\n?<!--track-target:([^\s>]+)-->\n?/g,
const b64 = btoa(unescape(encodeURIComponent(content))) (_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
return `<div data-type="track-target" data-track-id="${id}" data-content="${b64}"></div>` )
}, .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 // 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```' return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'trackBlock': case 'trackBlock':
return '```track\n' + (node.attrs?.data as string || '') + '\n```' return '```track\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackTarget': { case 'trackTargetOpen':
const id = (node.attrs?.trackId as string) ?? '' return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
const content = (node.attrs?.content as string) ?? '' case 'trackTargetClose':
return `<!--track-target:${id}-->\n${content}\n<!--/track-target:${id}-->` return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
}
case 'imageBlock': case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock': case 'embedBlock':
@ -664,7 +672,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
ImageUploadPlaceholderExtension, ImageUploadPlaceholderExtension,
TaskBlockExtension, TaskBlockExtension,
TrackBlockExtension.configure({ notePath }), TrackBlockExtension.configure({ notePath }),
TrackTargetExtension, TrackTargetOpenExtension,
TrackTargetCloseExtension,
ImageBlockExtension, ImageBlockExtension,
EmbedBlockExtension, EmbedBlockExtension,
ChartBlockExtension, ChartBlockExtension,

View file

@ -1,117 +1,90 @@
import { mergeAttributes, Node } from '@tiptap/react' 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 markers two Tiptap atom nodes that represent the open and
* <!--track-target:ID-->...<!--/track-target:ID--> * close HTML comment markers bracketing a track's output region on disk:
* region. Content is display-only; the backend is the sole writer.
* *
* Parse path: `markdown-editor.tsx#preprocessTrackTargets` converts each * <!--track-target:ID--> TrackTargetOpenExtension
* comment-wrapped region into a placeholder * content in between regular Tiptap nodes (paragraphs, lists,
* <div data-type="track-target" data-track-id="..." data-content="<base64>"></div> * custom blocks, whatever tiptap-markdown parses)
* which the `parseHTML` rule below picks up. * <!--/track-target:ID--> TrackTargetCloseExtension
* *
* Serialize path: round-trips back to the exact comment-wrapped form via * The markers are *semantic boundaries*, not a UI container. Content between
* `addStorage().markdown.serialize` AND via the custom * them is real, editable document content fully rendered by the existing
* `blockToMarkdown` switch in `markdown-editor.tsx` (both save paths must * extension set and freely editable by the user. The backend's updateContent()
* handle it identically). * 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 type MarkerVariant = 'open' | 'close'
// emoji and CJK. btoa/atob alone only handle Latin-1.
function encode(s: string): string { function buildMarkerExtension(variant: MarkerVariant) {
return btoa(unescape(encodeURIComponent(s))) 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 { export const TrackTargetOpenExtension = buildMarkerExtension('open')
try { export const TrackTargetCloseExtension = buildMarkerExtension('close')
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

@ -718,39 +718,47 @@
} }
/* ============================================================= /* =============================================================
Track Target rendered output region below a track chip. Track target markers thin visual bookends around a track's
Display-only; the backend is the sole writer. output region. The content BETWEEN these markers is normal,
editable document content (rendered by the existing extensions).
============================================================= */ ============================================================= */
.tiptap-editor .ProseMirror .track-target-wrapper { .tiptap-editor .ProseMirror div[data-type="track-target-open"] {
margin: 6px 0 10px 0;
}
.tiptap-editor .ProseMirror .track-target-box {
position: relative; position: relative;
padding: 10px 12px 10px 16px; height: 1px;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); margin: 14px 0 6px 0;
border-left: 3px solid color-mix(in srgb, var(--foreground) 30%, transparent); background: color-mix(in srgb, var(--foreground) 15%, transparent);
border-radius: 8px; pointer-events: none;
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 { .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: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
outline-offset: 2px; outline-offset: 1px;
} pointer-events: auto;
.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) */ /* Shared block styles (image, embed, chart, table) */