@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
269 lines (267 loc) • 8.52 kB
JavaScript
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useRef, useState } from 'react';
import { makeStyles } from 'tss-react/mui';
import { useMount } from '../../hooks/useMount';
import { useTheme } from '@mui/material/styles';
import clsx from 'clsx';
import { useEnhancedDialogContext } from '../EnhancedDialog/useEnhancedDialogContext';
const aceOptions = [
'selectionStyle',
'highlightActiveLine',
'highlightSelectedWord',
'readOnly',
'cursorStyle',
'mergeUndoDeltas',
'behavioursEnabled',
'wrapBehavioursEnabled',
'autoScrollEditorIntoView',
'copyWithEmptySelection',
'useSoftTabs',
'navigateWithinSoftTabs',
'enableMultiselect',
'hScrollBarAlwaysVisible',
'vScrollBarAlwaysVisible',
'highlightGutterLine',
'animatedScroll',
'showInvisibles',
'showPrintMargin',
'printMarginColumn',
'printMargin',
'fadeFoldWidgets',
'showFoldWidgets',
'showLineNumbers',
'showGutter',
'displayIndentGuides',
'fontSize',
'fontFamily',
'maxLines',
'minLines',
'scrollPastEnd',
'fixedWidthGutter',
'theme',
'scrollSpeed',
'dragDelay',
'dragEnabled',
'focusTimout',
'tooltipFollowsMouse',
'firstLineNumber',
'overwrite',
'newLineMode',
'useWorker',
'tabSize',
'wrap',
'foldStyle',
'mode',
'enableBasicAutocompletion',
'enableLiveAutocompletion',
'enableSnippets',
'enableEmmet',
'useElasticTabstops'
];
// const aceModes = [];
// const aceThemes = [];
const useStyles = makeStyles()((_theme, { root, editorRoot } = {}) => ({
root: {
position: 'relative',
display: 'contents',
...root
},
editorRoot: {
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: 0,
width: '100%',
height: '100%',
...editorRoot
}
}));
function AceEditorComp(props, ref) {
const {
value = '',
classes: propClasses,
autoFocus = false,
styles,
extensions = [],
onChange,
onInit,
...options
} = props;
const { classes, cx } = useStyles(styles);
const editorRootClasses = propClasses?.editorRoot;
const refs = useRef({
ace: null,
elem: null,
pre: null,
onChange: null,
options: null
});
const [initialized, setInitialized] = useState(false);
const {
palette: { mode }
} = useTheme();
options.theme = options.theme ?? `ace/theme/${mode === 'light' ? 'chrome' : 'tomorrow_night'}`;
refs.current.onChange = onChange;
refs.current.options = options;
useMount(() => {
let unmounted = false;
let initialized = false;
let aceEditor;
let deps = { ace: false, emmet: false, languageTools: false };
const init = () => {
deps.ace &&
deps.emmet &&
deps.languageTools &&
// @ts-ignore - Ace types are incorrect; the require function takes a callback
window.ace.require(['ace/ace', 'ace/ext/language_tools', 'ace/ext/emmet', ...extensions], (ace) => {
if (!unmounted) {
const pre = document.createElement('pre');
pre.className = cx(classes.editorRoot, editorRootClasses);
refs.current.pre = pre;
refs.current.elem.appendChild(pre);
// @ts-ignore - Ace types are incorrect; they don't implement the constructor that receives options.
aceEditor = ace.edit(pre, refs.current.options);
aceEditor.setOptions({
fontSize: '15px',
fontFamily: "'Monaco', 'Menlo', 'Consolas', 'Source Code Pro', 'source-code-pro', monospace"
});
autoFocus && aceEditor.focus();
if (refs.current.options.readOnly) {
// This setting of the cursor to not display is unnecessary as the
// options.readOnly effect takes care of doing so. Nevertheless, this
// eliminates the delay in hiding the cursor if left up to the effect only.
// @ts-ignore - $cursorLayer.element typings are missing
aceEditor.renderer.$cursorLayer.element.style.display = 'none';
}
refs.current.ace = aceEditor;
onInit?.(aceEditor);
if (ref) {
typeof ref === 'function' ? ref(aceEditor) : (ref.current = aceEditor);
}
setInitialized((initialized = true));
}
});
};
// TODO: Loading mechanisms very rudimentary. Must research better ways.
if (!window.ace) {
const aceScript = document.createElement('script');
aceScript.src = '/studio/static-assets/libs/ace/ace.js';
aceScript.onload = () => {
deps.ace = true;
// Language tools
const languageToolsScript = document.createElement('script');
languageToolsScript.src = '/studio/static-assets/libs/ace/ext-language_tools.js';
languageToolsScript.onload = () => {
deps.emmet = true;
init();
};
document.head.appendChild(languageToolsScript);
// Emmet
const emmetScript = document.createElement('script');
emmetScript.src = '/studio/static-assets/libs/ace/ext-emmet.js';
emmetScript.onload = () => {
deps.languageTools = true;
init();
};
document.head.appendChild(emmetScript);
};
document.head.appendChild(aceScript);
} else {
deps.ace = true;
deps.emmet = true;
deps.languageTools = true;
init();
}
return () => {
unmounted = true;
if (initialized) {
aceEditor.destroy();
}
};
});
useEffect(
() => {
if (initialized) {
refs.current.ace.setOptions(options);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
initialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
...aceOptions.map((o) => options[o])
]
);
useEffect(() => {
if (initialized) {
const editor = refs.current.ace;
editor.renderer.$cursorLayer.element.style.display = options?.readOnly ? 'none' : '';
}
}, [initialized, options?.readOnly]);
// If the Editor is inside a dialog, resize when fullscreen changes
const isFullScreen = useEnhancedDialogContext()?.isFullScreen;
useEffect(() => {
refs.current.ace?.resize();
}, [isFullScreen]);
useEffect(() => {
if (initialized) {
const ace = refs.current.ace;
const onChange = (e) => {
refs.current.onChange?.(e);
};
ace.setValue(value, -1);
ace.session.getUndoManager().reset();
ace.getSession().on('change', onChange);
return () => {
ace.getSession().off('change', onChange);
};
}
}, [initialized, value]);
useEffect(() => {
if (refs.current.pre) {
refs.current.pre.className = `${[...refs.current.pre.classList]
.filter((value) => !/craftercms-|makeStyles-/.test(value))
.join(' ')} ${clsx(classes?.editorRoot, editorRootClasses)}`;
}
}, [classes.editorRoot, editorRootClasses]);
return React.createElement('div', {
ref: (e) => {
if (e) {
refs.current.elem = e;
}
},
className: cx(classes.root, props.classes?.root)
});
}
export const AceEditor = React.forwardRef(AceEditorComp);
export default AceEditor;