mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue