/** Prism Live: Code editor based on Prism.js Works best in Chrome. Currently only very basic support in other browsers (no snippets, no shortcuts) @author Lea Verou */ (async function() { const CURRENT_URL = document.currentScript? new URL(document.currentScript.src) : null; if (!window.Bliss) { // Load Bliss if not loaded console.log("Bliss not loaded. Loading remotely from blissfuljs.com"); let bliss = document.createElement("script"); bliss.src = "https://blissfuljs.com/bliss.shy.min.js"; document.head.appendChild(bliss); await new Promise(resolve => bliss.onload = resolve); } var $ = Bliss, $$ = Bliss.$; var ready = Promise.resolve(); if (CURRENT_URL) { // Tiny dynamic loader. Use e.g. ?load=css,markup,javascript to load components var load = CURRENT_URL.searchParams.get("load"); if (load !== null) { var files = ["../prism-live.css"]; if (load) { files.push(...load.split(/,/).map(c => /\./.test(c)? c : `prism-live-${c}.js`)); } ready = Promise.all(files.map(url => $.load(url, CURRENT_URL))); } } var superKey = navigator.platform.indexOf("Mac") === 0? "metaKey" : "ctrlKey"; var _ = Prism.Live = class PrismLive { constructor(source) { this.source = source; this.sourceType = source.nodeName.toLowerCase(); this.wrapper = $.create({ className: "prism-live", around: this.source }); if (this.sourceType === "textarea") { this.textarea = this.source; this.code = $.create("code"); this.pre = $.create("pre", { className: this.textarea.className + " no-whitespace-normalization", contents: this.code, before: this.textarea }); } else { this.pre = this.source; // Normalize once, to fix indentation from markup and then remove normalization // so we can enter blank lines etc // Prism.plugins.NormalizeWhitespace.normalize($("code", this.pre), {}); this.pre.classList.add("no-whitespace-normalization"); this.code = $("code", this.pre); this.textarea = $.create("textarea", { className: this.pre.className, value: this.pre.textContent, after: this.pre }); } _.all.set(this.textarea, this); _.all.set(this.pre, this); _.all.set(this.code, this); this.pre.classList.add("prism-live"); this.textarea.classList.add("prism-live"); this.source.classList.add("prism-live-source"); if (self.Incrementable) { // TODO data-* attribute for modifier // TODO load dynamically if not present new Incrementable(this.textarea); } $.bind(this.textarea, { input: evt => this.update(), keyup: evt => { if (evt.key == "Enter") { // Enter // Maintain indent on line breaks this.insert(this.currentIndent); this.syncScroll(); } }, keydown: evt => { if (evt.key == "Tab" && !evt.altKey) { // Default is to move focus off the textarea // this is never desirable in an editor evt.preventDefault(); if (this.tabstops && this.tabstops.length > 0) { // We have tabstops to go this.moveCaret(this.tabstops.shift()); } else if (this.hasSelection) { var before = this.beforeCaret("\n"); var outdent = evt.shiftKey; this.selectionStart -= before.length; var selection = _.adjustIndentation(this.selection, { relative: true, indentation: outdent? -1 : 1 }); this.replace(selection); if (outdent) { var indentStart = _.regexp.gm`^${this.indent}`; var isBeforeIndented = indentStart.test(before); this.selectionStart += before.length + 1 - (outdent + isBeforeIndented); } else { // Indent var hasLineAbove = before.length == this.selectionStart; this.selectionStart += before.length + 1 + !hasLineAbove; } } else { // Nothing selected, expand snippet var selector = _.match(this.beforeCaret(), /\S*$/); var snippetExpanded = this.expandSnippet(selector); if (snippetExpanded) { requestAnimationFrame(() => $.fire(this.textarea, "input")); } else { this.insert(this.indent); } } } else if (_.pairs[evt.key]) { var other = _.pairs[evt.key]; this.wrapSelection({ before: evt.key, after: other, outside: true }); evt.preventDefault(); } else if (Object.values(_.pairs).includes(evt.key)) { if (this.selectionStart == this.selectionEnd && this.textarea.value[this.selectionEnd] == evt.key) { this.selectionStart += 1; this.selectionEnd += 1; evt.preventDefault(); } } else { for (let shortcut in _.shortcuts) { if (_.checkShortcut(shortcut, evt)) { _.shortcuts[shortcut].call(this, evt); evt.preventDefault(); } } } }, click: evt => { var l = this.getLine(); var v = this.value; var ss = this.selectionStart; //console.log(ss, v[ss], l, v.slice(l.start, l.end)); }, "click keyup": evt => { if (!evt.key || evt.key.lastIndexOf("Arrow") > -1) { // Caret moved this.tabstops = null; } } }); // this.syncScroll(); this.textarea.addEventListener("scroll", this, {passive: true}); $.bind(window, { "resize": evt => this.syncStyles() }); // Copy styles with a delay requestAnimationFrame(() => { this.syncStyles(); var sourceCS = getComputedStyle(this.source); this.pre.style.height = this.source.style.height || sourceCS.getPropertyValue("--height"); this.pre.style.maxHeight = this.source.style.maxHeight || sourceCS.getPropertyValue("--max-height"); this.textarea.spellcheck = this.source.spellcheck || sourceCS.getPropertyValue("--spellcheck"); }); this.update(); this.lang = (this.code.className.match(/lang(?:uage)?-(\w+)/i) || [,])[1]; this.observer = new MutationObserver(r => { if (document.activeElement !== this.textarea) { this.textarea.value = this.pre.textContent; } }); this.observe(); this.source.dispatchEvent(new CustomEvent("prism-live-init", {bubbles: true, detail: this})); } handleEvent(evt) { if (evt.type === "scroll") { this.syncScroll(); } } observe () { return this.observer && this.observer.observe(this.pre, { childList: true, subtree: true, characterData: true }); } unobserve () { if (this.observer) { this.observer.takeRecords(); this.observer.disconnect(); } } expandSnippet(text) { if (!text) { return false; } var context = this.context; if (text in context.snippets || text in _.snippets) { // Static Snippets var expansion = context.snippets[text] || _.snippets[text]; } else if (context.snippets.custom) { var expansion = context.snippets.custom.call(this, text); } if (expansion) { // Insert snippet var stops = []; var replacement = []; var str = expansion; var match; while (match = _.CARET_INDICATOR.exec(str)) { stops.push(match.index + 1); replacement.push(str.slice(0, match.index + match[1].length)); str = str.slice(match.index + match[0].length); _.CARET_INDICATOR.lastIndex = 0; } replacement.push(str); replacement = replacement.join(""); if (stops.length > 0) { // make first stop relative to end, all others relative to previous stop stops[0] -= replacement.length; } this.delete(text); this.insert(replacement, {matchIndentation: true}); this.tabstops = stops; this.moveCaret(this.tabstops.shift()); } return !!expansion; } get selectionStart() { return this.textarea.selectionStart; } set selectionStart(v) { this.textarea.selectionStart = v; } get selectionEnd() { return this.textarea.selectionEnd; } set selectionEnd(v) { this.textarea.selectionEnd = v; } get hasSelection() { return this.selectionStart != this.selectionEnd; } get selection() { return this.value.slice(this.selectionStart, this.selectionEnd); } get value() { return this.textarea.value; } set value(v) { this.textarea.value = v; } get indent() { return _.match(this.value, /^[\t ]+/m, _.DEFAULT_INDENT); } get currentIndent() { var before = this.value.slice(0, this.selectionStart-1); return _.match(before, /^[\t ]*/mg, "", -1); } // Current language at caret position get currentLanguage() { var node = this.getNode(); node = node? node.parentNode : this.code; var lang = _.match(node.closest('[class*="language-"]').className, /language-(\w+)/, 1); return _.aliases[lang] || lang; } // Get settings based on current language get context() { var lang = this.currentLanguage; return _.languages[lang] || _.languages.DEFAULT; } setSelection(start, end) { if (start && typeof start === "object" && (start.start || start.end)) { end = start.end; start = start.start; } let prevStart = this.selectionStart; let prevEnd = this.selectionEnd; if (start !== undefined) { this.selectionStart = start; } if (end !== undefined) { this.selectionEnd = end; } // If there is a selection, and it's not the same as the previous selection, fire appropriate select event if (this.selectionStart !== this.selectionEnd && (prevStart !== this.selectionStart || prevEnd !== this.selectionEnd)) { this.textarea.dispatchEvent(new Event("select", {bubbles: true})); } } update (force) { var code = this.value; // If code ends in newline then browser "conveniently" trims it // but we want to see the new line we just inserted! // So we insert a zero-width space, which isn't trimmed if (/\n$/.test(this.value)) { code += "\u200b"; } if (!force && this.code.textContent === code && $(".token", this.code)) { // Already highlighted return; } this.unobserve(); this.code.textContent = code; Prism.highlightElement(this.code); this.observe(); } syncStyles() { // Copy pre metrics over to textarea var cs = getComputedStyle(this.pre); // Copy styles from
 to textarea
		this.textarea.style.caretColor = cs.color;

		var properties = /^(font|lineHeight)|[tT]abSize/gi;

		for (var prop in cs) {
			if (cs[prop] && prop in this.textarea.style && properties.test(prop)) {
				this.wrapper.style[prop] = cs[prop];
				this.textarea.style[prop] = this.pre.style[prop] = "inherit";
			}
		}

		// This is primarily for supporting the line-numbers plugin.
		this.textarea.style['padding-left'] = cs['padding-left'];

		this.update();
	}

	syncScroll() {
		if (this.pre.clientWidth === 0 && this.pre.clientHeight === 0) {
			return;
		}

		this.pre.scrollTop = this.textarea.scrollTop;
		this.pre.scrollLeft = this.textarea.scrollLeft;
	}

	beforeCaretIndex (until = "") {
		return this.value.lastIndexOf(until, this.selectionStart);
	}

	afterCaretIndex (until = "") {
		return this.value.indexOf(until, this.selectionEnd);
	}

	beforeCaret (until = "") {
		var index = this.beforeCaretIndex(until);

		if (index === -1 || !until) {
			index = 0;
		}

		return this.value.slice(index, this.selectionStart);
	}

	getLine(offset = this.selectionStart) {
		var value = this.value;
		var lf = "\n", cr = "\r";
		var start, end, char;

		for (var start = this.selectionStart; char = value[start]; start--) {
			if (char === lf || char === cr || !start) {
				break;
			}
		}

		for (var end = this.selectionStart; char = value[end]; end++) {
			if (char === lf || char === cr) {
				break;
			}
		}

		return {start, end};
	}

	afterCaret(until = "") {
		var index = this.afterCaretIndex(until);

		if (index === -1 || !until) {
			index = undefined;
		}

		return this.value.slice(this.selectionEnd, index);
	}

	setCaret(pos) {
		this.selectionStart = this.selectionEnd = pos;
	}

	moveCaret(chars) {
		if (chars) {
			this.setCaret(this.selectionEnd + chars);
		}
	}

	insert(text, {index} = {}) {
		if (!text) {
			return;
		}

		this.textarea.focus();

		if (index === undefined) {
			// No specified index, insert in current caret position
			this.replace(text);
		}
		else {
			// Specified index, first move caret there
			var start = this.selectionStart;
			var end = this.selectionEnd;

			this.selectionStart = this.selectionEnd = index;
			this.replace(text);

			this.setSelection(
				start + (index < start? text.length : 0),
				end + (index <= end? text.length : 0)
			);
		}
	}

	// Replace currently selected text
	replace (text) {
		var hadSelection = this.hasSelection;

		this.insertText(text);

		if (hadSelection) {
			// By default inserText places the caret at the end, losing any selection
			// What we want instead is the replaced text to be selected
			this.setSelection({start: this.selectionEnd - text.length});
		}
	}

	// Set text between indexes and restore caret position
	set (text, {start, end} = {}) {
		var ss = this.selectionStart;
		var se = this.selectionEnd;

		this.setSelection(start, end);

		this.insertText(text);

		this.setSelection(ss, se);
	}

	insertText (text) {
		if (!text) {
			return;
		}

		if (_.supportsExecCommand === true) {
			document.execCommand("insertText", false, text);
		}
		else if (_.supportsExecCommand === undefined) {
			// We still don't know if document.execCommand("insertText") is supported
			let value = this.value;

			document.execCommand("insertText", false, text);

			_.supportsExecCommand = value !== this.value;
		}

		if (_.supportsExecCommand === false) {
			this.textarea.setRangeText(text, this.selectionStart, this.selectionEnd, "end");
			requestAnimationFrame(() => this.update());
		}

		return _.supportsExecCommand;
	}

	/**
	 * Wrap text with strings
	 * @param before {String} The text to insert before
	 * @param after {String} The text to insert after
	 * @param start {Number} Character offset
	 * @param end {Number} Character offset
	 */
	wrap ({before, after, start = this.selectionStart, end = this.selectionEnd} = {}) {
		var ss = this.selectionStart;
		var se = this.selectionEnd;
		var between = this.value.slice(start, end);

		this.set(before + between + after, {start, end});

		if (ss > start) {
			ss += before.length;
		}

		if (se > start) {
			se += before.length;
		}

		if (ss > end) {
			ss += after.length;
		}

		if (se > end) {
			se += after.length;
		}

		this.setSelection(ss, se);
	}

	wrapSelection (o = {}) {
		var hadSelection = this.hasSelection;

		this.replace(o.before + this.selection + o.after);

		if (hadSelection) {
			if (o.outside) {
				// Do not include new text in selection
				this.selectionStart += o.before.length;
				this.selectionEnd -= o.after.length;
			}
		}
		else {
			this.moveCaret(-o.after.length);
		}
	}

	toggleComment() {
		var comments = this.context.comments;

		// Are we inside a comment?
		var node = this.getNode();
		var commentNode = node.parentNode.closest(".token.comment");

		if (commentNode) {
			// Remove comment
			var start = this.getOffset(commentNode);
			var commentText = commentNode.textContent;

			if (comments.singleline && commentText.indexOf(comments.singleline) === 0) {
				var end = start + commentText.length;
				this.set(this.value.slice(start + comments.singleline.length, end), {start, end});
				this.moveCaret(-comments.singleline.length);
			}
			else {
				comments = comments.multiline || comments;
				var end = start + commentText.length - comments[1].length;
				this.set(this.value.slice(start + comments[0].length, end), {start, end: end + comments[1].length});
			}
		}
		else {
			// Not inside comment, add
			if (this.hasSelection) {
				comments = comments.multiline || comments;

				this.wrapSelection({
					before: comments[0],
					after: comments[1]
				});
			}
			else {
				// No selection, wrap line
				// FIXME *inside indent*
				comments = comments.singleline? [comments.singleline, ""] : comments.multiline || comments;
				end = this.afterCaretIndex("\n");
				this.wrap({
					before: comments[0],
					after: comments[1],
					start: this.beforeCaretIndex("\n") + 1,
					end: end < 0? this.value.length : end
				});
			}
		}
	}

	duplicateContent () {
		var before = this.beforeCaret("\n");
		var after = this.afterCaret("\n");
		var text = before + this.selection + after;

		this.insert(text, {index: this.selectionStart - before.length});
	}

	delete (characters, {forward, pos} = {}) {
		var i = characters = characters > 0? characters : (characters + "").length;

		if (pos) {
			var selectionStart = this.selectionStart;
			this.selectionStart = pos;
			this.selectionEnd = pos + this.selectionEnd - selectionStart;
		}

		while (i--) {
			document.execCommand(forward? "forwardDelete" : "delete");
		}

		if (pos) {
			// Restore caret
			this.selectionStart = selectionStart - characters;
			this.selectionEnd = this.selectionEnd - pos + this.selectionStart;
		}
	}

	/**
	 * Get the text node at a given chracter offset
	 */
	getNode(offset = this.selectionStart, container = this.code) {
		var node, sum = 0;
		var walk = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);

		while (node = walk.nextNode()) {
			sum += node.data.length;

			if (sum >= offset) {
				return node;
			}
		}

		// if here, offset is larger than maximum
		return null;
	}

	/**
	 * Get the character offset of a given node in the highlighted source
	 */
	getOffset(node) {
		var range = document.createRange();
		range.selectNodeContents(this.code);
		range.setEnd(node, 0);
		return range.toString().length;
	}

	// Utility method to get regex matches
	static match(str, regex, def, index = 0) {
		if (typeof def === "number" && arguments.length === 3) {
			index = def;
			def = undefined;
		}

		var match = str.match(regex);

		if (index < 0) {
			index = match.length + index;
		}

		return match? match[index] : def;
	}

	static checkShortcut(shortcut, evt) {
		return shortcut.trim().split(/\s*\+\s*/).every(key => {
			switch (key) {
				case "Cmd":   return evt[superKey];
				case "Ctrl":  return evt.ctrlKey;
				case "Shift": return evt.shiftKey;
				case "Alt":   return evt.altKey;
				default: return evt.key === key;
			}
		});
	}

	static registerLanguage(name, context, parent = _.languages.DEFAULT) {
		Object.setPrototypeOf(context, parent);
		return _.languages[name] = context;
	}

	static matchIndentation(text, currentIndent) {
		// FIXME this assumes that text has no indentation of its own
		// to make this more generally useful beyond snippets, we should first
		// strip text's own indentation.
		text = text.replace(/\r?\n/g, "$&" + currentIndent);
	}

	static adjustIndentation(text, {indentation, relative = true, indent = _.DEFAULT_INDENT}) {
		if (!relative) {
			// First strip min indentation
			var minIndent = text.match(_.regexp.gm`^(${indent})+`).sort()[0];

			if (minIndent) {
				text.replace(_.regexp.gm`^${minIndent}`, "");
			}
		}

		if (indentation < 0) {
			return text.replace(_.regexp.gm`^${indent}`, "");
		}
		else if (indentation > 0) { // Indent
			return text.replace(/^/gm, indent);
		}
	}

	static create (source, ...args) {
		let ret = _.all.get(source);
		if (!ret) {
			ret = new _(source);
		}
		return ret;
	}
};

