jsreport-studio
Version:
jsreport templates editor and designer
452 lines (366 loc) • 13.6 kB
JavaScript
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { connect } from 'react-redux'
import ChromeTheme from 'monaco-themes/themes/Chrome DevTools.json'
import MonacoEditor from 'react-monaco-editor'
import debounce from 'lodash/debounce'
import { reformat } from '../../redux/editor/actions'
import reformatter from '../../helpers/reformatter'
import { getCurrentTheme } from '../../helpers/theme'
import LinterWorker from './workers/linter.worker'
import { textEditorInitializeListeners, textEditorCreatedListeners, subscribeToThemeChange, subscribeToSplitResize } from '../../lib/configuration.js'
let lastTextEditorMounted = {
timeoutId: null,
timestamp: null
}
class TextEditor extends Component {
static propTypes = {
value: PropTypes.string,
onUpdate: PropTypes.func.isRequired,
mode: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}
constructor (props) {
super(props)
this.lintWorker = null
this.oldCode = null
this.monacoRef = React.createRef()
this.getFocus = this.getFocus.bind(this)
this.setUpLintWorker = this.setUpLintWorker.bind(this)
this.lint = this.lint.bind(this)
this.lint = debounce(this.lint, 400)
this.editorWillMount = this.editorWillMount.bind(this)
this.editorDidMount = this.editorDidMount.bind(this)
this.calculateEditorLayout = this.calculateEditorLayout.bind(this)
this.state = {
editorTheme: getCurrentTheme().editorTheme
}
}
componentDidMount () {
this.unsubscribe = subscribeToSplitResize(this.calculateEditorLayout)
this.debouncedCalculateEditorLayout = debounce(this.calculateEditorLayout, 200)
window.addEventListener('resize', this.debouncedCalculateEditorLayout)
this.unsubscribeThemeChange = subscribeToThemeChange(({ newEditorTheme }) => {
this.setState({
editorTheme: newEditorTheme
})
})
}
componentWillUnmount () {
this.oldCode = null
this.unsubscribe()
window.removeEventListener('resize', this.debouncedCalculateEditorLayout)
this.unsubscribeThemeChange()
if (this.lintWorker) {
this.lintWorker.terminate()
}
}
editorWillMount (monaco) {
ChromeTheme.colors['editor.lineHighlightBackground'] = '#EDEDED'
// js updates
this.updateThemeRule(ChromeTheme, 'string', '1f19a6')
this.updateThemeRule(ChromeTheme, 'number', '1f19a6')
this.updateThemeRule(ChromeTheme, 'regexp', '1f19a6')
this.updateThemeRule(ChromeTheme, 'regexp.escape', '687587')
this.updateThemeRule(ChromeTheme, 'regexp.escape.control', '585CF6')
this.updateThemeRule(ChromeTheme, 'string.escape', '585CF6')
// html updates
this.updateThemeRule(ChromeTheme, 'tag.html', 'aa0d91')
this.updateThemeRule(ChromeTheme, 'delimiter.handlebars', 'aa0d91')
this.updateThemeRule(ChromeTheme, 'variable.parameter.handlebars', 'F6971F')
this.updateThemeRule(ChromeTheme, 'keyword.helper.handlebars', 'F6971F')
this.updateThemeRule(ChromeTheme, 'attribute.name', '994407')
this.updateThemeRule(ChromeTheme, 'attribute.value', '1f19a6')
// css updates
this.updateThemeRule(ChromeTheme, 'tag.css', '318495')
this.updateThemeRule(ChromeTheme, 'attribute.name.css', '6D78DE')
this.updateThemeRule(ChromeTheme, 'attribute.value.css', '27950C')
this.updateThemeRule(ChromeTheme, 'attribute.value.number.css', '2900CD')
this.updateThemeRule(ChromeTheme, 'attribute.value.unit.css', '920F80')
// json updates
this.updateThemeRule(ChromeTheme, 'string.key.json', '1f19a6')
this.updateThemeRule(ChromeTheme, 'string.value.json', '1f19a6')
textEditorInitializeListeners.forEach((fn) => {
fn({ monaco, theme: ChromeTheme })
})
monaco.editor.defineTheme('chrome', ChromeTheme)
}
editorDidMount (editor, monaco) {
monaco.languages.typescript.typescriptDefaults.setMaximumWorkerIdleTime(-1)
monaco.languages.typescript.javascriptDefaults.setMaximumWorkerIdleTime(-1)
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true)
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: true
})
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: true
})
// adding universal ctrl + y, cmd + y handler
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_Y, () => {
editor.trigger('jsreport-studio', 'redo')
})
// adding universal ctrl + shift + f, cmd + shift + f handler reformat key binding
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_F, () => {
editor.getAction('editor.action.formatDocument').run()
})
const self = this
const defaultFormattersPerLang = {
html: (value) => reformatter(value, 'html'),
handlebars: (value) => reformatter(value, 'html'),
javascript: (value) => reformatter(value, 'js'),
json: (value) => reformatter(value, 'js'),
css: (value) => reformatter(value, 'css')
}
// we override here the monaco "Format Document" option for all registered languages,
// what we do is that we replace the default format of monaco with the .reformat logic of the registered
// editorComponents of studio. so if user click "Format Document" option of monaco what will be executed is
// the .reformat logic of current active editorComponent, if there is no .reformat logic for the current editor then
// we check if the current language is a well known one (like css), if it is then we reformat using a default reformatter,
// if it isn't then we just do nothing
monaco.languages.getLanguages().map((l) => l.id).forEach((lang) => {
monaco.languages.registerDocumentFormattingEditProvider(lang, {
async provideDocumentFormattingEdits (model, options, token) {
let update
try {
const registeredReformatterExists = await self.props.reformat()
if (!registeredReformatterExists && defaultFormattersPerLang[lang]) {
update = {
range: model.getFullModelRange(),
text: defaultFormattersPerLang[lang](model.getValue())
}
}
} catch (e) {
console.error(`Error when reformatting language "${lang}"`, e)
}
if (update) {
return [update]
}
return []
}
})
})
// monkey path setValue option to make it preserve undo stack
// when editing text editor (by prop change)
editor.setValue = (newValue) => {
const model = editor.getModel()
if (newValue !== model.getValue()) {
model.pushEditOperations(
[],
[
{
range: model.getFullModelRange(),
text: newValue
}
]
)
}
}
// auto-size it
editor.layout()
window.requestAnimationFrame(() => {
this.setUpLintWorker(editor, monaco)
let autoCloseTimeout
// auto-close tag handling
editor.onDidChangeModelContent((e) => {
if (this.props.mode !== 'html' && this.props.mode !== 'handlebars') {
return
}
if (typeof autoCloseTimeout !== 'undefined') {
clearTimeout(autoCloseTimeout)
}
const changes = e.changes
const lastChange = changes[changes.length - 1]
const lastCharacter = lastChange.text[lastChange.text.length - 1]
if (lastChange.rangeLength > 0 || lastCharacter !== '>') {
return
}
autoCloseTimeout = setTimeout(() => {
const pos = editor.getPosition()
const textInLineAtCursor = editor.getModel().getLineContent(pos.lineNumber).slice(0, lastChange.range.endColumn)
let foundValidOpenTag = false
let extractIndex = 0
let tagName
let openTag
while (textInLineAtCursor.length !== Math.abs(extractIndex) && !foundValidOpenTag) {
extractIndex--
openTag = textInLineAtCursor.slice(extractIndex)
if (Math.abs(extractIndex) <= 2) {
continue
}
if (
openTag[0] === '/' ||
openTag[0] === '>' ||
openTag[0] === ' '
) {
break
}
if (openTag[0] === '<' && openTag[openTag.length - 1] === '>') {
tagName = openTag.slice(1, -1)
foundValidOpenTag = true
}
}
if (!foundValidOpenTag) {
return
}
const targetRange = new monaco.Range(
pos.lineNumber,
pos.column - openTag.length,
pos.lineNumber,
pos.column
)
const op = {
identifier: { major: 1, minor: 1 },
range: targetRange,
text: `${openTag}</${tagName}>`,
forceMoveMarkers: true
}
editor.executeEdits('auto-close-tag', [op])
editor.setPosition(pos)
autoCloseTimeout = undefined
}, 100)
})
editor.onDidChangeModelContent((e) => {
const newCode = editor.getModel().getValue()
const filename = typeof this.props.getFilename === 'function' ? this.props.getFilename() : ''
if (newCode !== this.oldCode) {
this.lint(newCode, filename, editor.getModel().getVersionId())
}
this.oldCode = newCode
})
})
this.oldCode = editor.getModel().getValue()
textEditorCreatedListeners.forEach((fn) => {
fn({ monaco, editor })
})
const nowTimestamp = Date.now()
const threshold = 350
const defer = 250
const initialFocus = this.props.preventInitialFocus !== true
// this logic calls get focus only if some time (threshold) has passed since the last
// time of an editor has been mounted, this prevents getting multiple active cursors when
// a lot of entities/tabs are loaded (like the playground case which opens multiple tabs at page load).
if (
(lastTextEditorMounted.timestamp == null ||
(nowTimestamp - lastTextEditorMounted.timestamp > threshold)) &&
initialFocus
) {
clearTimeout(lastTextEditorMounted.timeoutId)
lastTextEditorMounted.timeoutId = setTimeout(() => {
clearTimeout(lastTextEditorMounted.timeoutId)
lastTextEditorMounted.timeoutId = null
this.getFocus()
}, defer)
lastTextEditorMounted.timestamp = nowTimestamp
} else {
if (initialFocus && lastTextEditorMounted.timeoutId) {
clearTimeout(lastTextEditorMounted.timeoutId)
lastTextEditorMounted.timeoutId = setTimeout(() => {
clearTimeout(lastTextEditorMounted.timeoutId)
lastTextEditorMounted.timeoutId = null
this.getFocus()
}, defer)
lastTextEditorMounted.timestamp = nowTimestamp
}
}
}
getFocus () {
const self = this
if (!self.monacoRef.current) {
setTimeout(() => {
self.getFocus()
}, 150)
} else {
self.monacoRef.current.editor.focus()
}
}
updateThemeRule (theme, tokenName, foregroundColor) {
let r
r = theme.rules.find((i) => i.token === tokenName)
if (r) {
r.foreground = foregroundColor
} else {
theme.rules.push({
foreground: foregroundColor,
token: tokenName
})
}
}
setUpLintWorker (editor, monaco) {
if (this.lintWorker) {
return
}
this.lintWorker = new LinterWorker()
this.lintWorker.addEventListener('message', (event) => {
const { markers } = event.data
window.requestAnimationFrame(() => {
if (!editor.getModel()) {
return
}
const model = editor.getModel()
monaco.editor.setModelMarkers(model, 'eslint', markers)
})
})
// first lint
window.requestAnimationFrame(() => {
if (!editor.getModel()) {
return
}
const filename = typeof this.props.getFilename === 'function' ? this.props.getFilename() : ''
this.lint(
this.props.value,
filename,
editor.getModel().getVersionId()
)
})
}
calculateEditorLayout (ev) {
if (this.monacoRef.current && this.monacoRef.current.editor) {
this.monacoRef.current.editor.layout()
}
}
lint (code, filename, version) {
if (!this.lintWorker || this.props.mode !== 'javascript') {
return
}
this.lintWorker.postMessage({
filename,
code,
version
})
}
render () {
const { editorTheme } = this.state
const { value, onUpdate, name, mode } = this.props
const editorOptions = {
roundedSelection: false,
automaticLayout: false,
dragAndDrop: false,
lineNumbersMinChars: 4,
fontSize: 11.8,
minimap: {
enabled: false
}
}
return (
<MonacoEditor
name={name}
ref={this.monacoRef}
width='100%'
height='100%'
language={mode}
theme={editorTheme}
value={value || ''}
editorWillMount={this.editorWillMount}
editorDidMount={this.editorDidMount}
options={editorOptions}
onChange={(v) => onUpdate(v)}
/>
)
}
}
export default connect(undefined, {
reformat
}, undefined, { forwardRef: true })(TextEditor)