2024-04-26 20:34:16 +00:00
|
|
|
import {Ace, loadAce} from 'app/client/lib/imports';
|
2023-04-11 05:00:28 +00:00
|
|
|
import {theme, vars} from 'app/client/ui2018/cssVars';
|
2024-04-26 20:34:16 +00:00
|
|
|
import {gristThemeObs} from 'app/client/ui2018/theme';
|
|
|
|
import {
|
|
|
|
BindableValue,
|
|
|
|
Disposable,
|
|
|
|
DomElementArg,
|
|
|
|
Observable,
|
|
|
|
styled,
|
|
|
|
subscribeElem,
|
|
|
|
} from 'grainjs';
|
|
|
|
|
|
|
|
interface BuildCodeHighlighterOptions {
|
2021-03-17 03:45:44 +00:00
|
|
|
maxLines?: number;
|
|
|
|
}
|
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
let _ace: Ace;
|
|
|
|
let _highlighter: any;
|
|
|
|
let _PythonMode: any;
|
|
|
|
let _aceDom: any;
|
|
|
|
let _chrome: any;
|
|
|
|
let _dracula: any;
|
|
|
|
let _mode: any;
|
|
|
|
|
|
|
|
async function fetchAceModules() {
|
|
|
|
return {
|
|
|
|
ace: _ace || (_ace = await loadAce()),
|
|
|
|
highlighter: _highlighter || (_highlighter = _ace.require('ace/ext/static_highlight')),
|
|
|
|
PythonMode: _PythonMode || (_PythonMode = _ace.require('ace/mode/python').Mode),
|
|
|
|
aceDom: _aceDom || (_aceDom = _ace.require('ace/lib/dom')),
|
|
|
|
chrome: _chrome || (_chrome = _ace.require('ace/theme/chrome')),
|
|
|
|
dracula: _dracula || (_dracula = _ace.require('ace/theme/dracula')),
|
|
|
|
mode: _mode || (_mode = new _PythonMode()),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a function that accepts a string of text representing code and returns
|
|
|
|
* a highlighted version of it as an HTML string.
|
|
|
|
*
|
|
|
|
* This is useful for scenarios where highlighted code needs to be displayed outside of
|
|
|
|
* grainjs. For example, when using `marked`'s `highlight` option to highlight code
|
|
|
|
* blocks in a Markdown string.
|
|
|
|
*/
|
|
|
|
export async function buildCodeHighlighter(options: BuildCodeHighlighterOptions = {}) {
|
|
|
|
const {maxLines} = options;
|
|
|
|
const {highlighter, aceDom, chrome, dracula, mode} = await fetchAceModules();
|
|
|
|
|
|
|
|
return (code: string) => {
|
|
|
|
if (maxLines) {
|
|
|
|
// If requested, trim to maxLines, and add an ellipsis at the end.
|
|
|
|
// (Long lines are also truncated with an ellpsis via text-overflow style.)
|
|
|
|
const lines = code.split(/\n/);
|
|
|
|
if (lines.length > maxLines) {
|
|
|
|
code = lines.slice(0, maxLines).join("\n") + " \u2026"; // Ellipsis
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let aceThemeName: 'chrome' | 'dracula';
|
|
|
|
let aceTheme: any;
|
|
|
|
if (gristThemeObs().get().appearance === 'dark') {
|
|
|
|
aceThemeName = 'dracula';
|
|
|
|
aceTheme = dracula;
|
|
|
|
} else {
|
|
|
|
aceThemeName = 'chrome';
|
|
|
|
aceTheme = chrome;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Rendering highlighted code gives you back the HTML to insert into the DOM, as well
|
|
|
|
// as the CSS styles needed to apply the theme. The latter typically isn't included in
|
|
|
|
// the document until an Ace editor is opened, so we explicitly import it here to avoid
|
|
|
|
// leaving highlighted code blocks without a theme applied.
|
|
|
|
const {html, css} = highlighter.render(code, mode, aceTheme, 1, true);
|
|
|
|
aceDom.importCssString(css, `${aceThemeName}-highlighted-code`);
|
|
|
|
return html;
|
|
|
|
};
|
|
|
|
}
|
2023-04-11 05:00:28 +00:00
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
interface BuildHighlightedCodeOptions extends BuildCodeHighlighterOptions {
|
|
|
|
placeholder?: string;
|
|
|
|
}
|
2021-03-17 03:45:44 +00:00
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
/**
|
|
|
|
* Builds a block of highlighted `code`.
|
|
|
|
*
|
|
|
|
* Highlighting applies an appropriate Ace theme (Chrome or Dracula) based on
|
|
|
|
* the current Grist theme, and automatically re-applies it whenever the Grist
|
|
|
|
* theme changes.
|
|
|
|
*/
|
|
|
|
export function buildHighlightedCode(
|
|
|
|
owner: Disposable,
|
|
|
|
code: BindableValue<string>,
|
|
|
|
options: BuildHighlightedCodeOptions,
|
|
|
|
...args: DomElementArg[]
|
|
|
|
): HTMLElement {
|
|
|
|
const {placeholder, maxLines} = options;
|
|
|
|
const codeText = Observable.create(owner, '');
|
|
|
|
const codeTheme = Observable.create(owner, gristThemeObs().get());
|
2023-04-11 05:00:28 +00:00
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
async function updateHighlightedCode(elem: HTMLElement) {
|
2023-04-11 05:00:28 +00:00
|
|
|
let text = codeText.get();
|
2023-07-13 14:00:56 +00:00
|
|
|
if (!text) {
|
|
|
|
elem.textContent = placeholder || '';
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-04-26 20:34:16 +00:00
|
|
|
const {highlighter, aceDom, chrome, dracula, mode} = await fetchAceModules();
|
|
|
|
if (owner.isDisposed()) { return; }
|
|
|
|
|
2023-07-13 14:00:56 +00:00
|
|
|
if (maxLines) {
|
|
|
|
// If requested, trim to maxLines, and add an ellipsis at the end.
|
|
|
|
// (Long lines are also truncated with an ellpsis via text-overflow style.)
|
|
|
|
const lines = text.split(/\n/);
|
|
|
|
if (lines.length > maxLines) {
|
|
|
|
text = lines.slice(0, maxLines).join("\n") + " \u2026"; // Ellipsis
|
2023-04-11 05:00:28 +00:00
|
|
|
}
|
2023-07-13 14:00:56 +00:00
|
|
|
}
|
2023-04-11 05:00:28 +00:00
|
|
|
|
2023-07-13 14:00:56 +00:00
|
|
|
let aceThemeName: 'chrome' | 'dracula';
|
|
|
|
let aceTheme: any;
|
2024-04-26 20:34:16 +00:00
|
|
|
if (codeTheme.get().appearance === 'dark') {
|
2023-07-13 14:00:56 +00:00
|
|
|
aceThemeName = 'dracula';
|
|
|
|
aceTheme = dracula;
|
2023-04-11 05:00:28 +00:00
|
|
|
} else {
|
2023-07-13 14:00:56 +00:00
|
|
|
aceThemeName = 'chrome';
|
|
|
|
aceTheme = chrome;
|
2023-04-11 05:00:28 +00:00
|
|
|
}
|
2023-07-13 14:00:56 +00:00
|
|
|
|
|
|
|
// Rendering highlighted code gives you back the HTML to insert into the DOM, as well
|
|
|
|
// as the CSS styles needed to apply the theme. The latter typically isn't included in
|
|
|
|
// the document until an Ace editor is opened, so we explicitly import it here to avoid
|
|
|
|
// leaving highlighted code blocks without a theme applied.
|
|
|
|
const {html, css} = highlighter.render(text, mode, aceTheme, 1, true);
|
|
|
|
elem.innerHTML = html;
|
|
|
|
aceDom.importCssString(css, `${aceThemeName}-highlighted-code`);
|
2023-04-11 05:00:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return cssHighlightedCode(
|
2024-04-26 20:34:16 +00:00
|
|
|
elem => subscribeElem(elem, code, async (newCodeText) => {
|
2023-04-11 05:00:28 +00:00
|
|
|
codeText.set(newCodeText);
|
2024-04-26 20:34:16 +00:00
|
|
|
await updateHighlightedCode(elem);
|
2021-03-17 03:45:44 +00:00
|
|
|
}),
|
2024-04-26 20:34:16 +00:00
|
|
|
elem => subscribeElem(elem, gristThemeObs(), async (newCodeTheme) => {
|
2023-04-11 05:00:28 +00:00
|
|
|
codeTheme.set(newCodeTheme);
|
2024-04-26 20:34:16 +00:00
|
|
|
await updateHighlightedCode(elem);
|
2023-04-11 05:00:28 +00:00
|
|
|
}),
|
2021-03-17 03:45:44 +00:00
|
|
|
...args,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Use a monospace font, a subset of what ACE editor seems to use.
|
|
|
|
export const cssCodeBlock = styled('div', `
|
|
|
|
font-family: 'Monaco', 'Menlo', monospace;
|
|
|
|
font-size: ${vars.smallFontSize};
|
2023-04-11 05:00:28 +00:00
|
|
|
background-color: ${theme.highlightedCodeBlockBg};
|
2021-03-17 03:45:44 +00:00
|
|
|
&[disabled], &.disabled {
|
2023-04-11 05:00:28 +00:00
|
|
|
background-color: ${theme.highlightedCodeBlockBgDisabled};
|
2021-03-17 03:45:44 +00:00
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssHighlightedCode = styled(cssCodeBlock, `
|
|
|
|
position: relative;
|
|
|
|
overflow: hidden;
|
2023-04-11 05:00:28 +00:00
|
|
|
border: 1px solid ${theme.highlightedCodeBorder};
|
2021-03-17 03:45:44 +00:00
|
|
|
border-radius: 3px;
|
|
|
|
min-height: 28px;
|
|
|
|
padding: 5px 6px;
|
2023-04-11 05:00:28 +00:00
|
|
|
color: ${theme.highlightedCodeFg};
|
2021-03-17 03:45:44 +00:00
|
|
|
|
2023-04-11 05:00:28 +00:00
|
|
|
&.disabled, &.disabled .ace-chrome, &.disabled .ace-dracula {
|
|
|
|
background-color: ${theme.highlightedCodeBgDisabled};
|
2021-03-17 03:45:44 +00:00
|
|
|
}
|
|
|
|
& .ace_line {
|
|
|
|
overflow: hidden;
|
|
|
|
text-overflow: ellipsis;
|
2024-04-26 20:34:16 +00:00
|
|
|
white-space: nowrap;
|
2023-03-23 18:22:28 +00:00
|
|
|
}
|
|
|
|
`);
|