diff --git a/src/web/utils/editorUtils.mjs b/src/web/utils/editorUtils.mjs
index e02e692b..fe1b6749 100644
--- a/src/web/utils/editorUtils.mjs
+++ b/src/web/utils/editorUtils.mjs
@@ -95,3 +95,42 @@ export function escapeControlChars(str, preserveWs=false, lineBreak="\n") {
return n.outerHTML;
});
}
+
+/**
+ * Convert and EOL sequence to its name
+ */
+export const eolSeqToCode = {
+ "\u000a": "LF",
+ "\u000b": "VT",
+ "\u000c": "FF",
+ "\u000d": "CR",
+ "\u000d\u000a": "CRLF",
+ "\u0085": "NEL",
+ "\u2028": "LS",
+ "\u2029": "PS"
+};
+
+/**
+ * Convert an EOL name to its sequence
+ */
+export const eolCodeToSeq = {
+ "LF": "\u000a",
+ "VT": "\u000b",
+ "FF": "\u000c",
+ "CR": "\u000d",
+ "CRLF": "\u000d\u000a",
+ "NEL": "\u0085",
+ "LS": "\u2028",
+ "PS": "\u2029"
+};
+
+export const eolCodeToName = {
+ "LF": "Line Feed",
+ "VT": "Vertical Tab",
+ "FF": "Form Feed",
+ "CR": "Carriage Return",
+ "CRLF": "Carriage Return + Line Feed",
+ "NEL": "Next Line",
+ "LS": "Line Separator",
+ "PS": "Paragraph Separator"
+};
diff --git a/src/web/utils/statusBar.mjs b/src/web/utils/statusBar.mjs
index 6469379a..69c4dd51 100644
--- a/src/web/utils/statusBar.mjs
+++ b/src/web/utils/statusBar.mjs
@@ -6,6 +6,7 @@
import {showPanel} from "@codemirror/view";
import {CHR_ENC_SIMPLE_LOOKUP, CHR_ENC_SIMPLE_REVERSE_LOOKUP} from "../../core/lib/ChrEnc.mjs";
+import { eolCodeToName, eolSeqToCode } from "./editorUtils.mjs";
/**
* A Status bar extension for CodeMirror
@@ -92,22 +93,12 @@ class StatusBarPanel {
// preventDefault is required to stop the URL being modified and popState being triggered
e.preventDefault();
- const eolLookup = {
- "LF": "\u000a",
- "VT": "\u000b",
- "FF": "\u000c",
- "CR": "\u000d",
- "CRLF": "\u000d\u000a",
- "NEL": "\u0085",
- "LS": "\u2028",
- "PS": "\u2029"
- };
- const eolval = eolLookup[e.target.getAttribute("data-val")];
-
- if (eolval === undefined) return;
+ const eolCode = e.target.getAttribute("data-val");
+ if (!eolCode) return;
// Call relevant EOL change handler
- this.eolHandler(eolval);
+ this.eolHandler(e.target.getAttribute("data-val"), true);
+
hideElement(e.target.closest(".cm-status-bar-select-content"));
}
@@ -223,23 +214,13 @@ class StatusBarPanel {
updateEOL(state) {
if (state.lineBreak === this.eolVal) return;
- const eolLookup = {
- "\u000a": ["LF", "Line Feed"],
- "\u000b": ["VT", "Vertical Tab"],
- "\u000c": ["FF", "Form Feed"],
- "\u000d": ["CR", "Carriage Return"],
- "\u000d\u000a": ["CRLF", "Carriage Return + Line Feed"],
- "\u0085": ["NEL", "Next Line"],
- "\u2028": ["LS", "Line Separator"],
- "\u2029": ["PS", "Paragraph Separator"]
- };
-
const val = this.dom.querySelector(".eol-value");
const button = val.closest(".cm-status-bar-select-btn");
- const eolName = eolLookup[state.lineBreak];
- val.textContent = eolName[0];
- button.setAttribute("title", `End of line sequence:
${eolName[1]}`);
- button.setAttribute("data-original-title", `End of line sequence:
${eolName[1]}`);
+ const eolCode = eolSeqToCode[state.lineBreak];
+ const eolName = eolCodeToName[eolCode];
+ val.textContent = eolCode;
+ button.setAttribute("title", `End of line sequence:
${eolName}`);
+ button.setAttribute("data-original-title", `End of line sequence:
${eolName}`);
this.eolVal = state.lineBreak;
}
diff --git a/src/web/waiters/ControlsWaiter.mjs b/src/web/waiters/ControlsWaiter.mjs
index 6a0ef6f2..660a7ec5 100755
--- a/src/web/waiters/ControlsWaiter.mjs
+++ b/src/web/waiters/ControlsWaiter.mjs
@@ -5,6 +5,7 @@
*/
import Utils from "../../core/Utils.mjs";
+import { eolSeqToCode } from "../utils/editorUtils.mjs";
/**
@@ -140,16 +141,16 @@ class ControlsWaiter {
const inputChrEnc = this.manager.input.getChrEnc();
const outputChrEnc = this.manager.output.getChrEnc();
- const inputEOLSeq = this.manager.input.getEOLSeq();
- const outputEOLSeq = this.manager.output.getEOLSeq();
+ const inputEOL = eolSeqToCode[this.manager.input.getEOLSeq()];
+ const outputEOL = eolSeqToCode[this.manager.output.getEOLSeq()];
const params = [
includeRecipe ? ["recipe", recipeStr] : undefined,
includeInput && input.length ? ["input", Utils.escapeHtml(input)] : undefined,
inputChrEnc !== 0 ? ["ienc", inputChrEnc] : undefined,
outputChrEnc !== 0 ? ["oenc", outputChrEnc] : undefined,
- inputEOLSeq !== "\n" ? ["ieol", inputEOLSeq] : undefined,
- outputEOLSeq !== "\n" ? ["oeol", outputEOLSeq] : undefined
+ inputEOL !== "LF" ? ["ieol", inputEOL] : undefined,
+ outputEOL !== "LF" ? ["oeol", outputEOL] : undefined
];
const hash = params
diff --git a/src/web/waiters/InputWaiter.mjs b/src/web/waiters/InputWaiter.mjs
index 25c1629d..ad8eb38c 100644
--- a/src/web/waiters/InputWaiter.mjs
+++ b/src/web/waiters/InputWaiter.mjs
@@ -42,7 +42,7 @@ import {
import {statusBar} from "../utils/statusBar.mjs";
import {fileDetailsPanel} from "../utils/fileDetails.mjs";
-import {renderSpecialChar} from "../utils/editorUtils.mjs";
+import {eolCodeToSeq, eolCodeToName, renderSpecialChar} from "../utils/editorUtils.mjs";
/**
@@ -62,6 +62,7 @@ class InputWaiter {
this.inputTextEl = document.getElementById("input-text");
this.inputChrEnc = 0;
+ this.eolSetManually = false;
this.initEditor();
this.inputWorker = null;
@@ -92,6 +93,7 @@ class InputWaiter {
fileDetailsPanel: new Compartment
};
+ const self = this;
const initialState = EditorState.create({
doc: null,
extensions: [
@@ -141,6 +143,15 @@ class InputWaiter {
if (e.docChanged && !this.silentInputChange)
this.inputChange(e);
this.silentInputChange = false;
+ }),
+
+ // Event handlers
+ EditorView.domEventHandlers({
+ paste(event, view) {
+ setTimeout(() => {
+ self.afterPaste(event);
+ });
+ }
})
]
});
@@ -154,12 +165,35 @@ class InputWaiter {
/**
* Handler for EOL change events
* Sets the line separator
- * @param {string} eolVal
+ * @param {string} eol
+ * @param {boolean} manual - a flag for whether this was set by the user or automatically
*/
- eolChange(eolVal) {
- const oldInputVal = this.getInput();
+ eolChange(eol, manual=false) {
+ const eolVal = eolCodeToSeq[eol];
+ if (eolVal === undefined) return;
+
+ const eolBtn = document.querySelector("#input-text .eol-value");
+ if (manual) {
+ this.eolSetManually = true;
+ eolBtn.classList.remove("font-italic");
+ } else {
+ eolBtn.classList.add("font-italic");
+ }
+
+ if (eolVal === this.getEOLSeq()) return;
+
+ if (!manual) {
+ // Pulse
+ eolBtn.classList.add("pulse");
+ setTimeout(() => {
+ eolBtn.classList.remove("pulse");
+ }, 2000);
+ // Alert
+ this.app.alert(`Input EOL separator has been changed to ${eolCodeToName[eol]}`, 5000);
+ }
// Update the EOL value
+ const oldInputVal = this.getInput();
this.inputEditorView.dispatch({
effects: this.inputEditorConf.eol.reconfigure(EditorState.lineSeparator.of(eolVal))
});
@@ -866,6 +900,49 @@ class InputWaiter {
}, delay, "inputChange", this, [e])();
}
+ /**
+ * Handler that fires just after input paste events.
+ * Checks whether the EOL separator or character encoding should be updated.
+ *
+ * @param {event} e
+ */
+ afterPaste(e) {
+ // If EOL has been fixed, skip this.
+ if (this.eolSetManually) return;
+
+ const inputText = this.getInput();
+
+ // Detect most likely EOL sequence
+ const eolCharCounts = {
+ "LF": inputText.count("\u000a"),
+ "VT": inputText.count("\u000b"),
+ "FF": inputText.count("\u000c"),
+ "CR": inputText.count("\u000d"),
+ "CRLF": inputText.count("\u000d\u000a"),
+ "NEL": inputText.count("\u0085"),
+ "LS": inputText.count("\u2028"),
+ "PS": inputText.count("\u2029")
+ };
+
+ // If all zero, leave alone
+ const total = Object.values(eolCharCounts).reduce((acc, curr) => {
+ return acc + curr;
+ }, 0);
+ if (total === 0) return;
+
+ // If CRLF not zero and more than half the highest alternative, choose CRLF
+ const highest = Object.entries(eolCharCounts).reduce((acc, curr) => {
+ return curr[1] > acc[1] ? curr : acc;
+ }, ["LF", 0]);
+ if ((eolCharCounts.CRLF * 2) > highest[1]) {
+ this.eolChange("CRLF");
+ return;
+ }
+
+ // Else choose max
+ this.eolChange(highest[0]);
+ }
+
/**
* Handler for input dragover events.
* Gives the user a visual cue to show that items can be dropped here.
@@ -1199,6 +1276,9 @@ class InputWaiter {
this.manager.output.removeAllOutputs();
this.manager.output.terminateZipWorker();
+ this.eolSetManually = false;
+ this.manager.output.eolSetManually = false;
+
const tabsList = document.getElementById("input-tabs");
const tabsListChildren = tabsList.children;
diff --git a/src/web/waiters/OutputWaiter.mjs b/src/web/waiters/OutputWaiter.mjs
index dae27f3e..6acd6752 100755
--- a/src/web/waiters/OutputWaiter.mjs
+++ b/src/web/waiters/OutputWaiter.mjs
@@ -38,7 +38,7 @@ import {
import {statusBar} from "../utils/statusBar.mjs";
import {htmlPlugin} from "../utils/htmlWidget.mjs";
import {copyOverride} from "../utils/copyOverride.mjs";
-import {renderSpecialChar} from "../utils/editorUtils.mjs";
+import {eolCodeToSeq, eolCodeToName, renderSpecialChar} from "../utils/editorUtils.mjs";
/**
@@ -70,6 +70,7 @@ class OutputWaiter {
this.zipWorker = null;
this.maxTabs = this.manager.tabs.calcMaxTabs();
this.tabTimeout = null;
+ this.eolSetManually = false;
}
/**
@@ -146,9 +147,33 @@ class OutputWaiter {
/**
* Handler for EOL change events
* Sets the line separator
- * @param {string} eolVal
+ * @param {string} eol
+ * @param {boolean} manual - a flag for whether this was set by the user or automatically
*/
- async eolChange(eolVal) {
+ async eolChange(eol, manual=false) {
+ const eolVal = eolCodeToSeq[eol];
+ if (eolVal === undefined) return;
+
+ const eolBtn = document.querySelector("#output-text .eol-value");
+ if (manual) {
+ this.eolSetManually = true;
+ eolBtn.classList.remove("font-italic");
+ } else {
+ eolBtn.classList.add("font-italic");
+ }
+
+ if (eolVal === this.getEOLSeq()) return;
+
+ if (!manual) {
+ // Pulse
+ eolBtn.classList.add("pulse");
+ setTimeout(() => {
+ eolBtn.classList.remove("pulse");
+ }, 2000);
+ // Alert
+ this.app.alert(`Output EOL separator has been changed to ${eolCodeToName[eol]}`, 5000);
+ }
+
const currentTabNum = this.manager.tabs.getActiveTab("output");
if (currentTabNum >= 0) {
this.outputs[currentTabNum].eolSequence = eolVal;
@@ -276,6 +301,9 @@ class OutputWaiter {
// If turning word wrap off, do it before we populate the editor for performance reasons
if (!wrap) this.setWordWrap(wrap);
+ // Detect suitable EOL sequence
+ this.detectEOLSequence(data);
+
// We use setTimeout here to delay the editor dispatch until the next event cycle,
// ensuring all async actions have completed before attempting to set the contents
// of the editor. This is mainly with the above call to setWordWrap() in mind.
@@ -345,6 +373,48 @@ class OutputWaiter {
});
}
+ /**
+ * Checks whether the EOL separator should be updated
+ *
+ * @param {string} data
+ */
+ detectEOLSequence(data) {
+ // If EOL has been fixed, skip this.
+ if (this.eolSetManually) return;
+ // If data is too long, skip this.
+ if (data.length > 1000000) return;
+
+ // Detect most likely EOL sequence
+ const eolCharCounts = {
+ "LF": data.count("\u000a"),
+ "VT": data.count("\u000b"),
+ "FF": data.count("\u000c"),
+ "CR": data.count("\u000d"),
+ "CRLF": data.count("\u000d\u000a"),
+ "NEL": data.count("\u0085"),
+ "LS": data.count("\u2028"),
+ "PS": data.count("\u2029")
+ };
+
+ // If all zero, leave alone
+ const total = Object.values(eolCharCounts).reduce((acc, curr) => {
+ return acc + curr;
+ }, 0);
+ if (total === 0) return;
+
+ // If CRLF not zero and more than half the highest alternative, choose CRLF
+ const highest = Object.entries(eolCharCounts).reduce((acc, curr) => {
+ return curr[1] > acc[1] ? curr : acc;
+ }, ["LF", 0]);
+ if ((eolCharCounts.CRLF * 2) > highest[1]) {
+ this.eolChange("CRLF");
+ return;
+ }
+
+ // Else choose max
+ this.eolChange(highest[0]);
+ }
+
/**
* Calculates the maximum number of tabs to display
*/
diff --git a/tests/browser/00_nightwatch.js b/tests/browser/00_nightwatch.js
index 3ba2a865..2e5688d7 100644
--- a/tests/browser/00_nightwatch.js
+++ b/tests/browser/00_nightwatch.js
@@ -230,6 +230,7 @@ module.exports = {
// Alert bar shows and contains correct content
browser
+ .waitForElementNotVisible("#snackbar-container")
.click("#copy-output")
.waitForElementVisible("#snackbar-container")
.waitForElementVisible("#snackbar-container .snackbar-content")
diff --git a/tests/browser/01_io.js b/tests/browser/01_io.js
index 67d1fdff..6791c88e 100644
--- a/tests/browser/01_io.js
+++ b/tests/browser/01_io.js
@@ -545,8 +545,8 @@ module.exports = {
browser.expect.element("#output-text .cm-status-bar .stats-lines-value").text.to.equal("2");
/* Line endings appear in the URL */
- browser.assert.urlContains("ieol=%0D%0A");
- browser.assert.urlContains("oeol=%0D");
+ browser.assert.urlContains("ieol=CRLF");
+ browser.assert.urlContains("oeol=CR");
/* Preserved when changing tabs */
browser
@@ -643,7 +643,7 @@ module.exports = {
"Loading from URL": browser => {
/* Complex deep link populates the input correctly (encoding, eol, input) */
browser
- .urlHash("recipe=To_Base64('A-Za-z0-9%2B/%3D')&input=VGhlIHNoaXBzIGh1bmcgaW4gdGhlIHNreSBpbiBtdWNoIHRoZSBzYW1lIHdheSB0aGF0IGJyaWNrcyBkb24ndC4M&ienc=21866&oenc=1201&ieol=%0C&oeol=%E2%80%A9")
+ .urlHash("recipe=To_Base64('A-Za-z0-9%2B/%3D')&input=VGhlIHNoaXBzIGh1bmcgaW4gdGhlIHNreSBpbiBtdWNoIHRoZSBzYW1lIHdheSB0aGF0IGJyaWNrcyBkb24ndC4M&ienc=21866&oenc=1201&ieol=FF&oeol=PS")
.waitForElementVisible("#rec-list li.operation");
browser.expect.element(`#input-text .cm-content`).to.have.property("textContent").match(/^.{65}$/);