Skip to content

Commit 16dfb3f

Browse files
committed
Automatically detect EOL from paste events and output setting
1 parent c4e7c41 commit 16dfb3f

7 files changed

Lines changed: 215 additions & 43 deletions

File tree

src/web/utils/editorUtils.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,42 @@ export function escapeControlChars(str, preserveWs=false, lineBreak="\n") {
9595
return n.outerHTML;
9696
});
9797
}
98+
99+
/**
100+
* Convert and EOL sequence to its name
101+
*/
102+
export const eolSeqToCode = {
103+
"\u000a": "LF",
104+
"\u000b": "VT",
105+
"\u000c": "FF",
106+
"\u000d": "CR",
107+
"\u000d\u000a": "CRLF",
108+
"\u0085": "NEL",
109+
"\u2028": "LS",
110+
"\u2029": "PS"
111+
};
112+
113+
/**
114+
* Convert an EOL name to its sequence
115+
*/
116+
export const eolCodeToSeq = {
117+
"LF": "\u000a",
118+
"VT": "\u000b",
119+
"FF": "\u000c",
120+
"CR": "\u000d",
121+
"CRLF": "\u000d\u000a",
122+
"NEL": "\u0085",
123+
"LS": "\u2028",
124+
"PS": "\u2029"
125+
};
126+
127+
export const eolCodeToName = {
128+
"LF": "Line Feed",
129+
"VT": "Vertical Tab",
130+
"FF": "Form Feed",
131+
"CR": "Carriage Return",
132+
"CRLF": "Carriage Return + Line Feed",
133+
"NEL": "Next Line",
134+
"LS": "Line Separator",
135+
"PS": "Paragraph Separator"
136+
};

src/web/utils/statusBar.mjs

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import {showPanel} from "@codemirror/view";
88
import {CHR_ENC_SIMPLE_LOOKUP, CHR_ENC_SIMPLE_REVERSE_LOOKUP} from "../../core/lib/ChrEnc.mjs";
9+
import { eolCodeToName, eolSeqToCode } from "./editorUtils.mjs";
910

