* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import $ from 'jquery';
import * as monaco from 'monaco-editor';
import WebFont from 'webfontloader';
import './index.scss';
import { DEFAULT_THEMES, setTheme, THEME_PROPERTIES } from './themes.js';
const WARN_LINES = 15;
const WARN_LINE_LENGTH = 80;
const $editor = $('#editor');
const $output = $('#output');
let config = {
code: localStorage.highlighterCode || '',
theme: localStorage.highlighterTheme || 'light',
lang: localStorage.highlighterLang || '',
font: localStorage.highlighterFont || 'Roboto Mono',
tabSize: Number(localStorage.highlighterTabSize || '4'),
typeSize: Number(localStorage.highlighterTypeSize || '40'),
selectionTreatment: localStorage.highlighterSelectionTreatment || 'focus',
customTheme: JSON.parse(localStorage.customTheme || JSON.stringify(DEFAULT_THEMES['light'])),
};
if (!!window.location.search) {
loadConfigFromUrl();
}
if (config.lang == '--') {
config.lang = '';
}
let editor;
setupToolbar();
setupEditor();
setupOutputArea();
updateOutputArea();
setupCustomThemeEditor();
loadFont();
installServiceWorker();
function setupEditor() {
let updateCode_ = code => {
localStorage.highlighterCode = config.code = code;
updateOutputArea();
};
if (navigator.userAgent.match(/iP(hone|od|ad)|Android/)) {
let $textArea = $('<textarea>')
.attr('autocapitalize', 'off')
.attr('spellcheck', 'false')
.val(config.code)
.on('input', () => updateCode_($textArea.val()))
.appendTo($editor);
return;
}
editor = monaco.editor.create($editor.get(0), {
automaticLayout: true,
minimap: {
enabled: false
},
language: 'plaintext',
value: config.code,
theme: 'vs',
fontSize: 14,
padding: {
top: 8,
},
fontFamily: '"Roboto Mono"',
});
updateCode_(config.code);
editor.onDidChangeModelContent(() => updateCode_(editor.getValue()));
editor.onDidChangeCursorPosition(() => updateOutputArea());
editor.onDidChangeCursorSelection(() => updateOutputArea());
updateEditorParams();
}
function updateEditorParams() {
if (!editor) {
return;
}
editor.updateOptions({
fontFamily: `"${config.font}"`,
tabSize: config.tabSize,
detectIndentation: false,
});
}
function setupOutputArea() {
$output.on('click', () => {
var selection = window.getSelection();
var range = document.createRange();
range.selectNodeContents($output.find('pre').get(0));
selection.removeAllRanges();
selection.addRange(range);
});
$(window).on('resize', () => updateOutputArea());
document.fonts.ready.then(() => updateOutputArea());
}
function setupToolbar() {
$('#theme')
.val(config.theme)
.on('input', ev => {
localStorage.highlighterTheme = config.theme = $(ev.target).val();
updateConfigUrl();
updateOutputArea();
});
$('#lang')
.val(config.lang)
.on('input', ev => {
localStorage.highlighterLang = config.lang = $(ev.target).val();
updateOutputArea();
});
let $dl = $('#lang-datalist');
let langs = Object.keys(Prism.languages)
.filter(s => typeof Prism.languages[s] == 'object');
for (let lang of langs) {
$dl.append($('<option>').attr('value', lang));
}
$('#tab-size')
.val(config.tabSize)
.on('input', ev => {
localStorage.highlighterTabSize = $(ev.target).val();
config.tabSize = Number(localStorage.highlighterTabSize);
updateEditorParams();
updateConfigUrl();
updateOutputArea();
});
$('#font')
.val(config.font)
.on('input', ev => {
localStorage.highlighterFont = config.font = $(ev.target).val();
updateConfigUrl();
loadFont();
});
$('#selection-treatment')
.val(config.selectionTreatment)
.on('input', ev => {
localStorage.highlighterSelectionTreatment = config.selectionTreatment = $(ev.target).val();
updateConfigUrl();
updateOutputArea();
});
let $typeSize = $('#type-size');
let setTypeSize_ = size => {
if ($typeSize.val() != String(size)) {
$typeSize.val(size);
}
config.typeSize = size;
localStorage.highlighterTypeSize = String(config.typeSize);
updateConfigUrl();
updateOutputArea();
};
$typeSize
.val(config.typeSize)
.on('input', () => {
let val = parseInt($typeSize.val(), 10);
if (!isNaN(val) && val > 4) {
setTypeSize_(val);
}
})
.on('keydown', ev => {
if (!ev.shiftKey) {
if (ev.key == 'ArrowUp' || ev.key == 'ArrowDown') {
setTypeSize_(parseInt($typeSize.val(), 10) + (ev.key == 'ArrowUp' ? 1 : -1));
ev.preventDefault();
}
}
})
.on('blur', ev => setTypeSize_(config.typeSize));
}
function updateConfigUrl() {
let p = new URLSearchParams();
if (config.theme === 'custom') {
p.set('theme', 'custom');
for (let [k, v] of Object.entries(config.customTheme)) {
let { type, short } = THEME_PROPERTIES.find(s => s.id === k);
p.set(`t.${short}`, type === 'color' ? v.replace(/^#/, '') : v);
}
} else {
p.set('theme', config.theme);
}
p.set('font', config.font);
p.set('tab', config.tabSize);
p.set('size', config.typeSize);
p.set('sel', config.selectionTreatment);
window.history.replaceState('', '', '?' + p.toString());
}
function loadConfigFromUrl() {
let p = new URLSearchParams(window.location.search);
if (p.has('theme')) {
let theme = p.get('theme');
if (theme === 'custom') {
config.theme = 'custom';
let customTheme = {};
for (let k of [...p.keys()].filter(k => k.startsWith('t.'))) {
let { type, id } = THEME_PROPERTIES.find(s => s.short === k.replace(/^t\./, ''));
customTheme[id] = type === 'color' ? '#' + p.get(k) : p.get(k);
}
config.customTheme = customTheme;
}
}
if (p.has('font')) config.font = p.get('font');
if (p.has('tab')) config.tabSize = p.get('tab');
if (p.has('size')) config.typeSize = p.get('size');
if (p.has('sel')) config.selectionTreatment = p.get('sel');
}
function loadFont() {
WebFont.load({
google: {
families: [`${config.font}:400,700`]
},
active: () => {
updateEditorParams();
updateOutputArea();
}
});
}
function updateOutputArea() {
let $messages = $('.edit-area .messages');
$messages.empty();
$output.empty();
if (config.theme == 'custom') {
$('.custom-theme-area').css('display', 'flex');
setTheme(config.customTheme, config.typeSize);
} else {
$('.custom-theme-area').css('display', 'none');
setTheme(DEFAULT_THEMES[config.theme], config.typeSize);
}
let $pre = $('<pre>')
.addClass('prettyprint')
.css({
'font-family': config.font,
'font-size': `${config.typeSize}px`,
'background': 'transparent',
})
.appendTo($output);
let lang = config.lang;
if (lang == '') {
lang = /\s*</.test(config.code) ? 'markup' : 'js';
}
if (!Prism.languages[lang]) {
$('#lang').addClass('is-invalid');
return;
}
$('#lang').removeClass('is-invalid');
let html = Prism.highlight(
cleanupCode(config.code).code,
Prism.languages[lang], lang);
$pre.html(html);
highlightSelection();
if (false) {
addLineNumbers();
}
let preWidth = Math.max(1, measureNaturalPreWidth($pre));
let preHeight = Math.max(1, $pre.outerHeight());
let scale = Math.min(1, Math.min(
$output.width() / preWidth,
$output.height() / preHeight));
$pre.css({
width: preWidth,
transform: `translate(-50%, -50%) scale(${scale})`
});
let messages = [];
if ((config.code.match(/\n/g) || []).length >= WARN_LINES) {
messages.push({
type: 'warning',
message:
`More than ${WARN_LINES} lines of code will be hard to read in a
slide presentation.`
});
}
let lines = config.code.split('\n') || [];
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > WARN_LINE_LENGTH) {
messages.push({
type: 'warning',
message:
`Line ${(i + 1)} has more than ${WARN_LINE_LENGTH} characters!`
});
break;
}
}
messages.forEach(({ type, message }) =>
$('<div>')
.addClass(`message message-${type}`)
.text(message)
.appendTo($messages));
}
const htmlEscape = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
function highlightSelection() {
if (!editor) {
return;
}
$output.removeClass('has-highlights');
$output.removeAttr('data-seltreat');
if (config.selectionTreatment == '--') {
return;
}
$output.attr('data-seltreat', config.selectionTreatment);
let rawCode = config.code;
let { code, commonIndent, leadingEmptyLines } = cleanupCode(rawCode);
let preRoot = $output.find('pre').get(0);
let rangeToCharPos = ({ row, column }) => code.split(/\r?\n/)
.slice(0, row - leadingEmptyLines)
.reduce((a, r) => a + r.length + 1, 0)
+ Math.max(0,
((rawCode.split(/\n/)[row] || '').substring(0, column).match(/\t/g) || []).length
* (config.tabSize - 1)
+ column - commonIndent);
let hasHighlights = false;
for (let range of editor.getSelections()) {
let targetStartPos = rangeToCharPos({ row: range.startLineNumber - 1, column: range.startColumn - 1 });
let targetEndPos = rangeToCharPos({ row: range.endLineNumber - 1, column: range.endColumn - 1 });
if (targetEndPos == targetStartPos) {
continue;
}
hasHighlights = true;
let childStartPos = 0;
let traverse_ = (parent, emptyClass = '') => {
for (let child of Array.from(parent.childNodes)) {
if (child.childNodes.length >= 2 ||
(child.childNodes.length >= 1
&& child.childNodes[0].nodeType != 3 )) {
traverse_(child, child.className);
continue;
}
let childContent = child.textContent;
let childEndPos = childStartPos + childContent.length;
if (targetStartPos < childEndPos && targetEndPos >= childStartPos) {
let startInChild = Math.max(0, targetStartPos - childStartPos);
let endInChild = Math.min(childContent.length, childContent.length - (childEndPos - targetEndPos));
let makeSub = (tag, start, end) => {
if (start == end) {
return null;
}
let f = document.createElement(tag);
if (child.className) {
f.className = child.className;
} else if (emptyClass) {
f.className = emptyClass;
}
f.innerHTML = htmlEscape(childContent.substring(start, end));
return f;
};
child.replaceWith.apply(child, [
makeSub('span', 0, startInChild),
makeSub('mark', startInChild, endInChild),
makeSub('span', endInChild, childContent.length),
].filter(s => !!s));
}
childStartPos = childEndPos;
}
};
traverse_(preRoot);
}
$output.toggleClass('has-highlights', hasHighlights);
}
function addLineNumbers() {
let $pre = $output.find('pre');
let htmlLines = $pre.html().split(/\n/);
$pre.html(htmlLines
.map((s, ind) => `<span style="color:grey">` +
String(ind + 1).padStart(Math.ceil((htmlLines.length + 1) / 10), ' ') +
`</span> ${s}`)
.join('\n'));
}
function cleanupCode(code) {
let lines = code.split('\n');
let leadingEmptyLines = 0;
for (let line of lines) {
if (line.match(/^\s*$/)) {
++leadingEmptyLines;
} else {
break;
}
}
let trailingEmptyLines = 0;
for (let line of [...lines].reverse()) {
if (line.match(/^\s*$/)) {
++trailingEmptyLines;
} else {
break;
}
}
if (leadingEmptyLines == lines.length) {
trailingEmptyLines = 0;
}
lines = lines.slice(leadingEmptyLines, lines.length - trailingEmptyLines);
lines = lines.map(line => line.replace(/\t/g, ' '.repeat(config.tabSize)));
lines = lines.map(line => line.replace(/ +$/g, ''));
let commonIndent = -1;
for (let line of lines) {
if (!$.trim(line)) {
continue;
}
let indent = line.match(/^\s*/)[0].length;
if (indent < commonIndent || commonIndent == -1) {
commonIndent = indent;
}
}
if (commonIndent > 0) {
lines = lines.map(line => line.substring(commonIndent));
}
code = lines.join('\n');
return { code, commonIndent, leadingEmptyLines, trailingEmptyLines };
}
function measureNaturalPreWidth(pre) {
let $pre = $(pre);
let longestLine = $pre.text()
.split('\n')
.reduce((longest, line) => (longest.length > line.length) ? longest : line, '');
let $preClone = $pre
.clone()
.css({
position: 'fixed',
left: -10000,
top: 0,
display: 'inline-block',
width: 'auto',
height: 'auto',
})
.text(longestLine)
.appendTo(document.body);
let naturalWidth = $preClone.width();
$preClone.remove();
return naturalWidth;
}
function setupCustomThemeEditor() {
let sanitize_ = s => s.replace(/^\s*|\s*$/g, '').toUpperCase();
let rebuildCustomThemeProperties = () => {
let $customThemeEditor = $('.custom-theme-editor').empty();
for (let prop of THEME_PROPERTIES.filter(s => !s.hideEditor)) {
let $prop = $('<div>')
.addClass('custom-theme-prop')
.appendTo($customThemeEditor);
let $label = $('<label>')
.appendTo($prop);
let hexColor = String(config.customTheme[prop.id] || '#000000').toUpperCase();
let $textInput, $colorInput;
$colorInput = $('<input>')
.attr('type', 'color')
.val(hexColor)
.on('input', () => {
config.customTheme[prop.id] = sanitize_($colorInput.val());
$textInput.val(config.customTheme[prop.id]);
localStorage.customTheme = JSON.stringify(config.customTheme);
updateConfigUrl();
updateOutputArea();
})
.appendTo($label);
$textInput = $('<input>')
.attr('type', 'text')
.val(hexColor)
.on('input', () => {
config.customTheme[prop.id] = sanitize_($textInput.val());
$colorInput.val(config.customTheme[prop.id]);
localStorage.customTheme = JSON.stringify(config.customTheme);
updateConfigUrl();
updateOutputArea();
})
.appendTo($label);
$label.append(`<span>${prop.name}</span>`);
}
}
rebuildCustomThemeProperties();
$('.custom-theme-import-export').on('click', () => {
let currentJSON = JSON.stringify(config.customTheme);
let newJSON = window.prompt(
'Copy the below JSON or paste new JSON for your custom theme.', currentJSON);
if (newJSON && newJSON != currentJSON) {
try {
config.customTheme = Object.assign({}, DEFAULT_THEMES['light'], JSON.parse(newJSON) || {});
localStorage.customTheme = JSON.stringify(config.customTheme);
updateConfigUrl();
updateOutputArea();
rebuildCustomThemeProperties();
} catch (e) {
alert('Error parsing the JSON: ' + e);
}
}
});
$('.custom-theme-reset').on('click', () => {
config.customTheme = { ...DEFAULT_THEMES['light'] };
localStorage.customTheme = JSON.stringify(config.customTheme);
updateConfigUrl();
updateOutputArea();
rebuildCustomThemeProperties();
});
}
function installServiceWorker() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').then(registration => {
console.log('SW registered: ', registration);
}).catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
}