diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx
index ee838d41..3d22c646 100644
--- a/apps/x/apps/renderer/src/components/markdown-editor.tsx
+++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx
@@ -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 `...` 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
\n\n` so the HTML block starts and ends on
+// its own line.
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 ``
- },
- )
+ return md
+ .replace(
+ /\n?\n?/g,
+ (_m, id: string) => `\n\n\n\n`,
+ )
+ .replace(
+ /\n?\n?/g,
+ (_m, id: string) => `\n\n\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 `\n${content}\n`
- }
+ case 'trackTargetOpen':
+ return ``
+ case 'trackTargetClose':
+ return ``
case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
@@ -664,7 +672,8 @@ export const MarkdownEditor = forwardRef...
- * 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
- *
- * which the `parseHTML` rule below picks up.
+ * → TrackTargetOpenExtension
+ * content in between → regular Tiptap nodes (paragraphs, lists,
+ * custom blocks, whatever tiptap-markdown parses)
+ * → 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' ? `` : ``
+
+ 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; node: { attrs: Record } }) {
+ 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 }
-}) {
- 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
- },
- },
- }
- },
-})
+export const TrackTargetOpenExtension = buildMarkerExtension('open')
+export const TrackTargetCloseExtension = buildMarkerExtension('close')
diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css
index 21f8956a..2083f4b0 100644
--- a/apps/x/apps/renderer/src/styles/editor.css
+++ b/apps/x/apps/renderer/src/styles/editor.css
@@ -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) */