myst-to-react 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +48 -0
- package/src/admonitions.tsx +183 -0
- package/src/basic.tsx +217 -0
- package/src/cite.tsx +94 -0
- package/src/code.tsx +119 -0
- package/src/components/ClickPopover.tsx +56 -0
- package/src/components/CopyIcon.tsx +40 -0
- package/src/components/HoverPopover.tsx +60 -0
- package/src/components/LinkCard.tsx +42 -0
- package/src/convertToReact.ts +33 -0
- package/src/crossReference.tsx +139 -0
- package/src/extensions/chemicalFormula.tsx +42 -0
- package/src/extensions/index.tsx +10 -0
- package/src/extensions/siunits.tsx +15 -0
- package/src/footnotes.tsx +30 -0
- package/src/heading.tsx +68 -0
- package/src/iframe.tsx +42 -0
- package/src/image.tsx +72 -0
- package/src/index.tsx +59 -0
- package/src/inlineError.tsx +15 -0
- package/src/links/index.tsx +132 -0
- package/src/links/rrid.tsx +81 -0
- package/src/links/wiki.tsx +119 -0
- package/src/math.tsx +81 -0
- package/src/mermaid.tsx +49 -0
- package/src/myst.tsx +185 -0
- package/src/output/components.tsx +34 -0
- package/src/output/error.tsx +20 -0
- package/src/output/hooks.ts +127 -0
- package/src/output/index.tsx +7 -0
- package/src/output/jupyter.tsx +86 -0
- package/src/output/output.tsx +79 -0
- package/src/output/outputBlock.tsx +21 -0
- package/src/output/safe.tsx +84 -0
- package/src/output/selectors.ts +15 -0
- package/src/output/stream.tsx +18 -0
- package/src/reactive.tsx +64 -0
- package/src/tabs.tsx +58 -0
- package/src/types.ts +6 -0
package/src/myst.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useParse } from '.';
|
|
2
|
+
import { VFile } from 'vfile';
|
|
3
|
+
import type { LatexResult } from 'myst-to-tex'; // Only import the type!!
|
|
4
|
+
import type { VFileMessage } from 'vfile-message';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import type { References } from '@curvenote/site-common';
|
|
7
|
+
import type { PageFrontmatter } from 'myst-frontmatter';
|
|
8
|
+
import type { NodeRenderer } from './types';
|
|
9
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
10
|
+
import classnames from 'classnames';
|
|
11
|
+
import ExclamationIcon from '@heroicons/react/outline/ExclamationIcon';
|
|
12
|
+
import ExclamationCircleIcon from '@heroicons/react/outline/ExclamationCircleIcon';
|
|
13
|
+
import InformationCircleIcon from '@heroicons/react/outline/InformationCircleIcon';
|
|
14
|
+
import { CopyIcon } from './components/CopyIcon';
|
|
15
|
+
import { CodeBlock } from './code';
|
|
16
|
+
import { ReferencesProvider } from '@curvenote/ui-providers';
|
|
17
|
+
|
|
18
|
+
async function parse(text: string, defaultFrontmatter?: PageFrontmatter) {
|
|
19
|
+
// Ensure that any imports from myst are async and scoped to this function
|
|
20
|
+
const { MyST, unified, visit } = await import('mystjs');
|
|
21
|
+
const {
|
|
22
|
+
mathPlugin,
|
|
23
|
+
footnotesPlugin,
|
|
24
|
+
keysPlugin,
|
|
25
|
+
basicTransformationsPlugin,
|
|
26
|
+
enumerateTargetsPlugin,
|
|
27
|
+
resolveReferencesPlugin,
|
|
28
|
+
WikiTransformer,
|
|
29
|
+
DOITransformer,
|
|
30
|
+
RRIDTransformer,
|
|
31
|
+
linksPlugin,
|
|
32
|
+
ReferenceState,
|
|
33
|
+
getFrontmatter,
|
|
34
|
+
} = await import('myst-transforms');
|
|
35
|
+
const { default: mystToTex } = await import('myst-to-tex');
|
|
36
|
+
const myst = new MyST();
|
|
37
|
+
const mdast = myst.parse(text);
|
|
38
|
+
const linkTransforms = [new WikiTransformer(), new DOITransformer(), new RRIDTransformer()];
|
|
39
|
+
// For the mdast that we show, duplicate, strip positions and dump to yaml
|
|
40
|
+
// Also run some of the transforms, like the links
|
|
41
|
+
const mdastPre = JSON.parse(JSON.stringify(mdast));
|
|
42
|
+
unified().use(linksPlugin, { transformers: linkTransforms }).runSync(mdastPre);
|
|
43
|
+
visit(mdastPre, (n) => delete n.position);
|
|
44
|
+
const mdastString = yaml.dump(mdastPre);
|
|
45
|
+
const htmlString = myst.renderMdast(mdastPre);
|
|
46
|
+
const file = new VFile();
|
|
47
|
+
const references = {
|
|
48
|
+
cite: { order: [], data: {} },
|
|
49
|
+
footnotes: {},
|
|
50
|
+
};
|
|
51
|
+
const { frontmatter } = getFrontmatter(mdast, { removeYaml: true, removeHeading: false });
|
|
52
|
+
const state = new ReferenceState({
|
|
53
|
+
numbering: frontmatter.numbering ?? defaultFrontmatter?.numbering,
|
|
54
|
+
file,
|
|
55
|
+
});
|
|
56
|
+
unified()
|
|
57
|
+
.use(basicTransformationsPlugin)
|
|
58
|
+
.use(mathPlugin, { macros: frontmatter?.math ?? {} }) // This must happen before enumeration, as it can add labels
|
|
59
|
+
.use(enumerateTargetsPlugin, { state })
|
|
60
|
+
.use(linksPlugin, { transformers: linkTransforms })
|
|
61
|
+
.use(footnotesPlugin, { references })
|
|
62
|
+
.use(resolveReferencesPlugin, { state })
|
|
63
|
+
.use(keysPlugin)
|
|
64
|
+
.runSync(mdast as any, file);
|
|
65
|
+
const tex = unified()
|
|
66
|
+
.use(mystToTex)
|
|
67
|
+
.stringify(mdast as any).result as LatexResult;
|
|
68
|
+
const content = useParse(mdast as any);
|
|
69
|
+
return {
|
|
70
|
+
yaml: mdastString,
|
|
71
|
+
references: { ...references, article: mdast } as References,
|
|
72
|
+
html: htmlString,
|
|
73
|
+
tex: tex.value,
|
|
74
|
+
content,
|
|
75
|
+
warnings: file.messages,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function MySTRenderer({ value, numbering }: { value: string; numbering: any }) {
|
|
80
|
+
const area = useRef<HTMLTextAreaElement | null>(null);
|
|
81
|
+
const [text, setText] = useState<string>(value.trim());
|
|
82
|
+
const [references, setReferences] = useState<References>({});
|
|
83
|
+
const [mdastYaml, setYaml] = useState<string>('Loading...');
|
|
84
|
+
const [html, setHtml] = useState<string>('Loading...');
|
|
85
|
+
const [tex, setTex] = useState<string>('Loading...');
|
|
86
|
+
const [warnings, setWarnings] = useState<VFileMessage[]>([]);
|
|
87
|
+
const [content, setContent] = useState<React.ReactNode>(<p>{value}</p>);
|
|
88
|
+
const [previewType, setPreviewType] = useState('DEMO');
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const ref = { current: true };
|
|
92
|
+
parse(text, { numbering }).then((result) => {
|
|
93
|
+
if (!ref.current) return;
|
|
94
|
+
setYaml(result.yaml);
|
|
95
|
+
setReferences(result.references);
|
|
96
|
+
setHtml(result.html);
|
|
97
|
+
setTex(result.tex);
|
|
98
|
+
setContent(result.content);
|
|
99
|
+
setWarnings(result.warnings);
|
|
100
|
+
});
|
|
101
|
+
return () => {
|
|
102
|
+
ref.current = false;
|
|
103
|
+
};
|
|
104
|
+
}, [text]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!area.current) return;
|
|
108
|
+
area.current.style.height = 'auto'; // for the scroll area in the next step!
|
|
109
|
+
area.current.style.height = `${area.current.scrollHeight}px`;
|
|
110
|
+
}, [text]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<figure className="relative shadow-lg rounded overflow-hidden">
|
|
114
|
+
<div className="absolute right-0 p-1">
|
|
115
|
+
<CopyIcon text={text} />
|
|
116
|
+
</div>
|
|
117
|
+
<div className="myst">
|
|
118
|
+
<label>
|
|
119
|
+
<span className="sr-only">Edit the MyST text</span>
|
|
120
|
+
<textarea
|
|
121
|
+
ref={area}
|
|
122
|
+
value={text}
|
|
123
|
+
className="block p-6 shadow-inner resize-none w-full font-mono bg-slate-50 dark:bg-slate-800 outline-none"
|
|
124
|
+
onChange={(e) => setText(e.target.value)}
|
|
125
|
+
></textarea>
|
|
126
|
+
</label>
|
|
127
|
+
</div>
|
|
128
|
+
{/* The `exclude-from-outline` class is excluded from the document outline */}
|
|
129
|
+
<div className="exclude-from-outline relative min-h-1 pt-[50px] px-6 pb-6 dark:bg-slate-900">
|
|
130
|
+
<div className="absolute cursor-pointer top-0 left-0 border dark:border-slate-600">
|
|
131
|
+
{['DEMO', 'AST', 'HTML', 'LaTeX'].map((show) => (
|
|
132
|
+
<button
|
|
133
|
+
key={show}
|
|
134
|
+
className={classnames('px-2', {
|
|
135
|
+
'bg-white hover:bg-slate-200 dark:bg-slate-500 dark:hover:bg-slate-700':
|
|
136
|
+
previewType !== show,
|
|
137
|
+
'bg-blue-800 text-white': previewType === show,
|
|
138
|
+
})}
|
|
139
|
+
title={`Show the ${show}`}
|
|
140
|
+
aria-label={`Show the ${show}`}
|
|
141
|
+
aria-pressed={previewType === show ? 'true' : 'false'}
|
|
142
|
+
onClick={() => setPreviewType(show)}
|
|
143
|
+
>
|
|
144
|
+
{show}
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
{previewType === 'DEMO' && (
|
|
149
|
+
<ReferencesProvider references={references}>{content}</ReferencesProvider>
|
|
150
|
+
)}
|
|
151
|
+
{previewType === 'AST' && <CodeBlock lang="yaml" value={mdastYaml} showCopy={false} />}
|
|
152
|
+
{previewType === 'HTML' && <CodeBlock lang="xml" value={html} showCopy={false} />}
|
|
153
|
+
{previewType === 'LaTeX' && <CodeBlock lang="latex" value={tex} showCopy={false} />}
|
|
154
|
+
</div>
|
|
155
|
+
{previewType === 'DEMO' && warnings.length > 0 && (
|
|
156
|
+
<div>
|
|
157
|
+
{warnings.map((m) => (
|
|
158
|
+
<div
|
|
159
|
+
className={classnames('p-1 shadow-inner text-white not-prose', {
|
|
160
|
+
'bg-red-500 dark:bg-red-800': m.fatal === true,
|
|
161
|
+
'bg-orange-500 dark:bg-orange-700': m.fatal === false,
|
|
162
|
+
'bg-slate-500 dark:bg-slate-800': m.fatal === null,
|
|
163
|
+
})}
|
|
164
|
+
>
|
|
165
|
+
{m.fatal === true && <ExclamationCircleIcon className="inline h-[1.3em] mr-1" />}
|
|
166
|
+
{m.fatal === false && <ExclamationIcon className="inline h-[1.3em] mr-1" />}
|
|
167
|
+
{m.fatal === null && <InformationCircleIcon className="inline h-[1.3em] mr-1" />}
|
|
168
|
+
<code>{m.ruleId || m.source}</code>: {m.message}
|
|
169
|
+
</div>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</figure>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const MystNodeRenderer: NodeRenderer = (node) => {
|
|
178
|
+
return <MySTRenderer key={node.key} value={node.value} numbering={node.numbering} />;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const MYST_RENDERERS = {
|
|
182
|
+
myst: MystNodeRenderer,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export default MYST_RENDERERS;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useLongContent } from './hooks';
|
|
3
|
+
|
|
4
|
+
export const MaybeLongContent = ({
|
|
5
|
+
content,
|
|
6
|
+
path,
|
|
7
|
+
render,
|
|
8
|
+
}: {
|
|
9
|
+
content?: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
render: (content: string) => JSX.Element;
|
|
12
|
+
}) => {
|
|
13
|
+
const { error, data } = useLongContent(content, path);
|
|
14
|
+
if (error) {
|
|
15
|
+
return <div className="text-red-500">Error loading content: {error.message}</div>;
|
|
16
|
+
}
|
|
17
|
+
if (!data) {
|
|
18
|
+
return <div>Fetching long content....</div>;
|
|
19
|
+
}
|
|
20
|
+
return <div>{render(data.content)}</div>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const DangerousHTML = ({ content, ...rest }: { content: string }) => {
|
|
24
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!content || !ref.current) return;
|
|
28
|
+
const el = document.createRange().createContextualFragment(content);
|
|
29
|
+
ref.current.innerHTML = '';
|
|
30
|
+
ref.current.appendChild(el);
|
|
31
|
+
}, [content, ref]);
|
|
32
|
+
|
|
33
|
+
return <div {...rest} ref={ref} />;
|
|
34
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Ansi from 'ansi-to-react';
|
|
2
|
+
import { ensureString } from '@curvenote/blocks';
|
|
3
|
+
import type { MinifiedErrorOutput } from '@curvenote/nbtx';
|
|
4
|
+
import { MaybeLongContent } from './components';
|
|
5
|
+
|
|
6
|
+
export default function Error({ output }: { output: MinifiedErrorOutput }) {
|
|
7
|
+
return (
|
|
8
|
+
<MaybeLongContent
|
|
9
|
+
content={ensureString(output.traceback)}
|
|
10
|
+
path={output.path}
|
|
11
|
+
render={(content?: string) => {
|
|
12
|
+
return (
|
|
13
|
+
<pre className="text-sm font-thin font-system jupyter-error">
|
|
14
|
+
<Ansi>{content ?? ''}</Ansi>
|
|
15
|
+
</pre>
|
|
16
|
+
);
|
|
17
|
+
}}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import useSWRImmutable from 'swr/immutable';
|
|
2
|
+
import type {
|
|
3
|
+
MinifiedErrorOutput,
|
|
4
|
+
MinifiedMimeBundle,
|
|
5
|
+
MinifiedMimePayload,
|
|
6
|
+
MinifiedOutput,
|
|
7
|
+
MinifiedStreamOutput,
|
|
8
|
+
} from '@curvenote/nbtx/dist/minify/types';
|
|
9
|
+
import { walkPaths } from '@curvenote/nbtx/dist/minify/utils';
|
|
10
|
+
import { useState, useLayoutEffect } from 'react';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Truncation vs Summarization
|
|
14
|
+
*
|
|
15
|
+
* In Curvespace, we're decided to change our data structure for outputs to align it as
|
|
16
|
+
* closely as possible with Jupyters nbformat.IOutput[] type, but in a way that still allows
|
|
17
|
+
* us to truncate output content and push that to storage.
|
|
18
|
+
*
|
|
19
|
+
* This will be used only in the CLI and Curvespace initially but should be ported back to
|
|
20
|
+
* the rest of the code base. This will mean
|
|
21
|
+
*
|
|
22
|
+
* - changing the DB schema
|
|
23
|
+
* - migration
|
|
24
|
+
* - changing API response
|
|
25
|
+
* - changing the frontend
|
|
26
|
+
* - changing the extension
|
|
27
|
+
*
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
interface LongContent {
|
|
31
|
+
content_type?: string;
|
|
32
|
+
content: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const fetcher = (...args: Parameters<typeof fetch>) =>
|
|
36
|
+
fetch(...args).then((res) => {
|
|
37
|
+
if (res.status === 200) return res.json();
|
|
38
|
+
throw new Error(`Content returned with status ${res.status}.`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function useLongContent(
|
|
42
|
+
content?: string,
|
|
43
|
+
url?: string,
|
|
44
|
+
): { data?: LongContent; error?: Error } {
|
|
45
|
+
if (typeof document === 'undefined') {
|
|
46
|
+
// This is ONLY called on the server
|
|
47
|
+
return url ? {} : { data: { content: content ?? '' } };
|
|
48
|
+
}
|
|
49
|
+
const { data, error } = useSWRImmutable<LongContent>(url || null, fetcher);
|
|
50
|
+
if (!url) return { data: { content: content ?? '' } };
|
|
51
|
+
return { data, error };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const arrayFetcher = (...urls: string[]) => {
|
|
55
|
+
return Promise.all(urls.map((url) => fetcher(url)));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type ObjectWithPath = MinifiedErrorOutput | MinifiedStreamOutput | MinifiedMimePayload;
|
|
59
|
+
|
|
60
|
+
function shallowCloneOutputs(outputs: MinifiedOutput[]) {
|
|
61
|
+
return outputs.map((output) => {
|
|
62
|
+
if ('data' in output && output.data) {
|
|
63
|
+
const data = output.data as MinifiedMimeBundle;
|
|
64
|
+
return {
|
|
65
|
+
...output,
|
|
66
|
+
data: Object.entries(data).reduce((acc, [mimetype, payload]) => {
|
|
67
|
+
return { ...acc, [mimetype]: { ...payload } };
|
|
68
|
+
}, {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ...output };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useFetchAnyTruncatedContent(outputs: MinifiedOutput[]) {
|
|
76
|
+
const itemsWithPaths: ObjectWithPath[] = [];
|
|
77
|
+
const updated = shallowCloneOutputs(outputs);
|
|
78
|
+
|
|
79
|
+
walkPaths(updated, (path, obj) => {
|
|
80
|
+
// images have paths, but we don't need to fetch them
|
|
81
|
+
if ('content_type' in obj && (obj as MinifiedMimePayload).content_type.startsWith('image/'))
|
|
82
|
+
return;
|
|
83
|
+
obj.path = path;
|
|
84
|
+
itemsWithPaths.push(obj);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { data, error } = useSWRImmutable<LongContent[]>(
|
|
88
|
+
itemsWithPaths.map(({ path }) => path),
|
|
89
|
+
arrayFetcher,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
data?.forEach(({ content }, idx) => {
|
|
93
|
+
const obj = itemsWithPaths[idx];
|
|
94
|
+
if ('text' in obj) obj.text = content; // stream
|
|
95
|
+
if ('traceback' in obj) obj.traceback = content; // error
|
|
96
|
+
if ('content' in obj) obj.content = content; // mimeoutput
|
|
97
|
+
obj.path = undefined;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
data: itemsWithPaths.length === 0 || data ? updated : undefined,
|
|
102
|
+
error,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getWindowSize() {
|
|
107
|
+
const { innerWidth: width, innerHeight: height } = window;
|
|
108
|
+
return {
|
|
109
|
+
width,
|
|
110
|
+
height,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default function useWindowSize() {
|
|
115
|
+
const [windowSize, setWindowSize] = useState(getWindowSize());
|
|
116
|
+
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
function handleResize() {
|
|
119
|
+
setWindowSize(getWindowSize());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
window.addEventListener('resize', handleResize);
|
|
123
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
return windowSize;
|
|
127
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import useWindowSize, { useFetchAnyTruncatedContent } from './hooks';
|
|
3
|
+
import { nanoid } from 'nanoid';
|
|
4
|
+
import { useSelector } from 'react-redux';
|
|
5
|
+
import { host, actions } from '@curvenote/connect';
|
|
6
|
+
import type { MinifiedOutput } from '@curvenote/nbtx';
|
|
7
|
+
import { convertToIOutputs, fetchAndEncodeOutputImages } from '@curvenote/nbtx';
|
|
8
|
+
import { ChevronDoubleDownIcon } from '@heroicons/react/outline';
|
|
9
|
+
import type { State } from './selectors';
|
|
10
|
+
import { selectIFrameHeight, selectIFrameReady } from './selectors';
|
|
11
|
+
|
|
12
|
+
const PERCENT_OF_WINDOW = 0.9;
|
|
13
|
+
|
|
14
|
+
export const NativeJupyterOutputs = ({
|
|
15
|
+
id,
|
|
16
|
+
outputs,
|
|
17
|
+
}: {
|
|
18
|
+
id: string;
|
|
19
|
+
outputs: MinifiedOutput[];
|
|
20
|
+
}) => {
|
|
21
|
+
const windowSize = useWindowSize();
|
|
22
|
+
|
|
23
|
+
const { data, error } = useFetchAnyTruncatedContent(outputs);
|
|
24
|
+
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [frameHeight, setFrameHeight] = useState(0);
|
|
27
|
+
const [clamped, setClamped] = useState(false);
|
|
28
|
+
|
|
29
|
+
const uid = useMemo(nanoid, []);
|
|
30
|
+
|
|
31
|
+
const height = useSelector((state: State) => selectIFrameHeight(state, uid));
|
|
32
|
+
const rendererReady = useSelector((state: State) => selectIFrameReady(state, uid));
|
|
33
|
+
|
|
34
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (iframeRef.current == null || !rendererReady || !data) return;
|
|
38
|
+
fetchAndEncodeOutputImages(convertToIOutputs(data)).then((out) => {
|
|
39
|
+
host.commsDispatch(iframeRef.current, actions.connectHostSendContent(uid, out));
|
|
40
|
+
});
|
|
41
|
+
}, [id, iframeRef.current, rendererReady]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (height == null) return;
|
|
45
|
+
if (height > PERCENT_OF_WINDOW * windowSize.height) {
|
|
46
|
+
setFrameHeight(PERCENT_OF_WINDOW * windowSize.height);
|
|
47
|
+
setClamped(true);
|
|
48
|
+
} else {
|
|
49
|
+
setFrameHeight(height + 25);
|
|
50
|
+
setClamped(false);
|
|
51
|
+
}
|
|
52
|
+
setLoading(false);
|
|
53
|
+
}, [height]);
|
|
54
|
+
|
|
55
|
+
if (error) {
|
|
56
|
+
return <div className="text-red-500">Error rendering output: {error.message}</div>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div>
|
|
61
|
+
{loading && <div className="p-2.5">Loading...</div>}
|
|
62
|
+
<iframe
|
|
63
|
+
ref={iframeRef}
|
|
64
|
+
id={uid}
|
|
65
|
+
name={uid}
|
|
66
|
+
title={uid}
|
|
67
|
+
src="https://next.curvenote.run"
|
|
68
|
+
width={'100%'}
|
|
69
|
+
height={frameHeight}
|
|
70
|
+
sandbox="allow-scripts"
|
|
71
|
+
></iframe>
|
|
72
|
+
{clamped && (
|
|
73
|
+
<div
|
|
74
|
+
className="cursor-pointer p-1 pb-2 hover:bg-gray-50 dark:hover:bg-gray-700 text-center text-gray-500 hover:text-gray-600 dark:text-gray-200 dark:hover:text-gray-50"
|
|
75
|
+
title="Expand"
|
|
76
|
+
onClick={() => {
|
|
77
|
+
setFrameHeight(height ?? 0);
|
|
78
|
+
setClamped(false);
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<ChevronDoubleDownIcon className="w-5 h-5 inline"></ChevronDoubleDownIcon>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { GenericNode } from 'mystjs';
|
|
2
|
+
import { KnownCellOutputMimeTypes } from '@curvenote/blocks';
|
|
3
|
+
import type { MinifiedMimeOutput, MinifiedOutput } from '@curvenote/nbtx/dist/minify/types';
|
|
4
|
+
import classNames from 'classnames';
|
|
5
|
+
import { SafeOutputs } from './safe';
|
|
6
|
+
import { NativeJupyterOutputs as JupyterOutputs } from './jupyter';
|
|
7
|
+
import { OutputBlock } from './outputBlock';
|
|
8
|
+
|
|
9
|
+
const DIRECT_OUTPUT_TYPES = new Set(['stream', 'error']);
|
|
10
|
+
|
|
11
|
+
const DIRECT_MIME_TYPES = new Set([
|
|
12
|
+
KnownCellOutputMimeTypes.TextPlain,
|
|
13
|
+
KnownCellOutputMimeTypes.ImagePng,
|
|
14
|
+
KnownCellOutputMimeTypes.ImageGif,
|
|
15
|
+
KnownCellOutputMimeTypes.ImageJpeg,
|
|
16
|
+
KnownCellOutputMimeTypes.ImageBmp,
|
|
17
|
+
]) as Set<string>;
|
|
18
|
+
|
|
19
|
+
export function allOutputsAreSafe(
|
|
20
|
+
outputs: MinifiedOutput[],
|
|
21
|
+
directOutputTypes: Set<string>,
|
|
22
|
+
directMimeTypes: Set<string>,
|
|
23
|
+
) {
|
|
24
|
+
return outputs.reduce((flag, output) => {
|
|
25
|
+
if (directOutputTypes.has(output.output_type)) return true;
|
|
26
|
+
const data = (output as MinifiedMimeOutput).data;
|
|
27
|
+
const mimetypes = data ? Object.keys(data) : [];
|
|
28
|
+
const safe =
|
|
29
|
+
'data' in output &&
|
|
30
|
+
Boolean(output.data) &&
|
|
31
|
+
mimetypes.every((mimetype) => directMimeTypes.has(mimetype));
|
|
32
|
+
return flag && safe;
|
|
33
|
+
}, true);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listMimetypes(outputs: MinifiedOutput[], directOutputTypes: Set<string>) {
|
|
37
|
+
return outputs.map((output) => {
|
|
38
|
+
if (directOutputTypes.has(output.output_type)) return [output.output_type];
|
|
39
|
+
const data = (output as MinifiedMimeOutput).data;
|
|
40
|
+
const mimetypes = data ? Object.keys(data) : [];
|
|
41
|
+
return [output.output_type, mimetypes];
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function anyErrors(outputs: MinifiedOutput[]) {
|
|
46
|
+
return outputs.reduce((flag, output) => flag || output.output_type === 'error', false);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function Output(node: GenericNode) {
|
|
50
|
+
const outputs: MinifiedOutput[] = node.data;
|
|
51
|
+
const allSafe = allOutputsAreSafe(outputs, DIRECT_OUTPUT_TYPES, DIRECT_MIME_TYPES);
|
|
52
|
+
const hasError = anyErrors(outputs);
|
|
53
|
+
|
|
54
|
+
let component;
|
|
55
|
+
if (allSafe) {
|
|
56
|
+
component = <SafeOutputs keyStub={node.key} outputs={outputs} />;
|
|
57
|
+
} else {
|
|
58
|
+
// Hide the iframe if rendering on the server
|
|
59
|
+
component =
|
|
60
|
+
typeof document === 'undefined' ? null : <JupyterOutputs id={node.key} outputs={outputs} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<figure
|
|
65
|
+
suppressHydrationWarning={!allSafe}
|
|
66
|
+
key={node.key}
|
|
67
|
+
id={node.identifier || undefined}
|
|
68
|
+
className={classNames('max-w-full overflow-auto m-0', {
|
|
69
|
+
'text-left': !node.align || node.align === 'left',
|
|
70
|
+
'text-center': node.align === 'center',
|
|
71
|
+
'text-right': node.align === 'right',
|
|
72
|
+
})}
|
|
73
|
+
>
|
|
74
|
+
<OutputBlock allSafe={allSafe} hasError={hasError}>
|
|
75
|
+
{component}
|
|
76
|
+
</OutputBlock>
|
|
77
|
+
</figure>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
allSafe?: boolean;
|
|
6
|
+
hasError?: boolean;
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function OutputBlock(props: Props) {
|
|
11
|
+
const { children, allSafe, className } = props;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
suppressHydrationWarning={!allSafe}
|
|
16
|
+
className={classNames('relative group not-prose overflow-auto mb-4 pl-0.5', className)}
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { KnownCellOutputMimeTypes } from '@curvenote/blocks/dist/blocks/types/jupyter';
|
|
2
|
+
import type {
|
|
3
|
+
MinifiedMimeBundle,
|
|
4
|
+
MinifiedMimePayload,
|
|
5
|
+
MinifiedOutput,
|
|
6
|
+
} from '@curvenote/nbtx/dist/minify/types';
|
|
7
|
+
import Stream from './stream';
|
|
8
|
+
import Error from './error';
|
|
9
|
+
import Ansi from 'ansi-to-react';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* https://jupyterbook.org/content/code-outputs.html#render-priority
|
|
13
|
+
*
|
|
14
|
+
* nb_render_priority:
|
|
15
|
+
html:
|
|
16
|
+
- "application/vnd.jupyter.widget-view+json"
|
|
17
|
+
- "application/javascript"
|
|
18
|
+
- "text/html"
|
|
19
|
+
- "image/svg+xml"
|
|
20
|
+
- "image/png"
|
|
21
|
+
- "image/jpeg"
|
|
22
|
+
- "text/markdown"
|
|
23
|
+
- "text/latex"
|
|
24
|
+
- "text/plain"
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const RENDER_PRIORITY = [
|
|
28
|
+
KnownCellOutputMimeTypes.ImagePng,
|
|
29
|
+
KnownCellOutputMimeTypes.ImageJpeg,
|
|
30
|
+
KnownCellOutputMimeTypes.ImageGif,
|
|
31
|
+
KnownCellOutputMimeTypes.ImageBmp,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function findSafeMimeOutputs(output: MinifiedOutput): {
|
|
35
|
+
image?: MinifiedMimePayload;
|
|
36
|
+
text?: MinifiedMimePayload;
|
|
37
|
+
} {
|
|
38
|
+
const data: MinifiedMimeBundle = output.data as MinifiedMimeBundle;
|
|
39
|
+
const image = RENDER_PRIORITY.reduce((acc: MinifiedMimePayload | undefined, mimetype) => {
|
|
40
|
+
if (acc) return acc;
|
|
41
|
+
if (data && data[mimetype]) return data[mimetype];
|
|
42
|
+
return undefined;
|
|
43
|
+
}, undefined);
|
|
44
|
+
const text = data && data['text/plain'];
|
|
45
|
+
return { image, text };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function OutputImage({ image, text }: { image: MinifiedMimePayload; text?: MinifiedMimePayload }) {
|
|
49
|
+
return <img src={image?.path} alt={text?.content ?? 'Image produced in Jupyter'} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function SafeOutput({ output }: { output: MinifiedOutput }) {
|
|
53
|
+
switch (output.output_type) {
|
|
54
|
+
case 'stream':
|
|
55
|
+
return <Stream output={output} />;
|
|
56
|
+
case 'error':
|
|
57
|
+
return <Error output={output} />;
|
|
58
|
+
case 'display_data':
|
|
59
|
+
case 'execute_result':
|
|
60
|
+
case 'update_display_data': {
|
|
61
|
+
const { image, text } = findSafeMimeOutputs(output);
|
|
62
|
+
if (!image && !text) return null;
|
|
63
|
+
if (image) return <OutputImage image={image} text={text} />;
|
|
64
|
+
if (text)
|
|
65
|
+
return (
|
|
66
|
+
<div>
|
|
67
|
+
<Ansi>{text.content}</Ansi>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
console.warn(`Unknown output_type ${output['output_type']}`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function SafeOutputs({ keyStub, outputs }: { keyStub: string; outputs: MinifiedOutput[] }) {
|
|
79
|
+
// TODO better key - add keys during content creation?
|
|
80
|
+
const components = outputs.map((output, idx) => (
|
|
81
|
+
<SafeOutput key={`${keyStub}-${idx}`} output={output} />
|
|
82
|
+
));
|
|
83
|
+
return <>{components}</>;
|
|
84
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HostState } from '@curvenote/connect';
|
|
2
|
+
import { host } from '@curvenote/connect';
|
|
3
|
+
|
|
4
|
+
export interface State {
|
|
5
|
+
app: HostState;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const selectIFrameHeight = (state: State, id: string) =>
|
|
9
|
+
host.selectors.selectIFrameSize(state.app, id);
|
|
10
|
+
|
|
11
|
+
export const selectIFrameReady = (state: State, id: string) =>
|
|
12
|
+
host.selectors.selectIFrameReady(state.app, id);
|
|
13
|
+
|
|
14
|
+
export const selectIFrameSendFailed = (state: State, id: string) =>
|
|
15
|
+
host.selectors.selectIFrameFailed(state.app, id);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Ansi from 'ansi-to-react';
|
|
2
|
+
import { ensureString } from '@curvenote/blocks';
|
|
3
|
+
import type { MinifiedStreamOutput } from '@curvenote/nbtx';
|
|
4
|
+
import { MaybeLongContent } from './components';
|
|
5
|
+
|
|
6
|
+
export default function Stream({ output }: { output: MinifiedStreamOutput }) {
|
|
7
|
+
return (
|
|
8
|
+
<MaybeLongContent
|
|
9
|
+
content={ensureString(output.text)}
|
|
10
|
+
path={output.path}
|
|
11
|
+
render={(content?: string) => (
|
|
12
|
+
<pre className="text-sm font-thin font-system">
|
|
13
|
+
<Ansi>{content ?? ''}</Ansi>
|
|
14
|
+
</pre>
|
|
15
|
+
)}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|