mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
fix
This commit is contained in:
parent
df1f788866
commit
c8e707ffb7
3 changed files with 145 additions and 155 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue