diff --git a/src/web/stylesheets/layout/_io.css b/src/web/stylesheets/layout/_io.css
index cb196709..ea15b6ac 100755
--- a/src/web/stylesheets/layout/_io.css
+++ b/src/web/stylesheets/layout/_io.css
@@ -177,31 +177,12 @@
}
.textarea-wrapper textarea,
-.textarea-wrapper #output-text,
-.textarea-wrapper #output-highlighter {
+.textarea-wrapper #output-text {
font-family: var(--fixed-width-font-family);
font-size: var(--fixed-width-font-size);
color: var(--fixed-width-font-colour);
}
-#input-highlighter,
-#output-highlighter {
- position: absolute;
- left: 0;
- bottom: 0;
- width: 100%;
- padding: 3px;
- margin: 0;
- overflow: hidden;
- letter-spacing: normal;
- white-space: pre-wrap;
- word-wrap: break-word;
- color: #fff;
- background-color: transparent;
- border: none;
- pointer-events: none;
-}
-
#output-loader {
position: absolute;
bottom: 0;
diff --git a/src/web/stylesheets/utils/_overrides.css b/src/web/stylesheets/utils/_overrides.css
index fa216836..920aab89 100755
--- a/src/web/stylesheets/utils/_overrides.css
+++ b/src/web/stylesheets/utils/_overrides.css
@@ -232,3 +232,11 @@ optgroup {
.colorpicker-color div {
height: 100px;
}
+
+
+/* CodeMirror */
+
+.ΝΌ2 .cm-specialChar,
+.cm-specialChar {
+ color: red;
+}
diff --git a/src/web/utils/copyOverride.mjs b/src/web/utils/copyOverride.mjs
new file mode 100644
index 00000000..51b2386b
--- /dev/null
+++ b/src/web/utils/copyOverride.mjs
@@ -0,0 +1,125 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2022
+ * @license Apache-2.0
+ *
+ * In order to render whitespace characters as control character pictures in the output, even
+ * when they are the designated line separator, CyberChef sometimes chooses to represent them
+ * internally using the Unicode Private Use Area (https://en.wikipedia.org/wiki/Private_Use_Areas).
+ * See `Utils.escapeWhitespace()` for an example of this.
+ *
+ * The `renderSpecialChar()` function understands that it should display these characters as
+ * control pictures. When copying data from the Output, we need to replace these PUA characters
+ * with their original values, so we override the DOM "copy" event and modify the copied data
+ * if required. This handler is based closely on the built-in CodeMirror handler and defers to the
+ * built-in handler if PUA characters are not present in the copied data, in order to minimise the
+ * impact of breaking changes.
+ */
+
+import {EditorView} from "@codemirror/view";
+
+/**
+ * Copies the currently selected text from the state doc.
+ * Based on the built-in implementation with a few unrequired bits taken out:
+ * https://github.com/codemirror/view/blob/7d9c3e54396242d17b3164a0e244dcc234ee50ee/src/input.ts#L604
+ *
+ * @param {EditorState} state
+ * @returns {Object}
+ */
+function copiedRange(state) {
+ const content = [];
+ let linewise = false;
+ for (const range of state.selection.ranges) if (!range.empty) {
+ content.push(state.sliceDoc(range.from, range.to));
+ }
+ if (!content.length) {
+ // Nothing selected, do a line-wise copy
+ let upto = -1;
+ for (const {from} of state.selection.ranges) {
+ const line = state.doc.lineAt(from);
+ if (line.number > upto) {
+ content.push(line.text);
+ }
+ upto = line.number;
+ }
+ linewise = true;
+ }
+
+ return {text: content.join(state.lineBreak), linewise};
+}
+
+/**
+ * Regex to match characters in the Private Use Area of the Unicode table.
+ */
+const PUARegex = new RegExp("[\ue000-\uf8ff]");
+const PUARegexG = new RegExp("[\ue000-\uf8ff]", "g");
+/**
+ * Regex tto match Unicode Control Pictures.
+ */
+const CPRegex = new RegExp("[\u2400-\u243f]");
+const CPRegexG = new RegExp("[\u2400-\u243f]", "g");
+
+/**
+ * Overrides the DOM "copy" handler in the CodeMirror editor in order to return the original
+ * values of control characters that have been represented in the Unicode Private Use Area for
+ * visual purposes.
+ * Based on the built-in copy handler with some modifications:
+ * https://github.com/codemirror/view/blob/7d9c3e54396242d17b3164a0e244dcc234ee50ee/src/input.ts#L629
+ *
+ * This handler will defer to the built-in version if no PUA characters are present.
+ *
+ * @returns {Extension}
+ */
+export function copyOverride() {
+ return EditorView.domEventHandlers({
+ copy(event, view) {
+ const {text, linewise} = copiedRange(view.state);
+ if (!text && !linewise) return;
+
+ // If there are no PUA chars in the copied text, return false and allow the built-in
+ // copy handler to fire
+ if (!PUARegex.test(text)) return false;
+
+ // If PUA chars are detected, modify them back to their original values and copy that instead
+ const rawText = text.replace(PUARegexG, function(c) {
+ return String.fromCharCode(c.charCodeAt(0) - 0xe000);
+ });
+
+ event.preventDefault();
+ event.clipboardData.clearData();
+ event.clipboardData.setData("text/plain", rawText);
+
+ // Returning true prevents CodeMirror default handlers from firing
+ return true;
+ }
+ });
+}
+
+
+/**
+ * Handler for copy events in output-html decorations. If there are control pictures present,
+ * this handler will convert them back to their raw form before copying. If there are no
+ * control pictures present, it will do nothing and defer to the default browser handler.
+ *
+ * @param {ClipboardEvent} event
+ * @returns {boolean}
+ */
+export function htmlCopyOverride(event) {
+ const text = window.getSelection().toString();
+ if (!text) return;
+
+ // If there are no control picture chars in the copied text, return false and allow the built-in
+ // copy handler to fire
+ if (!CPRegex.test(text)) return false;
+
+ // If control picture chars are detected, modify them back to their original values and copy that instead
+ const rawText = text.replace(CPRegexG, function(c) {
+ return String.fromCharCode(c.charCodeAt(0) - 0x2400);
+ });
+
+ event.preventDefault();
+ event.clipboardData.clearData();
+ event.clipboardData.setData("text/plain", rawText);
+
+ return true;
+}
diff --git a/src/web/utils/editorUtils.mjs b/src/web/utils/editorUtils.mjs
index fe6b83d4..cb0ebed1 100644
--- a/src/web/utils/editorUtils.mjs
+++ b/src/web/utils/editorUtils.mjs
@@ -6,12 +6,41 @@
* @license Apache-2.0
*/
+import Utils from "../../core/Utils.mjs";
+
+// Descriptions for named control characters
+const Names = {
+ 0: "null",
+ 7: "bell",
+ 8: "backspace",
+ 10: "line feed",
+ 11: "vertical tab",
+ 13: "carriage return",
+ 27: "escape",
+ 8203: "zero width space",
+ 8204: "zero width non-joiner",
+ 8205: "zero width joiner",
+ 8206: "left-to-right mark",
+ 8207: "right-to-left mark",
+ 8232: "line separator",
+ 8237: "left-to-right override",
+ 8238: "right-to-left override",
+ 8233: "paragraph separator",
+ 65279: "zero width no-break space",
+ 65532: "object replacement"
+};
+
+// Regex for Special Characters to be replaced
+const UnicodeRegexpSupport = /x/.unicode != null ? "gu" : "g";
+const Specials = new RegExp("[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\ufeff\ufff9-\ufffc\ue000-\uf8ff]", UnicodeRegexpSupport);
+
/**
* Override for rendering special characters.
* Should mirror the toDOM function in
* https://github.com/codemirror/view/blob/main/src/special-chars.ts#L150
* But reverts the replacement of line feeds with newline control pictures.
+ *
* @param {number} code
* @param {string} desc
* @param {string} placeholder
@@ -19,10 +48,47 @@
*/
export function renderSpecialChar(code, desc, placeholder) {
const s = document.createElement("span");
- // CodeMirror changes 0x0a to "NL" instead of "LF". We change it back.
- s.textContent = code === 0x0a ? "\u240a" : placeholder;
+
+ // CodeMirror changes 0x0a to "NL" instead of "LF". We change it back along with its description.
+ if (code === 0x0a) {
+ placeholder = "\u240a";
+ desc = desc.replace("newline", "line feed");
+ }
+
+ // Render CyberChef escaped characters correctly - see Utils.escapeWhitespace
+ if (code >= 0xe000 && code <= 0xf8ff) {
+ code = code - 0xe000;
+ placeholder = String.fromCharCode(0x2400 + code);
+ desc = "Control character " + (Names[code] || "0x" + code.toString(16));
+ }
+
+ s.textContent = placeholder;
s.title = desc;
s.setAttribute("aria-label", desc);
s.className = "cm-specialChar";
return s;
}
+
+
+/**
+ * Given a string, returns that string with any control characters replaced with HTML
+ * renderings of control pictures.
+ *
+ * @param {string} str
+ * @param {boolean} [preserveWs=false]
+ * @param {string} [lineBreak="\n"]
+ * @returns {html}
+ */
+export function escapeControlChars(str, preserveWs=false, lineBreak="\n") {
+ if (!preserveWs)
+ str = Utils.escapeWhitespace(str);
+
+ return str.replace(Specials, function(c) {
+ if (lineBreak.includes(c)) return c;
+ const code = c.charCodeAt(0);
+ const desc = "Control character " + (Names[code] || "0x" + code.toString(16));
+ const placeholder = code > 32 ? "\u2022" : String.fromCharCode(9216 + code);
+ const n = renderSpecialChar(code, desc, placeholder);
+ return n.outerHTML;
+ });
+}
diff --git a/src/web/utils/htmlWidget.mjs b/src/web/utils/htmlWidget.mjs
index fbce9b49..5e5c41c1 100644
--- a/src/web/utils/htmlWidget.mjs
+++ b/src/web/utils/htmlWidget.mjs
@@ -5,6 +5,9 @@
*/
import {WidgetType, Decoration, ViewPlugin} from "@codemirror/view";
+import {escapeControlChars} from "./editorUtils.mjs";
+import {htmlCopyOverride} from "./copyOverride.mjs";
+
/**
* Adds an HTML widget to the Code Mirror editor
@@ -14,9 +17,10 @@ class HTMLWidget extends WidgetType {
/**
* HTMLWidget consructor
*/
- constructor(html) {
+ constructor(html, view) {
super();
this.html = html;
+ this.view = view;
}
/**
@@ -27,9 +31,45 @@ class HTMLWidget extends WidgetType {
const wrap = document.createElement("span");
wrap.setAttribute("id", "output-html");
wrap.innerHTML = this.html;
+
+ // Find text nodes and replace unprintable chars with control codes
+ this.walkTextNodes(wrap);
+
+ // Add a handler for copy events to ensure the control codes are copied correctly
+ wrap.addEventListener("copy", htmlCopyOverride);
return wrap;
}
+ /**
+ * Walks all text nodes in a given element
+ * @param {DOMNode} el
+ */
+ walkTextNodes(el) {
+ for (const node of el.childNodes) {
+ switch (node.nodeType) {
+ case Node.TEXT_NODE:
+ this.replaceControlChars(node);
+ break;
+ default:
+ if (node.nodeName !== "SCRIPT" &&
+ node.nodeName !== "STYLE")
+ this.walkTextNodes(node);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Renders control characters in text nodes
+ * @param {DOMNode} textNode
+ */
+ replaceControlChars(textNode) {
+ const val = escapeControlChars(textNode.nodeValue, true, this.view.state.lineBreak);
+ const node = document.createElement("null");
+ node.innerHTML = val;
+ textNode.parentNode.replaceChild(node, textNode);
+ }
+
}
/**
@@ -42,7 +82,7 @@ function decorateHTML(view, html) {
const widgets = [];
if (html.length) {
const deco = Decoration.widget({
- widget: new HTMLWidget(html),
+ widget: new HTMLWidget(html, view),
side: 1
});
widgets.push(deco.range(0));
@@ -79,7 +119,8 @@ export function htmlPlugin(htmlOutput) {
}
}
}, {
- decorations: v => v.decorations
+ decorations: v => v.decorations,
+
}
);
diff --git a/src/web/waiters/OptionsWaiter.mjs b/src/web/waiters/OptionsWaiter.mjs
index 7d9a3e2d..36beef7e 100755
--- a/src/web/waiters/OptionsWaiter.mjs
+++ b/src/web/waiters/OptionsWaiter.mjs
@@ -141,13 +141,6 @@ class OptionsWaiter {
setWordWrap() {
this.manager.input.setWordWrap(this.app.options.wordWrap);
this.manager.output.setWordWrap(this.app.options.wordWrap);
- document.getElementById("input-highlighter").classList.remove("word-wrap");
- document.getElementById("output-highlighter").classList.remove("word-wrap");
-
- if (!this.app.options.wordWrap) {
- document.getElementById("input-highlighter").classList.add("word-wrap");
- document.getElementById("output-highlighter").classList.add("word-wrap");
- }
}
diff --git a/src/web/waiters/OutputWaiter.mjs b/src/web/waiters/OutputWaiter.mjs
index 3f031ac7..deaeaed3 100755
--- a/src/web/waiters/OutputWaiter.mjs
+++ b/src/web/waiters/OutputWaiter.mjs
@@ -5,7 +5,7 @@
* @license Apache-2.0
*/
-import Utils, { debounce } from "../../core/Utils.mjs";
+import Utils, {debounce} from "../../core/Utils.mjs";
import Dish from "../../core/Dish.mjs";
import FileSaver from "file-saver";
import ZipWorker from "worker-loader?inline=no-fallback!../workers/ZipWorker.mjs";
@@ -19,8 +19,9 @@ import {bracketMatching} from "@codemirror/language";
import {search, searchKeymap, highlightSelectionMatches} from "@codemirror/search";
import {statusBar} from "../utils/statusBar.mjs";
-import {renderSpecialChar} from "../utils/editorUtils.mjs";
import {htmlPlugin} from "../utils/htmlWidget.mjs";
+import {copyOverride} from "../utils/copyOverride.mjs";
+import {renderSpecialChar} from "../utils/editorUtils.mjs";
/**
* Waiter to handle events related to the output
@@ -61,7 +62,8 @@ class OutputWaiter {
initEditor() {
this.outputEditorConf = {
eol: new Compartment,
- lineWrapping: new Compartment
+ lineWrapping: new Compartment,
+ drawSelection: new Compartment
};
const initialState = EditorState.create({
@@ -69,9 +71,10 @@ class OutputWaiter {
extensions: [
// Editor extensions
EditorState.readOnly.of(true),
- htmlPlugin(this.htmlOutput),
- highlightSpecialChars({render: renderSpecialChar}),
- drawSelection(),
+ highlightSpecialChars({
+ render: renderSpecialChar, // Custom character renderer to handle special cases
+ addSpecialChars: /[\ue000-\uf8ff]/g // Add the Unicode Private Use Area which we use for some whitespace chars
+ }),
rectangularSelection(),
crosshairCursor(),
bracketMatching(),
@@ -79,16 +82,19 @@ class OutputWaiter {
search({top: true}),
EditorState.allowMultipleSelections.of(true),
- // Custom extensiosn
+ // Custom extensions
statusBar({
label: "Output",
bakeStats: this.bakeStats,
eolHandler: this.eolChange.bind(this)
}),
+ htmlPlugin(this.htmlOutput),
+ copyOverride(),
// Mutable state
this.outputEditorConf.lineWrapping.of(EditorView.lineWrapping),
this.outputEditorConf.eol.of(EditorState.lineSeparator.of("\n")),
+ this.outputEditorConf.drawSelection.of(drawSelection()),
// Keymap
keymap.of([
@@ -153,6 +159,14 @@ class OutputWaiter {
* @param {string} data
*/
setOutput(data) {
+ // Turn drawSelection back on
+ this.outputEditorView.dispatch({
+ effects: this.outputEditorConf.drawSelection.reconfigure(
+ drawSelection()
+ )
+ });
+
+ // Insert data into editor
this.outputEditorView.dispatch({
changes: {
from: 0,
@@ -173,6 +187,11 @@ class OutputWaiter {
// triggers the htmlWidget to render the HTML.
this.setOutput("");
+ // Turn off drawSelection
+ this.outputEditorView.dispatch({
+ effects: this.outputEditorConf.drawSelection.reconfigure([])
+ });
+
// Execute script sections
const scriptElements = document.getElementById("output-html").querySelectorAll("script");
for (let i = 0; i < scriptElements.length; i++) {
@@ -414,8 +433,6 @@ class OutputWaiter {
if (typeof inputNum !== "number") inputNum = parseInt(inputNum, 10);
const outputFile = document.getElementById("output-file");
- const outputHighlighter = document.getElementById("output-highlighter");
- const inputHighlighter = document.getElementById("input-highlighter");
// If pending or baking, show loader and status message
// If error, style the tab and handle the error
@@ -447,8 +464,6 @@ class OutputWaiter {
this.outputTextEl.style.display = "block";
this.outputTextEl.classList.remove("blur");
outputFile.style.display = "none";
- outputHighlighter.display = "none";
- inputHighlighter.display = "none";
this.clearHTMLOutput();
if (output.error) {
@@ -463,8 +478,6 @@ class OutputWaiter {
if (output.data === null) {
this.outputTextEl.style.display = "block";
outputFile.style.display = "none";
- outputHighlighter.display = "block";
- inputHighlighter.display = "block";
this.clearHTMLOutput();
this.setOutput("");
@@ -478,15 +491,11 @@ class OutputWaiter {
switch (output.data.type) {
case "html":
outputFile.style.display = "none";
- outputHighlighter.style.display = "none";
- inputHighlighter.style.display = "none";
this.setHTMLOutput(output.data.result);
break;
case "ArrayBuffer":
this.outputTextEl.style.display = "block";
- outputHighlighter.display = "none";
- inputHighlighter.display = "none";
this.clearHTMLOutput();
this.setOutput("");
@@ -497,8 +506,6 @@ class OutputWaiter {
default:
this.outputTextEl.style.display = "block";
outputFile.style.display = "none";
- outputHighlighter.display = "block";
- inputHighlighter.display = "block";
this.clearHTMLOutput();
this.setOutput(output.data.result);
@@ -1215,8 +1222,6 @@ class OutputWaiter {
document.querySelector("#output-loader .loading-msg").textContent = "Loading file slice...";
this.toggleLoader(true);
const outputFile = document.getElementById("output-file"),
- outputHighlighter = document.getElementById("output-highlighter"),
- inputHighlighter = document.getElementById("input-highlighter"),
showFileOverlay = document.getElementById("show-file-overlay"),
sliceFromEl = document.getElementById("output-file-slice-from"),
sliceToEl = document.getElementById("output-file-slice-to"),
@@ -1238,8 +1243,6 @@ class OutputWaiter {
this.outputTextEl.style.display = "block";
outputFile.style.display = "none";
- outputHighlighter.display = "block";
- inputHighlighter.display = "block";
this.toggleLoader(false);
}
@@ -1251,8 +1254,6 @@ class OutputWaiter {
document.querySelector("#output-loader .loading-msg").textContent = "Loading entire file at user instruction. This may cause a crash...";
this.toggleLoader(true);
const outputFile = document.getElementById("output-file"),
- outputHighlighter = document.getElementById("output-highlighter"),
- inputHighlighter = document.getElementById("input-highlighter"),
showFileOverlay = document.getElementById("show-file-overlay"),
output = this.outputs[this.manager.tabs.getActiveOutputTab()].data;
@@ -1270,8 +1271,6 @@ class OutputWaiter {
this.outputTextEl.style.display = "block";
outputFile.style.display = "none";
- outputHighlighter.display = "block";
- inputHighlighter.display = "block";
this.toggleLoader(false);
}
@@ -1319,36 +1318,13 @@ class OutputWaiter {
}
const output = await dish.get(Dish.STRING);
+ const self = this;
- // Create invisible textarea to populate with the raw dish string (not the printable version that
- // contains dots instead of the actual bytes)
- const textarea = document.createElement("textarea");
- textarea.style.position = "fixed";
- textarea.style.top = 0;
- textarea.style.left = 0;
- textarea.style.width = 0;
- textarea.style.height = 0;
- textarea.style.border = "none";
-
- textarea.value = output;
- document.body.appendChild(textarea);
-
- let success = false;
- try {
- textarea.select();
- success = output && document.execCommand("copy");
- } catch (err) {
- success = false;
- }
-
- if (success) {
- this.app.alert("Copied raw output successfully.", 2000);
- } else {
- this.app.alert("Sorry, the output could not be copied.", 3000);
- }
-
- // Clean up
- document.body.removeChild(textarea);
+ navigator.clipboard.writeText(output).then(function() {
+ self.app.alert("Copied raw output successfully.", 2000);
+ }, function(err) {
+ self.app.alert("Sorry, the output could not be copied.", 3000);
+ });
}
/**
diff --git a/src/web/waiters/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs
index f4107e66..d907a67c 100755
--- a/src/web/waiters/RecipeWaiter.mjs
+++ b/src/web/waiters/RecipeWaiter.mjs
@@ -7,6 +7,7 @@
import HTMLOperation from "../HTMLOperation.mjs";
import Sortable from "sortablejs";
import Utils from "../../core/Utils.mjs";
+import {escapeControlChars} from "../utils/editorUtils.mjs";
/**
@@ -568,7 +569,7 @@ class RecipeWaiter {
const registerList = [];
for (let i = 0; i < registers.length; i++) {
- registerList.push(`$R${numPrevRegisters + i} = ${Utils.escapeHtml(Utils.truncate(Utils.printable(registers[i]), 100))}`);
+ registerList.push(`$R${numPrevRegisters + i} = ${escapeControlChars(Utils.escapeHtml(Utils.truncate(registers[i], 100)))}`);
}
const registerListEl = `
${registerList.join("
")}