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) */