1011
/**
1112
* A Status bar extension for CodeMirror
@@ -92,22 +93,12 @@ class StatusBarPanel {
9293
// preventDefault is required to stop the URL being modified and popState being triggered
9394
e.preventDefault();
9495

95-
const eolLookup = {
96-
"LF": "\u000a",
97-
"VT": "\u000b",
98-
"FF": "\u000c",
99-
"CR": "\u000d",
100-
"CRLF": "\u000d\u000a",
101-
"NEL": "\u0085",
102-
"LS": "\u2028",
103-
"PS": "\u2029"
104-
};
105-
const eolval = eolLookup[e.target.getAttribute("data-val")];
106-
107-
if (eolval === undefined) return;
96+
const eolCode = e.target.getAttribute("data-val");
97+
if (!eolCode) return;
10898

10999
// Call relevant EOL change handler
110-
this.eolHandler(eolval);
100+
this.eolHandler(e.target.getAttribute("data-val"), true);
101+
111102
hideElement(e.target.closest(".cm-status-bar-select-content"));
112103
}
113104

@@ -223,23 +214,13 @@ class StatusBarPanel {
223214
updateEOL(state) {
224215
if (state.lineBreak === this.eolVal) return;
225216

226-
const eolLookup = {
227-
"\u000a": ["LF", "Line Feed"],
228-
"\u000b": ["VT", "Vertical Tab"],
229-
"\u000c": ["FF", "Form Feed"],
230-
"\u000d": ["CR", "Carriage Return"],
231-
"\u000d\u000a": ["CRLF", "Carriage Return + Line Feed"],
232-
"\u0085": ["NEL", "Next Line"],
233-
"\u2028": ["LS", "Line Separator"],
234-
"\u2029": ["PS", "Paragraph Separator"]
235-
};
236-
237217
const val = this.dom.querySelector(".eol-value");
238218
const button = val.closest(".cm-status-bar-select-btn");
239-
const eolName = eolLookup[state.lineBreak];
240-
val.textContent = eolName[0];
241-
button.setAttribute("title", `End of line sequence:<br>${eolName[1]}`);
242-
button.setAttribute("data-original-title", `End of line sequence:<br>${eolName[1]}`);
219+
const eolCode = eolSeqToCode[state.lineBreak];
220+
const eolName = eolCodeToName[eolCode];
221+
val.textContent = eolCode;
222+
button.setAttribute("title", `End of line sequence:<br>${eolName}`);
223+
button.setAttribute("data-original-title", `End of line sequence:<br>${eolName}`);
243224
this.eolVal = state.lineBreak;
244225
}
245226

src/web/waiters/ControlsWaiter.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import Utils from "../../core/Utils.mjs";
8+
import { eolSeqToCode } from "../utils/editorUtils.mjs";
89

910

1011
/**
@@ -140,16 +141,16 @@ class ControlsWaiter {
140141

141142
const inputChrEnc = this.manager.input.getChrEnc();
142143
const outputChrEnc = this.manager.output.getChrEnc();
143-
const inputEOLSeq = this.manager.input.getEOLSeq();
144-
const outputEOLSeq = this.manager.output.getEOLSeq();
144+
const inputEOL = eolSeqToCode[this.manager.input.getEOLSeq()];
145+
const outputEOL = eolSeqToCode[this.manager.output.getEOLSeq()];
145146

146147
const params = [
147148
includeRecipe ? ["recipe", recipeStr] : undefined,
148149
includeInput && input.length ? ["input", Utils.escapeHtml(input)] : undefined,
149150
inputChrEnc !== 0 ? ["ienc", inputChrEnc] : undefined,
150151
outputChrEnc !== 0 ? ["oenc", outputChrEnc] : undefined,
151-
inputEOLSeq !== "\n" ? ["ieol", inputEOLSeq] : undefined,
152-
outputEOLSeq !== "\n" ? ["oeol", outputEOLSeq] : undefined
152+
inputEOL !== "LF" ? ["ieol", inputEOL] : undefined,
153+
outputEOL !== "LF" ? ["oeol", outputEOL] : undefined
153154
];
154155

155156
const hash = params

src/web/waiters/InputWaiter.mjs

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242

4343
import {statusBar} from "../utils/statusBar.mjs";
4444
import {fileDetailsPanel} from "../utils/fileDetails.mjs";
45-
import {renderSpecialChar} from "../utils/editorUtils.mjs";
45+
import {eolCodeToSeq, eolCodeToName, renderSpecialChar} from "../utils/editorUtils.mjs";
4646

4747

4848
/**
@@ -62,6 +62,7 @@ class InputWaiter {
6262

6363
this.inputTextEl = document.getElementById("input-text");
6464
this.inputChrEnc = 0;
65+
this.eolSetManually = false;
6566
this.initEditor();
6667

6768
this.inputWorker = null;
@@ -92,6 +93,7 @@ class InputWaiter {
9293
fileDetailsPanel: new Compartment
9394
};
9495

96+
const self = this;
9597
const initialState = EditorState.create({
9698
doc: null,
9799
extensions: [
@@ -141,6 +143,15 @@ class InputWaiter {
141143
if (e.docChanged && !this.silentInputChange)
142144
this.inputChange(e);
143145
this.silentInputChange = false;
146+
}),
147+
148+
// Event handlers
149+
EditorView.domEventHandlers({
150+
paste(event, view) {
151+
setTimeout(() => {
152+
self.afterPaste(event);
153+
});
154+
}
144155
})
145156
]
146157
});
@@ -154,12 +165,35 @@ class InputWaiter {
154165
/**
155166
* Handler for EOL change events
156167
* Sets the line separator
157-
* @param {string} eolVal
168+
* @param {string} eol
169+
* @param {boolean} manual - a flag for whether this was set by the user or automatically
158170
*/
159-
eolChange(eolVal) {
160-
const oldInputVal = this.getInput();
171+
eolChange(eol, manual=false) {
172+
const eolVal = eolCodeToSeq[eol];
173+
if (eolVal === undefined) return;
174+
175+
const eolBtn = document.querySelector("#input-text .eol-value");
176+
if (manual) {
177+
this.eolSetManually = true;
178+
eolBtn.classList.remove("font-italic");
179+
} else {
180+
eolBtn.classList.add("font-italic");
181+
}
182+
183+
if (eolVal === this.getEOLSeq()) return;
184+
185+
if (!manual) {
186+
// Pulse
187+
eolBtn.classList.add("pulse");
188+
setTimeout(() => {
189+
eolBtn.classList.remove("pulse");
190+
}, 2000);
191+
// Alert
192+
this.app.alert(`Input EOL separator has been changed to ${eolCodeToName[eol]}`, 5000);
193+
}
161194

162195
// Update the EOL value
196+
const oldInputVal = this.getInput();
163197
this.inputEditorView.dispatch({
164198
effects: this.inputEditorConf.eol.reconfigure(EditorState.lineSeparator.of(eolVal))
165199
});
@@ -866,6 +900,49 @@ class InputWaiter {
866900
}, delay, "inputChange", this, [e])();
867901
}
868902

