mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
fix
This commit is contained in:
parent
c20c12ef64
commit
df1f788866
3 changed files with 181 additions and 2 deletions
|
|
@ -10,6 +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 { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
|
|
@ -43,6 +44,22 @@ 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.
|
||||
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>`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Post-process to clean up any zero-width spaces in the output
|
||||
function postprocessMarkdown(markdown: string): string {
|
||||
// Remove lines that contain only the zero-width space marker
|
||||
|
|
@ -141,6 +158,13 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return serializeList(node, 0).join('\n')
|
||||
case 'taskBlock':
|
||||
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 'imageBlock':
|
||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
|
|
@ -640,6 +664,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
TrackBlockExtension.configure({ notePath }),
|
||||
TrackTargetExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
|
|
@ -1034,8 +1059,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||
isInternalUpdate.current = true
|
||||
// Pre-process to preserve blank lines
|
||||
const preprocessed = preprocessMarkdown(content)
|
||||
// Pre-process to preserve blank lines, then wrap track-target comment
|
||||
// regions into placeholder divs so TrackTargetExtension can pick them up.
|
||||
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
|
||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||
isInternalUpdate.current = false
|
||||
|
|
|
|||
117
apps/x/apps/renderer/src/extensions/track-target.tsx
Normal file
117
apps/x/apps/renderer/src/extensions/track-target.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
// 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)))
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -717,6 +717,42 @@
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track Target — rendered output region below a track chip.
|
||||
Display-only; the backend is the sole writer.
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror .track-target-wrapper {
|
||||
margin: 6px 0 10px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-target-box {
|
||||
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;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-target-wrapper.ProseMirror-selectednode .track-target-box {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue