Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/web-ui/src/infrastructure/markdown/Markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,8 @@
margin-bottom: 0;
}

.markdown-renderer details {
.markdown-renderer details,
.markdown-renderer [data-type='details'] {
margin: calc(var(--markdown-block-gap) * 1.2) 0;
padding: 0.62rem 0.72rem;
border: 1px solid var(--openbitfun-color-border-subtle);
Expand All @@ -421,6 +422,10 @@
margin-bottom: var(--markdown-block-gap);
}

.markdown-renderer [data-type='details'] [data-type='detailsContent'] {
padding-top: var(--markdown-block-gap);
}

.markdown-renderer hr {
height: 1px;
margin: calc(var(--markdown-block-gap) * 1.8) 0;
Expand Down Expand Up @@ -543,6 +548,19 @@
background: var(--markdown-table-row-hover);
}

// Tiptap keeps the header in tbody; offset striping to match rendered data rows.
.markdown-renderer.ProseMirror table[data-type='markdown-table'] tbody tr:nth-child(2n) {
background: var(--markdown-table-row-base);
}

.markdown-renderer.ProseMirror table[data-type='markdown-table'] tbody tr:nth-child(2n + 3) {
background: var(--markdown-table-row-stripe);
}

.markdown-renderer.ProseMirror table[data-type='markdown-table'] tbody tr:hover {
background: var(--markdown-table-row-hover);
}

.markdown-renderer a,
.markdown-renderer .file-link,
.markdown-renderer .visualization-link,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import rehypeSanitize from 'rehype-sanitize';
import type { Options as RehypeSanitizeOptions } from 'rehype-sanitize';
import type { Pluggable } from 'unified';
import 'katex/dist/katex.min.css';
import { rehypeSourceRange, type MarkdownSourceRange } from './rehypeSourceRange';

interface MarkdownMathRendererProps {
markdownContent: string;
components: Components;
sanitizeSchema: RehypeSanitizeOptions;
remarkAutolinkComputerFileLinks: Pluggable;
urlTransform: (value: string) => string;
sourceRange?: MarkdownSourceRange;
}

export const MarkdownMathRenderer: React.FC<MarkdownMathRendererProps> = ({
Expand All @@ -24,11 +26,12 @@ export const MarkdownMathRenderer: React.FC<MarkdownMathRendererProps> = ({
sanitizeSchema,
remarkAutolinkComputerFileLinks,
urlTransform,
sourceRange,
}) => (
<div data-openbitfun-component="markdown" data-openbitfun-part="math">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkAutolinkComputerFileLinks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], [rehypeSourceRange, sourceRange], rehypeKatex]}
urlTransform={urlTransform}
components={components}
>
Expand Down
18 changes: 18 additions & 0 deletions src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ describe('Markdown file links', () => {
expect(mocks.getCurrentWorkspacePath).not.toHaveBeenCalled();
});

it('renders separate footnote regions and follows their shared anchors', async () => {
const reference = 'Text[^a].';
const definition = '[^a]: Definition';
const content = `${reference}\n\n${definition}`;
await act(async () => root.render(<>
<MarkdownRenderer content={content} sourceRange={{ start: 0, end: reference.length, idPrefix: 'test-editor-' }} />
<MarkdownRenderer content={content} sourceRange={{ start: reference.length + 2, end: content.length, idPrefix: 'test-editor-' }} />
</>));
const link = container.querySelector<HTMLAnchorElement>('[data-footnote-ref]')!;
const target = document.getElementById(link.hash.slice(1))!;
const scrollIntoView = vi.fn();
target.scrollIntoView = scrollIntoView;
expect(container.querySelectorAll('section[data-footnotes] li')).toHaveLength(1);
expect(target.textContent).toContain('Definition');
act(() => link.click());
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
});

it.each([
'[Open Canvas](openbitfun-canvas://session/session_1/canvas/canvas_1)',
'openbitfun-canvas://session/session_1/canvas/canvas_1',
Expand Down
16 changes: 14 additions & 2 deletions src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from '@/shared/utils/startupTrace';
import path from 'path-browserify';
import './Markdown.scss';
import { rehypeSourceRange, type MarkdownSourceRange } from './rehypeSourceRange';

const log = createLogger('Markdown');
const COMPUTER_LINK_PREFIX = 'computer://';
Expand Down Expand Up @@ -823,6 +824,8 @@ const CopyButton: React.FC<{ code: string }> = ({ code }) => {

export interface MarkdownRendererProps {
content: string;
/** Display a region while resolving Markdown references against all content. */
sourceRange?: MarkdownSourceRange;
basePath?: string;
remoteConnectionId?: string;
remoteSshHost?: string;
Expand All @@ -844,6 +847,7 @@ function useLiveValueRef<T>(value: T): React.MutableRefObject<T> {

export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
content,
sourceRange,
basePath,
remoteConnectionId,
remoteSshHost,
Expand Down Expand Up @@ -874,6 +878,7 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
const onTabOpenRef = useLiveValueRef(onTabOpen);
const onHttpLinkClickRef = useLiveValueRef(onHttpLinkClick);
const traceContextRef = useLiveValueRef(traceContext);
const sourceRangeRef = useLiveValueRef(sourceRange);

const syntaxTheme = useMemo(() => buildMarkdownPrismStyle(isLight), [isLight]);
const syntaxThemeRef = useLiveValueRef(syntaxTheme);
Expand Down Expand Up @@ -1512,6 +1517,11 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
{...props}
onClick={(e) => {
e.preventDefault();
if (isHashLink && sourceRangeRef.current) {
const target = document.getElementById(hrefValue.slice(1));
target?.scrollIntoView({ block: 'nearest' });
target?.focus({ preventScroll: true });
}
}}
style={{ cursor: 'pointer' }}
>
Expand Down Expand Up @@ -1592,13 +1602,14 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
remoteSshHostRef,
syntaxThemeRef,
traceContextRef,
sourceRangeRef,
]);

const wrapperClassName = `markdown-renderer ${className}`.trim();
const basicMarkdownRenderer = (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkAutolinkInternalLinks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], [rehypeSourceRange, sourceRange]]}
urlTransform={markdownUrlTransform}
components={components}
>
Expand All @@ -1618,7 +1629,7 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
traceContext={traceContext}
/>
)}
<MarkdownErrorBoundary fallbackContent={markdownContent}>
<MarkdownErrorBoundary fallbackContent={sourceRange ? markdownContent.slice(sourceRange.start, sourceRange.end) : markdownContent}>
{shouldUseMathRenderer ? (
<React.Suspense fallback={basicMarkdownRenderer}>
<MarkdownMathRenderer
Expand All @@ -1627,6 +1638,7 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
sanitizeSchema={sanitizeSchema}
remarkAutolinkComputerFileLinks={remarkAutolinkInternalLinks}
urlTransform={markdownUrlTransform}
sourceRange={sourceRange}
/>
</React.Suspense>
) : basicMarkdownRenderer}
Expand Down
52 changes: 52 additions & 0 deletions src/web-ui/src/infrastructure/markdown/rehypeSourceRange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeSanitize from 'rehype-sanitize';
import rehypeRaw from 'rehype-raw';
import { rehypeSourceRange } from './rehypeSourceRange';

