eb0b9f58f5
* Fix search highlight conflict Regular highlight styling and search highlight styling shouldn't both be applied at the same time. Search highlight styling should also be removed when search is closed. This PR resolves those conflicts. * Remove unnecessary dummy animation Not sure what the point of it ever was, but I'm pretty sure it should go.
950 lines
27 KiB
JavaScript
950 lines
27 KiB
JavaScript
/* global CodeMirror focusAccessibility colorMimicry editor
|
|
onDOMready $ $$ $create t debounce tryRegExp stringAsRegExp template */
|
|
'use strict';
|
|
|
|
onDOMready().then(() => {
|
|
|
|
//region Constants and state
|
|
|
|
const INCREMENTAL_SEARCH_DELAY = 0;
|
|
const ANNOTATE_SCROLLBAR_DELAY = 350;
|
|
const ANNOTATE_SCROLLBAR_OPTIONS = {maxMatches: 10e3};
|
|
const STORAGE_UPDATE_DELAY = 500;
|
|
const SCROLL_REVEAL_MIN_PX = 50;
|
|
|
|
const DIALOG_SELECTOR = '#search-replace-dialog';
|
|
const DIALOG_STYLE_SELECTOR = '#search-replace-dialog-style';
|
|
const TARGET_CLASS = 'search-target-editor';
|
|
const MATCH_CLASS = 'search-target-match';
|
|
const MATCH_TOKEN_NAME = 'searching';
|
|
const APPLIES_VALUE_CLASS = 'applies-value';
|
|
|
|
const RX_MAYBE_REGEXP = /^\s*\/(.+?)\/([simguy]*)\s*$/;
|
|
|
|
const state = {
|
|
// used for case-sensitive matching directly
|
|
find: '',
|
|
// used when /re/ is detected or for case-insensitive matching
|
|
rx: null,
|
|
// used by overlay and doSearchInApplies, equals to rx || stringAsRegExp(find)
|
|
rx2: null,
|
|
|
|
icase: true,
|
|
reverse: false,
|
|
lastFind: '',
|
|
|
|
numFound: 0,
|
|
numApplies: -1,
|
|
|
|
replace: '',
|
|
lastReplace: null,
|
|
|
|
cm: null,
|
|
input: null,
|
|
input2: null,
|
|
dialog: null,
|
|
tally: null,
|
|
originalFocus: null,
|
|
|
|
undoHistory: [],
|
|
|
|
searchInApplies: !document.documentElement.classList.contains('usercss'),
|
|
};
|
|
|
|
//endregion
|
|
//region Events
|
|
|
|
const ACTIONS = {
|
|
key: {
|
|
'Enter': event => {
|
|
switch (document.activeElement) {
|
|
case state.input:
|
|
if (state.dialog.dataset.type === 'find') {
|
|
const found = doSearch({canAdvance: false});
|
|
if (found) {
|
|
const target = $('.' + TARGET_CLASS);
|
|
const cm = target.CodeMirror;
|
|
(cm || target).focus();
|
|
if (cm) {
|
|
const pos = cm.state.search.searchPos;
|
|
cm.setSelection(pos.from, pos.to);
|
|
}
|
|
}
|
|
destroyDialog({restoreFocus: !found});
|
|
return;
|
|
}
|
|
// fallthrough
|
|
case state.input2:
|
|
doReplace();
|
|
return;
|
|
}
|
|
return !event.target.closest(focusAccessibility.ELEMENTS.join(','));
|
|
},
|
|
'Esc': () => {
|
|
destroyDialog({restoreFocus: true});
|
|
},
|
|
},
|
|
click: {
|
|
next: () => doSearch({reverse: false}),
|
|
prev: () => doSearch({reverse: true}),
|
|
close: () => destroyDialog({restoreFocus: true}),
|
|
replace: () => doReplace(),
|
|
replaceAll: () => doReplaceAll(),
|
|
undo: () => doUndo(),
|
|
clear() {
|
|
setInputValue(this._input, '');
|
|
},
|
|
case() {
|
|
state.icase = !state.icase;
|
|
state.lastFind = '';
|
|
toggleDataset(this, 'enabled', !state.icase);
|
|
doSearch({canAdvance: false});
|
|
}
|
|
},
|
|
};
|
|
|
|
const EVENTS = {
|
|
oninput() {
|
|
state.find = state.input.value;
|
|
debounce(doSearch, INCREMENTAL_SEARCH_DELAY, {canAdvance: false});
|
|
adjustTextareaSize(this);
|
|
if (!state.find) enableReplaceButtons(false);
|
|
},
|
|
onkeydown(event) {
|
|
const action = ACTIONS.key[CodeMirror.keyName(event)];
|
|
if (action && action(event) !== false) {
|
|
event.preventDefault();
|
|
}
|
|
},
|
|
onclick(event) {
|
|
const el = event.target.closest('[data-action]');
|
|
const action = el && ACTIONS.click[el.dataset.action];
|
|
if (action && action.call(el, event) !== false) {
|
|
event.preventDefault();
|
|
}
|
|
},
|
|
onfocusout() {
|
|
if (!state.dialog.contains(document.activeElement)) {
|
|
state.dialog.addEventListener('focusin', EVENTS.onfocusin);
|
|
state.dialog.removeEventListener('focusout', EVENTS.onfocusout);
|
|
}
|
|
},
|
|
onfocusin() {
|
|
state.dialog.addEventListener('focusout', EVENTS.onfocusout);
|
|
state.dialog.removeEventListener('focusin', EVENTS.onfocusin);
|
|
trimUndoHistory();
|
|
enableUndoButton(state.undoHistory.length);
|
|
if (state.find) doSearch({canAdvance: false});
|
|
}
|
|
};
|
|
|
|
const DIALOG_PROPS = {
|
|
dialog: {
|
|
onclick: EVENTS.onclick,
|
|
onkeydown: EVENTS.onkeydown,
|
|
},
|
|
input: {
|
|
oninput: EVENTS.oninput,
|
|
},
|
|
input2: {
|
|
oninput() {
|
|
state.replace = this.value;
|
|
adjustTextareaSize(this);
|
|
debounce(writeStorage, STORAGE_UPDATE_DELAY);
|
|
}
|
|
},
|
|
};
|
|
|
|
//endregion
|
|
//region Commands
|
|
|
|
const COMMANDS = {
|
|
find(cm, {reverse = false} = {}) {
|
|
state.reverse = reverse;
|
|
focusDialog('find', cm);
|
|
},
|
|
findNext: cm => doSearch({reverse: false, cm}),
|
|
findPrev: cm => doSearch({reverse: true, cm}),
|
|
replace(cm) {
|
|
state.reverse = false;
|
|
focusDialog('replace', cm);
|
|
}
|
|
};
|
|
COMMANDS.replaceAll = COMMANDS.replace;
|
|
|
|
//endregion
|
|
|
|
Object.assign(CodeMirror.commands, COMMANDS);
|
|
readStorage();
|
|
return;
|
|
|
|
//region Find
|
|
|
|
function initState({initReplace} = {}) {
|
|
const text = state.find;
|
|
const textChanged = text !== state.lastFind;
|
|
if (textChanged) {
|
|
state.numFound = 0;
|
|
state.numApplies = -1;
|
|
state.lastFind = text;
|
|
const match = text && text.match(RX_MAYBE_REGEXP);
|
|
const unicodeFlag = 'unicode' in RegExp.prototype ? 'u' : '';
|
|
const string2regexpFlags = (state.icase ? 'gi' : 'g') + unicodeFlag;
|
|
state.rx = match && tryRegExp(match[1], 'g' + match[2].replace(/[guy]/g, '') + unicodeFlag) ||
|
|
text && (state.icase || text.includes('\n')) && stringAsRegExp(text, string2regexpFlags);
|
|
state.rx2 = state.rx || text && stringAsRegExp(text, string2regexpFlags);
|
|
state.cursorOptions = {
|
|
caseFold: !state.rx && state.icase,
|
|
multiline: true,
|
|
};
|
|
debounce(writeStorage, STORAGE_UPDATE_DELAY);
|
|
}
|
|
if (initReplace && state.replace !== state.lastReplace) {
|
|
state.lastReplace = state.replace;
|
|
state.replaceValue = state.replace.replace(/(\\r)?\\n/g, '\n').replace(/\\t/g, '\t');
|
|
state.replaceHasRefs = /\$[$&`'\d]/.test(state.replaceValue);
|
|
}
|
|
const cmFocused = document.activeElement && document.activeElement.closest('.CodeMirror');
|
|
state.activeAppliesTo = $(`.${APPLIES_VALUE_CLASS}:focus, .${APPLIES_VALUE_CLASS}.${TARGET_CLASS}`);
|
|
state.cmStart = editor.closestVisible(
|
|
cmFocused && document.activeElement ||
|
|
state.activeAppliesTo ||
|
|
state.cm);
|
|
const cmExtra = $('body > :not(#sections) .CodeMirror');
|
|
state.editors = cmExtra ? [cmExtra.CodeMirror] : editor.getEditors();
|
|
}
|
|
|
|
|
|
function doSearch({
|
|
reverse = state.reverse,
|
|
canAdvance = true,
|
|
inApplies = true,
|
|
cm,
|
|
} = {}) {
|
|
if (cm) setActiveEditor(cm);
|
|
state.reverse = reverse;
|
|
if (!state.find && !dialogShown()) {
|
|
focusDialog('find', state.cm);
|
|
return;
|
|
}
|
|
initState();
|
|
const {cmStart} = state;
|
|
const {index, found, foundInCode} = state.find && doSearchInEditors({cmStart, canAdvance, inApplies}) || {};
|
|
if (!foundInCode) clearMarker();
|
|
if (!found) makeTargetVisible(null);
|
|
const radiateFrom = foundInCode ? index : state.editors.indexOf(cmStart);
|
|
setupOverlay(radiateArray(state.editors, radiateFrom));
|
|
enableReplaceButtons(foundInCode);
|
|
if (state.find) {
|
|
const firstSuccessfulSearch = foundInCode && !state.numFound;
|
|
debounce(showTally, 0, firstSuccessfulSearch ? 1 : undefined);
|
|
} else {
|
|
showTally(0, 0);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
|
|
function doSearchInEditors({cmStart, canAdvance, inApplies}) {
|
|
const query = state.rx || state.find;
|
|
const reverse = state.reverse;
|
|
const BOF = {line: 0, ch: 0};
|
|
const EOF = getEOF(cmStart);
|
|
|
|
const start = state.editors.indexOf(cmStart);
|
|
const total = state.editors.length;
|
|
let i = 0;
|
|
let wrapAround = 0;
|
|
let pos, index, cm;
|
|
|
|
if (inApplies && state.activeAppliesTo) {
|
|
if (doSearchInApplies(state.cmStart, canAdvance)) {
|
|
return {found: true};
|
|
}
|
|
if (reverse) pos = EOF; else i++;
|
|
} else {
|
|
pos = getContinuationPos({cm: cmStart, reverse: !canAdvance || reverse});
|
|
wrapAround = !reverse ?
|
|
CodeMirror.cmpPos(pos, BOF) > 0 :
|
|
CodeMirror.cmpPos(pos, EOF) < 0;
|
|
}
|
|
|
|
for (; i < total + wrapAround; i++) {
|
|
index = (start + i * (reverse ? -1 : 1) + total) % total;
|
|
cm = state.editors[index];
|
|
if (i) {
|
|
pos = !reverse ? BOF : {line: cm.doc.size, ch: 0};
|
|
}
|
|
const cursor = cm.getSearchCursor(query, pos, state.cursorOptions);
|
|
if (cursor.find(reverse)) {
|
|
makeMatchVisible(cm, cursor);
|
|
return {found: true, foundInCode: true, index};
|
|
}
|
|
const cmForNextApplies = !reverse ? cm : state.editors[index ? index - 1 : total - 1];
|
|
if (inApplies && doSearchInApplies(cmForNextApplies)) {
|
|
return {found: true};
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function doSearchInApplies(cm, canAdvance) {
|
|
if (!state.searchInApplies) return;
|
|
const inputs = editor.getSearchableInputs(cm);
|
|
if (state.reverse) inputs.reverse();
|
|
inputs.splice(0, inputs.indexOf(state.activeAppliesTo));
|
|
for (const input of inputs) {
|
|
const value = input.value;
|
|
if (input === state.activeAppliesTo) {
|
|
state.rx2.lastIndex = input.selectionStart + canAdvance;
|
|
} else {
|
|
state.rx2.lastIndex = 0;
|
|
}
|
|
const match = state.rx2.exec(value);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const end = match.index + match[0].length;
|
|
// scroll selected part into view in long inputs,
|
|
// works only outside of current event handlers chain, hence timeout=0
|
|
setTimeout(() => {
|
|
input.setSelectionRange(end, end);
|
|
input.setSelectionRange(match.index, end);
|
|
});
|
|
const canFocus = !state.dialog || !state.dialog.contains(document.activeElement);
|
|
makeTargetVisible(!canFocus && input);
|
|
editor.scrollToEditor(cm);
|
|
if (canFocus) input.focus();
|
|
state.cm = cm;
|
|
clearMarker();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
//endregion
|
|
//region Replace
|
|
|
|
function doReplace() {
|
|
initState({initReplace: true});
|
|
const cm = state.cmStart;
|
|
const generation = cm.changeGeneration();
|
|
const pos = getContinuationPos({cm, reverse: true});
|
|
const cursor = doReplaceInEditor({cm, pos});
|
|
if (!cursor) {
|
|
return;
|
|
}
|
|
|
|
if (cursor.findNext()) {
|
|
clearMarker();
|
|
makeMatchVisible(cm, cursor);
|
|
} else {
|
|
doSearchInEditors({cmStart: getNextEditor(cm)});
|
|
}
|
|
|
|
getStateSafe(cm).unclosedOp = false;
|
|
if (cm.curOp) cm.endOperation();
|
|
|
|
if (cursor) {
|
|
state.undoHistory.push([[cm, generation]]);
|
|
enableUndoButton(true);
|
|
}
|
|
}
|
|
|
|
|
|
function doReplaceAll() {
|
|
initState({initReplace: true});
|
|
clearMarker();
|
|
const generations = new Map(state.editors.map(cm => [cm, cm.changeGeneration()]));
|
|
const found = state.editors.filter(cm => doReplaceInEditor({cm, all: true}));
|
|
if (found.length) {
|
|
state.lastFind = null;
|
|
state.undoHistory.push(found.map(cm => [cm, generations.get(cm)]));
|
|
enableUndoButton(true);
|
|
doSearch({canAdvance: false});
|
|
}
|
|
}
|
|
|
|
|
|
function doReplaceInEditor({cm, pos, all = false}) {
|
|
const cursor = cm.getSearchCursor(state.rx || state.find, pos, state.cursorOptions);
|
|
const replace = state.replaceValue;
|
|
let found;
|
|
|
|
cursor.find();
|
|
while (cursor.atOccurrence) {
|
|
found = true;
|
|
if (!cm.curOp) {
|
|
cm.startOperation();
|
|
getStateSafe(cm).unclosedOp = true;
|
|
}
|
|
if (state.rx) {
|
|
const text = cm.getRange(cursor.pos.from, cursor.pos.to);
|
|
cursor.replace(state.replaceHasRefs ? text.replace(state.rx, replace) : replace);
|
|
} else {
|
|
cursor.replace(replace);
|
|
}
|
|
if (!all) {
|
|
makeMatchVisible(cm, cursor);
|
|
return cursor;
|
|
}
|
|
cursor.findNext();
|
|
}
|
|
if (all) {
|
|
getStateSafe(cm).searchPos = null;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
|
|
function doUndo() {
|
|
let undoneSome;
|
|
saveWindowScrollPos();
|
|
for (const [cm, generation] of state.undoHistory.pop() || []) {
|
|
if (document.body.contains(cm.display.wrapper) && !cm.isClean(generation)) {
|
|
cm.undo();
|
|
cm.getAllMarks().forEach(marker =>
|
|
marker !== state.marker &&
|
|
marker.className === MATCH_CLASS &&
|
|
marker.clear());
|
|
undoneSome = true;
|
|
}
|
|
}
|
|
enableUndoButton(state.undoHistory.length);
|
|
if (state.undoHistory.length) {
|
|
focusUndoButton();
|
|
} else {
|
|
state.input.focus();
|
|
}
|
|
if (undoneSome) {
|
|
state.lastFind = null;
|
|
restoreWindowScrollPos();
|
|
doSearch({
|
|
reverse: false,
|
|
canAdvance: false,
|
|
inApplies: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
//endregion
|
|
//region Overlay
|
|
|
|
|
|
function setupOverlay(queue, debounced) {
|
|
if (!queue.length) {
|
|
return;
|
|
}
|
|
if (queue.length > 1 || !debounced) {
|
|
debounce(setupOverlay, 0, queue, true);
|
|
if (!debounced) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
let canContinue = true;
|
|
while (queue.length && canContinue) {
|
|
const cm = queue.shift();
|
|
if (!document.body.contains(cm.display.wrapper)) {
|
|
continue;
|
|
}
|
|
|
|
const cmState = getStateSafe(cm);
|
|
const query = state.rx2;
|
|
|
|
if ((cmState.overlay || {}).query === query) {
|
|
if (cmState.unclosedOp && cm.curOp) cm.endOperation();
|
|
cmState.unclosedOp = false;
|
|
continue;
|
|
}
|
|
|
|
if (cmState.overlay) {
|
|
if (!cm.curOp) cm.startOperation();
|
|
cm.removeOverlay(cmState.overlay);
|
|
cmState.overlay = null;
|
|
canContinue = false;
|
|
}
|
|
|
|
const hasMatches = query && cm.getSearchCursor(query, null, state.cursorOptions).find();
|
|
if (hasMatches) {
|
|
if (!cm.curOp) cm.startOperation();
|
|
cmState.overlay = {
|
|
query,
|
|
token: tokenize,
|
|
numFound: 0,
|
|
tallyShownTime: 0,
|
|
};
|
|
cm.addOverlay(cmState.overlay);
|
|
canContinue = false;
|
|
}
|
|
|
|
if (cmState.annotate) {
|
|
if (!cm.curOp) cm.startOperation();
|
|
cmState.annotate.clear();
|
|
cmState.annotate = null;
|
|
canContinue = false;
|
|
}
|
|
if (cmState.annotateTimer) {
|
|
clearTimeout(cmState.annotateTimer);
|
|
cmState.annotateTimer = 0;
|
|
}
|
|
if (hasMatches) {
|
|
cmState.annotateTimer = setTimeout(annotateScrollbar, ANNOTATE_SCROLLBAR_DELAY,
|
|
cm, query, state.icase);
|
|
}
|
|
|
|
cmState.unclosedOp = false;
|
|
if (cm.curOp) cm.endOperation();
|
|
}
|
|
|
|
if (!queue.length) debounce.unregister(setupOverlay);
|
|
}
|
|
|
|
|
|
function tokenize(stream) {
|
|
this.query.lastIndex = stream.pos;
|
|
const match = this.query.exec(stream.string);
|
|
if (match && match.index === stream.pos) {
|
|
this.numFound++;
|
|
const t = performance.now();
|
|
if (t - this.tallyShownTime > 10) {
|
|
this.tallyShownTime = t;
|
|
debounce(showTally);
|
|
}
|
|
stream.pos += match[0].length || 1;
|
|
return MATCH_TOKEN_NAME;
|
|
} else if (match) {
|
|
stream.pos = match.index;
|
|
} else {
|
|
stream.skipToEnd();
|
|
}
|
|
}
|
|
|
|
|
|
function annotateScrollbar(cm, query, icase) {
|
|
getStateSafe(cm).annotate = cm.showMatchesOnScrollbar(query, icase, ANNOTATE_SCROLLBAR_OPTIONS);
|
|
debounce(showTally);
|
|
}
|
|
|
|
//endregion
|
|
//region Dialog
|
|
|
|
function focusDialog(type, cm) {
|
|
setActiveEditor(cm);
|
|
|
|
const dialogFocused = state.dialog && state.dialog.contains(document.activeElement);
|
|
let sel = dialogFocused ? '' : getSelection().toString() || cm && cm.getSelection();
|
|
sel = !sel.includes('\n') && !sel.includes('\r') && sel;
|
|
if (sel) state.find = sel;
|
|
|
|
if (!dialogShown(type)) {
|
|
destroyDialog();
|
|
createDialog(type);
|
|
} else if (sel) {
|
|
setInputValue(state.input, sel);
|
|
}
|
|
|
|
state.input.focus();
|
|
state.input.select();
|
|
if (state.find) {
|
|
doSearch({canAdvance: false});
|
|
}
|
|
}
|
|
|
|
|
|
function dialogShown(type) {
|
|
return document.body.contains(state.input) &&
|
|
(!type || state.dialog.dataset.type === type);
|
|
}
|
|
|
|
|
|
function createDialog(type) {
|
|
state.originalFocus = document.activeElement;
|
|
|
|
const dialog = state.dialog = template.searchReplaceDialog.cloneNode(true);
|
|
Object.assign(dialog, DIALOG_PROPS.dialog);
|
|
dialog.addEventListener('focusout', EVENTS.onfocusout);
|
|
dialog.dataset.type = type;
|
|
dialog.style.pointerEvents = 'auto';
|
|
|
|
const content = $('[data-type="content"]', dialog);
|
|
content.parentNode.replaceChild(template[type].cloneNode(true), content);
|
|
|
|
createInput(0, 'input', state.find);
|
|
createInput(1, 'input2', state.replace);
|
|
toggleDataset($('[data-action="case"]', dialog), 'enabled', !state.icase);
|
|
state.tally = $('[data-type="tally"]', dialog);
|
|
|
|
const colors = {
|
|
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}),
|
|
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}),
|
|
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}),
|
|
};
|
|
document.documentElement.appendChild(
|
|
$(DIALOG_STYLE_SELECTOR) ||
|
|
$create('style' + DIALOG_STYLE_SELECTOR)
|
|
).textContent = `
|
|
#search-replace-dialog {
|
|
background-color: ${colors.body.bg};
|
|
}
|
|
#search-replace-dialog textarea {
|
|
color: ${colors.body.fore};
|
|
background-color: ${colors.input.bg};
|
|
}
|
|
#search-replace-dialog svg {
|
|
fill: ${colors.icon.fill};
|
|
}
|
|
#search-replace-dialog [data-action="case"] {
|
|
color: ${colors.icon.fill};
|
|
}
|
|
#search-replace-dialog[data-type="replace"] button:hover svg,
|
|
#search-replace-dialog svg:hover {
|
|
fill: inherit;
|
|
}
|
|
#search-replace-dialog [data-action="case"]:hover {
|
|
color: inherit;
|
|
}
|
|
#search-replace-dialog [data-action="clear"] {
|
|
background-color: ${colors.input.bg.replace(/[^,]+$/, '') + '.75)'};
|
|
}
|
|
`;
|
|
|
|
document.body.appendChild(dialog);
|
|
dispatchEvent(new Event('showHotkeyInTooltip'));
|
|
|
|
adjustTextareaSize(state.input);
|
|
if (type === 'replace') {
|
|
adjustTextareaSize(state.input2);
|
|
enableReplaceButtons(state.find !== '');
|
|
enableUndoButton(state.undoHistory.length);
|
|
}
|
|
|
|
return dialog;
|
|
}
|
|
|
|
|
|
function createInput(index, name, value) {
|
|
const input = state[name] = $$('textarea', state.dialog)[index];
|
|
if (!input) {
|
|
return;
|
|
}
|
|
input.value = value;
|
|
Object.assign(input, DIALOG_PROPS[name]);
|
|
|
|
input.parentElement.appendChild(template.clearSearch.cloneNode(true));
|
|
$('[data-action]', input.parentElement)._input = input;
|
|
}
|
|
|
|
|
|
function destroyDialog({restoreFocus = false} = {}) {
|
|
state.input = null;
|
|
$.remove(DIALOG_SELECTOR);
|
|
debounce.unregister(doSearch);
|
|
makeTargetVisible(null);
|
|
if (restoreFocus) {
|
|
setTimeout(focusNoScroll, 0, state.originalFocus);
|
|
} else {
|
|
saveWindowScrollPos();
|
|
restoreWindowScrollPos({immediately: false});
|
|
}
|
|
}
|
|
|
|
|
|
function adjustTextareaSize(el) {
|
|
const oldWidth = parseFloat(el.style.width) || el.clientWidth;
|
|
const widthHistory = el._widthHistory = el._widthHistory || new Map();
|
|
const knownWidth = widthHistory.get(el.value);
|
|
let newWidth;
|
|
if (knownWidth) {
|
|
newWidth = knownWidth;
|
|
} else {
|
|
const hasVerticalScrollbar = el.scrollHeight > el.clientHeight;
|
|
newWidth = el.scrollWidth + (hasVerticalScrollbar ? el.scrollWidth - el.clientWidth : 0);
|
|
newWidth += newWidth > oldWidth ? 50 : 0;
|
|
widthHistory.set(el.value, newWidth);
|
|
}
|
|
if (newWidth !== oldWidth) {
|
|
const dialogRightOffset = parseFloat(getComputedStyle(state.dialog).right);
|
|
const dialogRight = state.dialog.getBoundingClientRect().right;
|
|
const textRight = (state.input2 || state.input).getBoundingClientRect().right;
|
|
newWidth = Math.min(newWidth,
|
|
(window.innerWidth - dialogRightOffset - (dialogRight - textRight)) / (state.input2 ? 2 : 1) - 20);
|
|
el.style.width = newWidth + 'px';
|
|
}
|
|
const numLines = el.value.split('\n').length;
|
|
if (numLines !== parseInt(el.rows)) {
|
|
el.rows = numLines;
|
|
}
|
|
el.style.overflowX = el.scrollWidth > el.clientWidth ? '' : 'hidden';
|
|
}
|
|
|
|
|
|
function enableReplaceButtons(enabled) {
|
|
if (state.dialog && state.dialog.dataset.type === 'replace') {
|
|
for (const el of $$('[data-action^="replace"]', state.dialog)) {
|
|
el.disabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function enableUndoButton(enabled) {
|
|
if (state.dialog && state.dialog.dataset.type === 'replace') {
|
|
for (const el of $$('[data-action="undo"]', state.dialog)) {
|
|
el.disabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function focusUndoButton() {
|
|
for (const btn of $$('[data-action="undo"]', state.dialog)) {
|
|
if (getComputedStyle(btn).display !== 'none') {
|
|
btn.focus();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
//endregion
|
|
//region Utility
|
|
|
|
function getStateSafe(cm) {
|
|
return cm.state.search || (cm.state.search = {});
|
|
}
|
|
|
|
|
|
// determines search start position:
|
|
// the cursor if it was moved or the last match
|
|
function getContinuationPos({cm, reverse}) {
|
|
const cmSearchState = getStateSafe(cm);
|
|
const posType = reverse ? 'from' : 'to';
|
|
const searchPos = (cmSearchState.searchPos || {})[posType];
|
|
const cursorPos = cm.getCursor(posType);
|
|
const preferCursor = !searchPos || CodeMirror.cmpPos(cursorPos, cmSearchState.cursorPos[posType]);
|
|
return preferCursor ? cursorPos : searchPos;
|
|
}
|
|
|
|
|
|
function getEOF(cm) {
|
|
const line = cm.doc.size - 1;
|
|
return {line, ch: cm.getLine(line).length};
|
|
}
|
|
|
|
|
|
function getNextEditor(cm, step = 1) {
|
|
const editors = state.editors;
|
|
return editors[(editors.indexOf(cm) + step + editors.length) % editors.length];
|
|
}
|
|
|
|
|
|
// sets the editor to start the search in
|
|
// e.g. when the user switched to another editor and invoked a search command
|
|
function setActiveEditor(cm) {
|
|
if (cm.display.wrapper.contains(document.activeElement)) {
|
|
state.cm = cm;
|
|
state.originalFocus = cm;
|
|
}
|
|
}
|
|
|
|
|
|
// adds a class on the editor that contains the active match
|
|
// instead of focusing it (in order to keep the minidialog focused)
|
|
function makeTargetVisible(element) {
|
|
const old = $('.' + TARGET_CLASS);
|
|
if (old !== element) {
|
|
if (old) {
|
|
old.classList.remove(TARGET_CLASS);
|
|
document.body.classList.remove('find-open');
|
|
}
|
|
if (element) {
|
|
element.classList.add(TARGET_CLASS);
|
|
document.body.classList.add('find-open');
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// scrolls the editor to reveal the match
|
|
function makeMatchVisible(cm, searchCursor) {
|
|
const canFocus = !state.dialog || !state.dialog.contains(document.activeElement);
|
|
state.cm = cm;
|
|
|
|
// scroll within the editor
|
|
Object.assign(getStateSafe(cm), {
|
|
cursorPos: {
|
|
from: cm.getCursor('from'),
|
|
to: cm.getCursor('to'),
|
|
},
|
|
searchPos: searchCursor.pos,
|
|
unclosedOp: !cm.curOp,
|
|
});
|
|
if (!cm.curOp) cm.startOperation();
|
|
if (canFocus) cm.setSelection(searchCursor.pos.from, searchCursor.pos.to);
|
|
cm.scrollIntoView(searchCursor.pos, SCROLL_REVEAL_MIN_PX);
|
|
|
|
// scroll to the editor itself
|
|
editor.scrollToEditor(cm);
|
|
|
|
// focus or expose as the current search target
|
|
clearMarker();
|
|
if (canFocus) {
|
|
cm.focus();
|
|
makeTargetVisible(null);
|
|
} else {
|
|
makeTargetVisible(cm.display.wrapper);
|
|
// mark the match
|
|
const pos = searchCursor.pos;
|
|
state.marker = cm.state.search.marker = cm.markText(pos.from, pos.to, {
|
|
className: MATCH_CLASS,
|
|
clearOnEnter: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
function clearMarker() {
|
|
if (state.marker) state.marker.clear();
|
|
}
|
|
|
|
|
|
function showTally(num, numApplies) {
|
|
if (!state.tally) return;
|
|
if (num === undefined) {
|
|
num = 0;
|
|
for (const cm of state.editors) {
|
|
const {annotate, overlay} = getStateSafe(cm);
|
|
num +=
|
|
((annotate || {}).matches || []).length ||
|
|
(overlay || {}).numFound ||
|
|
0;
|
|
}
|
|
state.numFound = num;
|
|
}
|
|
if (numApplies === undefined && state.searchInApplies && state.numApplies < 0) {
|
|
numApplies = 0;
|
|
const elements = state.find ? document.getElementsByClassName(APPLIES_VALUE_CLASS) : [];
|
|
for (const el of elements) {
|
|
const value = el.value;
|
|
if (state.rx) {
|
|
state.rx.lastIndex = 0;
|
|
while (state.rx.exec(value)) numApplies++;
|
|
} else {
|
|
let i = -1;
|
|
while ((i = value.indexOf(state.find, i + 1)) >= 0) numApplies++;
|
|
}
|
|
}
|
|
state.numApplies = numApplies;
|
|
} else {
|
|
numApplies = state.numApplies;
|
|
}
|
|
const newText = num + (numApplies > 0 ? '+' + numApplies : '');
|
|
if (state.tally.textContent !== newText) {
|
|
state.tally.textContent = newText;
|
|
const newTitle = t('searchNumberOfResults' + (numApplies ? '2' : ''));
|
|
if (state.tally.title !== newTitle) state.tally.title = newTitle;
|
|
}
|
|
}
|
|
|
|
|
|
function trimUndoHistory() {
|
|
const history = state.undoHistory;
|
|
for (let last; (last = history[history.length - 1]);) {
|
|
const undoables = last.filter(([cm, generation]) =>
|
|
document.body.contains(cm.display.wrapper) && !cm.isClean(generation));
|
|
if (undoables.length) {
|
|
history[history.length - 1] = undoables;
|
|
break;
|
|
}
|
|
history.length--;
|
|
}
|
|
}
|
|
|
|
|
|
function focusNoScroll(el) {
|
|
if (el) {
|
|
saveWindowScrollPos();
|
|
el.focus({preventScroll: true});
|
|
restoreWindowScrollPos({immediately: false});
|
|
}
|
|
}
|
|
|
|
|
|
function toggleDataset(el, prop, state) {
|
|
if (state) {
|
|
el.dataset[prop] = '';
|
|
} else {
|
|
delete el.dataset[prop];
|
|
}
|
|
}
|
|
|
|
|
|
function saveWindowScrollPos() {
|
|
state.scrollX = window.scrollX;
|
|
state.scrollY = window.scrollY;
|
|
}
|
|
|
|
|
|
function restoreWindowScrollPos({immediately = true} = {}) {
|
|
if (!immediately) {
|
|
// run in the next microtask cycle
|
|
Promise.resolve().then(restoreWindowScrollPos);
|
|
return;
|
|
}
|
|
if (window.scrollX !== state.scrollX || window.scrollY !== state.scrollY) {
|
|
window.scrollTo(state.scrollX, state.scrollY);
|
|
}
|
|
}
|
|
|
|
|
|
// produces [i, i+1, i-1, i+2, i-2, i+3, i-3, ...]
|
|
function radiateArray(arr, focalIndex) {
|
|
const result = [arr[focalIndex]];
|
|
const len = arr.length;
|
|
for (let i = 1; i < len; i++) {
|
|
if (focalIndex + i < len) {
|
|
result.push(arr[focalIndex + i]);
|
|
}
|
|
if (focalIndex - i >= 0) {
|
|
result.push(arr[focalIndex - i]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
function readStorage() {
|
|
chrome.storage.local.get('editor', ({editor = {}}) => {
|
|
state.find = editor.find || '';
|
|
state.replace = editor.replace || '';
|
|
state.icase = editor.icase || state.icase;
|
|
});
|
|
}
|
|
|
|
|
|
function writeStorage() {
|
|
chrome.storage.local.get('editor', ({editor}) =>
|
|
chrome.storage.local.set({
|
|
editor: Object.assign(editor || {}, {
|
|
find: state.find,
|
|
replace: state.replace,
|
|
icase: state.icase,
|
|
})
|
|
}));
|
|
}
|
|
|
|
|
|
function setInputValue(input, value) {
|
|
input.focus();
|
|
input.select();
|
|
// using execCommand to add to the input's undo history
|
|
document.execCommand(value ? 'insertText' : 'delete', false, value);
|
|
// some versions of Firefox ignore execCommand
|
|
if (input.value !== value) {
|
|
input.value = value;
|
|
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
}
|
|
}
|
|
|
|
//endregion
|
|
});
|