-
Notifications
You must be signed in to change notification settings - Fork 2.7k
/
TsxEditor.tsx
187 lines (166 loc) · 7.38 KB
/
TsxEditor.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as monaco from '@uifabric/monaco-editor';
import { LanguageServiceDefaultsImpl as TypescriptDefaults } from '@uifabric/monaco-editor/monaco-typescript.d';
import { getWindow } from 'office-ui-fabric-react/lib/Utilities';
import { ITsxEditorProps } from './TsxEditor.types';
import { transpileAndEval } from '../transpiler/transpile';
import { IMonacoTextModel, ICompilerOptions, IPackageGroup } from '../interfaces/index';
import { Editor } from './Editor';
import { SUPPORTED_PACKAGES } from '../utilities/index';
import { IEditorProps } from './Editor.types';
const typescript = monaco.languages.typescript;
const typescriptDefaults = typescript.typescriptDefaults as TypescriptDefaults;
const filePrefix = 'file:///';
const filename = filePrefix + 'main.tsx';
/**
* Wrapper for rendering a Monaco instance and also transpiling/eval-ing the React example code inside.
*/
export const TsxEditor: React.FunctionComponent<ITsxEditorProps> = (props: ITsxEditorProps) => {
const { editorProps, onTransformFinished, compilerOptions, supportedPackages = SUPPORTED_PACKAGES } = props;
// Hooks must be called unconditionally, so we have to create a backup ref here even if we
// immediately throw it away to use the one passed in.
const backupModelRef = React.useRef<IMonacoTextModel>();
const modelRef = editorProps.modelRef || backupModelRef;
// Load the globals before loading the editor (otherwise there will be an error executing the
// example code because the globals it depends on aren't defined)
const hasLoadedGlobals = _useGlobals(supportedPackages);
// Set up compiler options
_useCompilerOptions(compilerOptions);
// Set up type checking after globals are loaded
const hasLoadedTypes = _useTypes(supportedPackages, hasLoadedGlobals);
// Store the latest onChange in a ref to ensure that we get the latest values
// without forcing re-rendering
const onChangeRef = React.useRef<IEditorProps['onChange']>();
onChangeRef.current = (text: string) => {
if (editorProps.onChange) {
// If the consumer provided an additional onChange, call that too
editorProps.onChange(text);
}
transpileAndEval(modelRef.current!, supportedPackages).then(onTransformFinished);
};
// After type checking and globals are set up, call onChange to transpile
React.useEffect(() => {
if (hasLoadedTypes && modelRef.current) {
onChangeRef.current!(modelRef.current.getValue());
}
}, [onChangeRef, hasLoadedTypes, modelRef.current]);
return (
<Editor
{...editorProps}
filename={filename}
modelRef={modelRef}
// Don't track changes until types have loaded
onChange={hasLoadedTypes ? onChangeRef.current : undefined}
/>
);
};
function _useGlobals(supportedPackages: IPackageGroup[]): boolean {
const [hasLoadedGlobals, setHasLoadedGlobals] = React.useState<boolean>(false);
React.useEffect(() => {
setHasLoadedGlobals(false);
const win = getWindow() as Window & { [key: string]: any }; // tslint:disable-line:no-any
if (!win.React) {
win.React = React;
}
if (!win.ReactDOM) {
win.ReactDOM = ReactDOM;
}
Promise.all(
supportedPackages.map(group => {
if (!win[group.globalName]) {
// tslint:disable-next-line:no-any
return group.loadGlobal().then((globalModule: any) => (win[group.globalName] = globalModule));
}
})
).then(() => setHasLoadedGlobals(true));
}, [supportedPackages]);
return hasLoadedGlobals;
}
function _useCompilerOptions(compilerOptions: ICompilerOptions | undefined): void {
React.useEffect(() => {
const oldCompilerOptions = typescriptDefaults.getCompilerOptions();
typescriptDefaults.setCompilerOptions({
// The compiler options used here generally should *not* be strict, to make quick edits easier
experimentalDecorators: true,
preserveConstEnums: true,
// implicit global `this` usage is almost always a bug
noImplicitThis: true,
// Mix in provided options
...compilerOptions,
// These options are essential to making the transform/eval and types code work (no overriding)
allowNonTsExtensions: true,
target: typescript.ScriptTarget.ES2015,
jsx: typescript.JsxEmit.React,
module: typescript.ModuleKind.ESNext,
baseUrl: filePrefix,
// This is updated after types are loaded, so preserve the old setting
paths: oldCompilerOptions.paths
});
}, [compilerOptions]);
}
function _useTypes(supportedPackages: IPackageGroup[], isReady: boolean) {
const [hasLoadedTypes, setHasLoadedTypes] = React.useState<boolean>(false);
React.useEffect(() => {
if (!isReady) {
return;
}
// Initially disable type checking
typescriptDefaults.setDiagnosticsOptions({ noSemanticValidation: true });
// Load types and then turn on full type checking
_loadTypes(supportedPackages).then(() => {
typescriptDefaults.setDiagnosticsOptions({ noSemanticValidation: false });
setHasLoadedTypes(true);
});
}, [supportedPackages, isReady]);
return hasLoadedTypes;
}
/**
* Load types for React and any other packages.
*/
function _loadTypes(supportedPackages: IPackageGroup[]): Promise<void> {
const promises: Promise<void>[] = [];
const typesPrefix = filePrefix + 'node_modules/@types';
// React must be loaded first
promises.push(
// @ts-ignore: this import is handled by webpack
import('!raw-loader!@types/react/index.d.ts')
// raw-loader 0.x exports a single string, and later versions export a default.
// The package.json specifies 0.x, but handle either just in case.
.then((result: string | { default: string }) => {
typescriptDefaults.addExtraLib(typeof result === 'string' ? result : result.default, `${typesPrefix}/react/index.d.ts`);
})
);
// Load each package and add it to TS (and save path mappings to add to TS later)
const pathMappings: { [path: string]: string[] } = {};
for (const group of supportedPackages) {
for (const pkg of group.packages) {
const { packageName, loadTypes } = pkg;
// Get the pretend @types package name
// (for a scoped package like @uifabric/utilities, this will be uifabric__utilities)
const scopedMatch = packageName.match(/^@([^/]+)\/(.*)/);
const typesPackageName = scopedMatch ? `${scopedMatch[1]}__${scopedMatch[2]}` : packageName;
// Call the provided loader function
promises.push(
Promise.resolve(loadTypes()).then(contents => {
const indexPath = `${typesPrefix}/${typesPackageName}/index`;
// This makes TS automatically find typings for package-level imports
typescriptDefaults.addExtraLib(contents, `${indexPath}.d.ts`);
// But for deeper path imports, we likely need to map them back to the root index file
// (do still include '*' as a default in case the types include module paths--
// api-extractor rollups don't do this, but other packages' typings might)
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
pathMappings[packageName + '/lib/*'] = ['*', indexPath];
})
);
}
}
return Promise.all(promises).then(() => {
// Add the path mappings
typescriptDefaults.setCompilerOptions({
...typescriptDefaults.getCompilerOptions(),
paths: pathMappings
});
});
}
export default TsxEditor;