function renderRegions(regions: string[], idPrefix = 'editor-1-') {
const content = regions.join('\n\n');
let start = 0;
return regions.map(region => {
const processor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype)
.use(rehypeRaw).use(rehypeSanitize).use(rehypeSourceRange, { start, end: start + region.length, idPrefix });
start += region.length + 2;
return processor.runSync(processor.parse(content));
});
}

function elements(tree: any, predicate: (node: any) => boolean): any[] {
return [ ...(predicate(tree) ? [tree] : []), ...(tree.children ?? []).flatMap((node: any) => elements(node, predicate)) ];
}

describe('Markdown source region rendering', () => {
it('shares numbering and matching anchors without duplicating footnote bodies', () => {
const regions = ['First[^b].', 'Second[^a] and again[^b].', '[^a]: Alpha', '[^b]: Beta'];
const trees = renderRegions(regions);
const refs = trees.flatMap(tree => elements(tree, node => node.properties?.dataFootnoteRef !== undefined));
expect(refs.map(node => node.children[0].value)).toEqual(['1', '2', '1']);
const definitions = trees.flatMap(tree => elements(tree, node => node.tagName === 'li'));
expect(definitions).toHaveLength(2);
expect(definitions.map(node => node.properties.value)).toEqual([2, 1]);
const allIds = trees.flatMap(tree => elements(tree, node => typeof node.properties?.id === 'string'))
.map(node => node.properties.id);
expect(new Set(allIds).size).toBe(allIds.length);
const links = trees.flatMap(tree => elements(tree, node => node.tagName === 'a'));
links.forEach(node => expect(allIds).toContain(node.properties.href.slice(1)));
expect(elements(trees[0], node => node.tagName === 'section')).toHaveLength(0);
expect(JSON.stringify(trees[2])).not.toContain('Beta');
expect(JSON.stringify(trees[3])).not.toContain('Alpha');
const otherIds = renderRegions(regions, 'editor-2-').flatMap(tree =>
elements(tree, node => typeof node.properties?.id === 'string')).map(node => node.properties.id);
expect(otherIds.some(id => allIds.includes(id))).toBe(false);
});

it('resolves reference links and images from definitions outside the region', () => {
const [tree] = renderRegions(['[Guide][g] ![Image][img]', '[g]: https://example.com', '[img]: photo.png']);
expect(elements(tree, node => node.tagName === 'a')[0].properties.href).toBe('https://example.com');
expect(elements(tree, node => node.tagName === 'img')[0].properties.src).toBe('photo.png');
});
});
68 changes: 68 additions & 0 deletions src/web-ui/src/infrastructure/markdown/rehypeSourceRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/** Render one source region after resolving references against the whole document. */
export interface MarkdownSourceRange {
start: number;
end: number;
idPrefix: string;
}

type HtmlNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
position?: { start: { offset?: number }; end: { offset?: number } };
children?: HtmlNode[];
};

export function rehypeSourceRange(range?: MarkdownSourceRange) {
return (tree: HtmlNode) => {
if (!range) return;
const inside = (node: HtmlNode) => {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
return start !== undefined && end !== undefined && start >= range.start && end <= range.end;
};
const walk = (node: HtmlNode, visit: (node: HtmlNode) => void) => {
visit(node);
node.children?.forEach(child => walk(child, visit));
};

// Namespace all anchors consistently across independent render roots. Run
// after sanitization and account for its user-content- clobber protection.
const ids = new Map<string, string>();
walk(tree, node => {
const id = node.properties?.id;
if (typeof id === 'string') ids.set(id, `${range.idPrefix}${id}`);
});
walk(tree, node => {
const props = node.properties;
if (!props) return;
if (typeof props.id === 'string') props.id = ids.get(props.id);
if (typeof props.href === 'string' && props.href.startsWith('#')) {
const id = props.href.slice(1);
const target = ids.get(id) ?? ids.get(`user-content-${id}`);
if (target) props.href = `#${target}`;
}
if (Array.isArray(props.ariaDescribedBy)) {
props.ariaDescribedBy = props.ariaDescribedBy.map(id =>
ids.get(String(id)) ?? ids.get(`user-content-${id}`) ?? id);
}
});

tree.children = (tree.children ?? []).flatMap(node => {
if (inside(node)) return [node];
if (node.tagName !== 'section' || node.properties?.dataFootnotes === undefined) return [];
const list = node.children?.find(child => child.tagName === 'ol');
const items = list?.children?.filter(child => child.tagName === 'li') ?? [];
const selected = items.filter(inside);
if (!list || selected.length === 0) return [];
selected.forEach(item => {
item.properties = { ...item.properties, value: items.indexOf(item) + 1 };
});
list.children = selected;
// Each definition is rendered once, with one shared heading and globally
// ordered numbers, even when definitions live in separate editor blocks.
if (!selected.includes(items[0])) node.children = [list];
return [node];
});
};
}
17 changes: 15 additions & 2 deletions src/web-ui/src/locales/en-US/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,29 @@
"switchToSideBySide": "Switch to side-by-side view"
},
"markdownEditor": {
"blockTypes": {
"math": "Equation",
"code": "Code block",
"reference": "Link reference",
"footnote": "Footnote"
},
"finishBlockEdit": "Done editing",
"editImage": "Edit image",
"imageAddress": "Image address",
"imageAltText": "Alternative text",
"imageTitle": "Image title",
"richText": "Document",
"editBlockSource": "Edit block source",
"loadingFile": "Loading file...",
"placeholder": "Start writing Markdown...",
"source": "Source",
"markdown": "Markdown",
"preview": "Preview",
"copyMarkdown": "Copy Markdown",
"copiedMarkdown": "Copied Markdown",
"viewModeLabel": "Markdown and preview mode",
"viewModeLabel": "Document and source mode",
"notice": {
"sourcePreviewFallback": "This document contains syntax that is not safely editable in visual mode. Edit in source mode or switch to preview."
"sourcePreviewFallback": "Rich text could not load. You can continue editing the Markdown source."
}
},
"planViewer": {
Expand Down
17 changes: 15 additions & 2 deletions src/web-ui/src/locales/zh-CN/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,29 @@
"switchToSideBySide": "切换到并排视图"
},
"markdownEditor": {
"blockTypes": {
"math": "公式",
"code": "代码块",
"reference": "链接引用",
"footnote": "脚注"
},
"finishBlockEdit": "完成编辑",
"editImage": "编辑图片",
"imageAddress": "图片地址",
"imageAltText": "替代文本",
"imageTitle": "图片标题",
"richText": "文档",
"editBlockSource": "编辑此块源码",
"loadingFile": "正在加载文件...",
"placeholder": "开始编写 Markdown 内容...",
"source": "源码",
"markdown": "Markdown",
"preview": "预览",
"copyMarkdown": "复制 Markdown",
"copiedMarkdown": "已复制 Markdown",
"viewModeLabel": "Markdown 与预览模式",
"viewModeLabel": "文档与源码模式",
"notice": {
"sourcePreviewFallback": "该文档包含无法在可视化模式中安全编辑的语法。请在源码模式中编辑,或切换到预览模式查看。"
"sourcePreviewFallback": "富文本加载失败,你可以继续编辑 Markdown 源码。"
}
},
"planViewer": {
Expand Down
17 changes: 15 additions & 2 deletions src/web-ui/src/locales/zh-TW/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,29 @@
"switchToSideBySide": "切換到並排視圖"
},
"markdownEditor": {
"blockTypes": {
"math": "公式",
"code": "程式碼區塊",
"reference": "連結參照",
"footnote": "註腳"
},
"finishBlockEdit": "完成編輯",
"editImage": "編輯圖片",
"imageAddress": "圖片位址",
"imageAltText": "替代文字",
"imageTitle": "圖片標題",
"richText": "文件",
"editBlockSource": "編輯此區塊原始碼",
"loadingFile": "正在載入檔案...",
"placeholder": "開始編寫 Markdown 內容...",
"source": "源碼",
"markdown": "Markdown",
"preview": "預覽",
"copyMarkdown": "複製 Markdown",
"copiedMarkdown": "已複製 Markdown",
"viewModeLabel": "Markdown 與預覽模式",
"viewModeLabel": "文件與源碼模式",
"notice": {
"sourcePreviewFallback": "該文檔包含無法在可視化模式中安全編輯的語法。請在源碼模式中編輯,或切換到預覽模式查看。"
"sourcePreviewFallback": "富文字載入失敗,你可以繼續編輯 Markdown 原始碼。"
}
},
"planViewer": {
Expand Down
Loading
Loading