903+
/**
904+
* Handler that fires just after input paste events.
905+
* Checks whether the EOL separator or character encoding should be updated.
906+
*
907+
* @param {event} e
908+
*/
909+
afterPaste(e) {
910+
// If EOL has been fixed, skip this.
911+
if (this.eolSetManually) return;
912+
913+
const inputText = this.getInput();
914+
915+
// Detect most likely EOL sequence
916+
const eolCharCounts = {
917+
"LF": inputText.count("\u000a"),
918+
"VT": inputText.count("\u000b"),
919+
"FF": inputText.count("\u000c"),
920+
"CR": inputText.count("\u000d"),
921+
"CRLF": inputText.count("\u000d\u000a"),
922+
"NEL": inputText.count("\u0085"),
923+
"LS": inputText.count("\u2028"),
924+
"PS": inputText.count("\u2029")
925+
};
926+
927+
// If all zero, leave alone
928+
const total = Object.values(eolCharCounts).reduce((acc, curr) => {
929+
return acc + curr;
930+
}, 0);
931+
if (total === 0) return;
932+
933+
// If CRLF not zero and more than half the highest alternative, choose CRLF
934+
const highest = Object.entries(eolCharCounts).reduce((acc, curr) => {
935+
return curr[1] > acc[1] ? curr : acc;
936+
}, ["LF", 0]);
937+
if ((eolCharCounts.CRLF * 2) > highest[1]) {
938+
this.eolChange("CRLF");
939+
return;
940+
}
941+
942+
// Else choose max
943+
this.eolChange(highest[0]);
944+
}
945+
869946
/**
870947
* Handler for input dragover events.
871948
* Gives the user a visual cue to show that items can be dropped here.
@@ -1199,6 +1276,9 @@ class InputWaiter {
11991276
this.manager.output.removeAllOutputs();
12001277
this.manager.output.terminateZipWorker();
12011278

1279+
this.eolSetManually = false;
1280+
this.manager.output.eolSetManually = false;
1281+
12021282
const tabsList = document.getElementById("input-tabs");
12031283
const tabsListChildren = tabsList.children;
12041284

src/web/waiters/OutputWaiter.mjs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
import {statusBar} from "../utils/statusBar.mjs";
3939
import {htmlPlugin} from "../utils/htmlWidget.mjs";
4040
import {copyOverride} from "../utils/copyOverride.mjs";
41-
import {renderSpecialChar} from "../utils/editorUtils.mjs";
41+
import {eolCodeToSeq, eolCodeToName, renderSpecialChar} from "../utils/editorUtils.mjs";
4242

4343

4444
/**
@@ -70,6 +70,7 @@ class OutputWaiter {
7070
this.zipWorker = null;
7171
this.maxTabs = this.manager.tabs.calcMaxTabs();
7272
this.tabTimeout = null;
73+
this.eolSetManually = false;
7374
}
7475

7576
/**
@@ -146,9 +147,33 @@ class OutputWaiter {
146147
/**
147148
* Handler for EOL change events
148149
* Sets the line separator
149-
* @param {string} eolVal
150+
* @param {string} eol
151+
* @param {boolean} manual - a flag for whether this was set by the user or automatically
150152
*/
151-
async eolChange(eolVal) {
153+
async eolChange(eol, manual=false) {
154+
const eolVal = eolCodeToSeq[eol];
155+
if (eolVal === undefined) return;
156+
157+
const eolBtn = document.querySelector("#output-text .eol-value");
158+
if (manual) {
159+
this.eolSetManually = true;
160+
eolBtn.classList.remove("font-italic");
161+
} else {
162+
eolBtn.classList.add("font-italic");
163+
}
164+
165+
if (eolVal === this.getEOLSeq()) return;
166+
167+
if (!manual) {
168+
// Pulse
169+
eolBtn.classList.add("pulse");
170+
setTimeout(() => {
171+
eolBtn.classList.remove("pulse");
172+
}, 2000);
173+
// Alert
174+
this.app.alert(`Output EOL separator has been changed to ${eolCodeToName[eol]}`, 5000);
175+
}
176+
152177
const currentTabNum = this.manager.tabs.getActiveTab("output");
153178
if (currentTabNum >= 0) {
154179
this.outputs[currentTabNum].eolSequence = eolVal;
@@ -276,6 +301,9 @@ class OutputWaiter {
276301
// If turning word wrap off, do it before we populate the editor for performance reasons
277302
if (!wrap) this.setWordWrap(wrap);
278303

304+
// Detect suitable EOL sequence
305+
this.detectEOLSequence(data);
306+
279307
// We use setTimeout here to delay the editor dispatch until the next event cycle,
280308
// ensuring all async actions have completed before attempting to set the contents
281309
// of the editor. This is mainly with the above call to setWordWrap() in mind.
@@ -345,6 +373,48 @@ class OutputWaiter {
345373
});
346374
}
347375

376+
/**
377+
* Checks whether the EOL separator should be updated
378+
*
379+
* @param {string} data
380+
*/
381+
detectEOLSequence(data) {
382+
// If EOL has been fixed, skip this.
383+
if (this.eolSetManually) return;
384+
// If data is too long, skip this.
385+
if (data.length > 1000000) return;
386+
387+
// Detect most likely EOL sequence
388+
const eolCharCounts = {
389+
"LF": data.count("\u000a"),
390+
"VT": data.count("\u000b"),
391+
"FF": data.count("\u000c"),
392+
"CR": data.count("\u000d"),
393+
"CRLF": data.count("\u000d\u000a"),
394+
"NEL": data.count("\u0085"),
395+
"LS": data.count("\u2028"),
396+
"PS": data.count("\u2029")
397+
};
398+
399+
// If all zero, leave alone
400+
const total = Object.values(eolCharCounts).reduce((acc, curr) => {
401+
return acc + curr;
402+
}, 0);
403+
if (total === 0) return;
404+
405+
// If CRLF not zero and more than half the highest alternative, choose CRLF
406+
const highest = Object.entries(eolCharCounts).reduce((acc, curr) => {
407+
return curr[1] > acc[1] ? curr : acc;
408+
}, ["LF", 0]);
409+
if ((eolCharCounts.CRLF * 2) > highest[1]) {
410+
this.eolChange("CRLF");
411+
return;
412+
}
413+
414+
// Else choose max
415+
this.eolChange(highest[0]);
416+
}
417+
348418
/**
349419
* Calculates the maximum number of tabs to display
350420
*/

tests/browser/00_nightwatch.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ module.exports = {
230230

231231
// Alert bar shows and contains correct content
232232
browser
233+
.waitForElementNotVisible("#snackbar-container")
233234
.click("#copy-output")
234235
.waitForElementVisible("#snackbar-container")
235236
.waitForElementVisible("#snackbar-container .snackbar-content")

0 commit comments

Comments
 (0)