// Static properties
Object.assign(_, {
	all: new WeakMap(),
	ready,
	DEFAULT_INDENT: "\t",
	CARET_INDICATOR: /(^|[^\\])\$(\d+)/g,
	snippets: {
		"test": "Snippets work!",
	},
	pairs: {
		"(": ")",
		"[": "]",
		"{": "}",
		'"': '"',
		"'": "'",
		"`": "`"
	},
	shortcuts: {
		"Cmd + /": function() {
			this.toggleComment();
		},
		"Ctrl + Shift + D": function() {
			this.duplicateContent();
		}
	},
	languages: {
		DEFAULT: {
			comments: {
				multiline: ["/*", "*/"]
			},
			snippets: {}
		}
	},
	// Map of Prism language ids and their canonical name
	aliases: (() => {
		var ret = {};
		var canonical = new WeakMap(Object.entries(Prism.languages).map(x => x.reverse()).reverse());

		for (var id in Prism.languages) {
			var grammar = Prism.languages[id];

			if (typeof grammar !== "function") {
				ret[id] = canonical.get(grammar);
			}
		}

		return ret;
	})(),

	regexp: (() => {
		var escape = s => s.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
		var _regexp = (flags, strings, ...values) => {
			var pattern = strings[0] + values.map((v, i) => escape(v) + strings[i+1]).join("");
			return RegExp(pattern, flags);
		};
		var cache = {};

		return new Proxy(_regexp.bind(_, ""), {
			get: (t, property) => {
				return t[property] || cache[property]
					   || (cache[property] = _regexp.bind(_, property));
			}
		});
	})()
});

_.supportsExecCommand = document.execCommand? undefined : false;

$.ready().then(() => {
	$$(":not(.prism-live) > textarea.prism-live, :not(.prism-live) > pre.prism-live").forEach(source => _.create(source));
});

})();