global-search.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947
  1. /* global $ $$ $create $remove focusAccessibility toggleDataset */// dom.js
  2. /* global CodeMirror */
  3. /* global chromeLocal */// storage-util.js
  4. /* global colorMimicry */
  5. /* global debounce stringAsRegExp tryRegExp */// toolbox.js
  6. /* global editor */
  7. /* global t */// localization.js
  8. 'use strict';
  9. (() => {
  10. require(['/edit/global-search.css']);
  11. //region Constants and state
  12. const INCREMENTAL_SEARCH_DELAY = 0;
  13. const ANNOTATE_SCROLLBAR_DELAY = 350;
  14. const ANNOTATE_SCROLLBAR_OPTIONS = {maxMatches: 10e3};
  15. const STORAGE_UPDATE_DELAY = 500;
  16. const DIALOG_SELECTOR = '#search-replace-dialog';
  17. const DIALOG_STYLE_SELECTOR = '#search-replace-dialog-style';
  18. const TARGET_CLASS = 'search-target-editor';
  19. const MATCH_CLASS = 'search-target-match';
  20. const MATCH_TOKEN_NAME = 'searching';
  21. const APPLIES_VALUE_CLASS = 'applies-value';
  22. const RX_MAYBE_REGEXP = /^\s*\/(.+?)\/([simguy]*)\s*$/;
  23. const state = {
  24. firstRun: true,
  25. // used for case-sensitive matching directly
  26. find: '',
  27. // used when /re/ is detected or for case-insensitive matching
  28. rx: null,
  29. // used by overlay and doSearchInApplies, equals to rx || stringAsRegExp(find)
  30. rx2: null,
  31. icase: true,
  32. reverse: false,
  33. lastFind: '',
  34. numFound: 0,
  35. numApplies: -1,
  36. replace: '',
  37. lastReplace: null,
  38. cm: null,
  39. input: null,
  40. input2: null,
  41. dialog: null,
  42. tally: null,
  43. originalFocus: null,
  44. undoHistory: [],
  45. searchInApplies: !document.documentElement.classList.contains('usercss'),
  46. };
  47. //endregion
  48. //region Events
  49. const ACTIONS = {
  50. key: {
  51. 'Enter': event => {
  52. switch (document.activeElement) {
  53. case state.input:
  54. if (state.dialog.dataset.type === 'find') {
  55. const found = doSearch({canAdvance: false});
  56. if (found) {
  57. const target = $('.' + TARGET_CLASS);
  58. const cm = target.CodeMirror;
  59. /* Since this runs in `keydown` event we have to delay focusing
  60. * to prevent CodeMirror from seeing and handling the key */
  61. setTimeout(() => (cm || target).focus());
  62. if (cm) {
  63. const {from, to} = cm.state.search.searchPos;
  64. cm.jumpToPos(from, to);
  65. }
  66. }
  67. destroyDialog({restoreFocus: !found});
  68. return;
  69. }
  70. // fallthrough
  71. case state.input2:
  72. doReplace();
  73. return;
  74. }
  75. return !focusAccessibility.closest(event.target);
  76. },
  77. 'Esc': () => {
  78. destroyDialog({restoreFocus: true});
  79. },
  80. },
  81. click: {
  82. next: () => doSearch({reverse: false}),
  83. prev: () => doSearch({reverse: true}),
  84. close: () => destroyDialog({restoreFocus: true}),
  85. replace: () => doReplace(),
  86. replaceAll: () => doReplaceAll(),
  87. undo: () => doUndo(),
  88. clear() {
  89. setInputValue(this._input, '');
  90. },
  91. case() {
  92. state.icase = !state.icase;
  93. state.lastFind = '';
  94. toggleDataset(this, 'enabled', !state.icase);
  95. doSearch({canAdvance: false});
  96. },
  97. },
  98. };
  99. const EVENTS = {
  100. oninput() {
  101. state.find = state.input.value;
  102. debounce(doSearch, INCREMENTAL_SEARCH_DELAY, {canAdvance: false});
  103. adjustTextareaSize(this);
  104. if (!state.find) enableReplaceButtons(false);
  105. },
  106. onkeydown(event) {
  107. const action = ACTIONS.key[CodeMirror.keyName(event)];
  108. if (action && action(event) !== false) {
  109. event.preventDefault();
  110. }
  111. },
  112. onclick(event) {
  113. const el = event.target.closest('[data-action]');
  114. const action = el && ACTIONS.click[el.dataset.action];
  115. if (action && action.call(el, event) !== false) {
  116. event.preventDefault();
  117. }
  118. },
  119. onfocusout() {
  120. if (!state.dialog.contains(document.activeElement)) {
  121. state.dialog.on('focusin', EVENTS.onfocusin);
  122. state.dialog.off('focusout', EVENTS.onfocusout);
  123. }
  124. },
  125. onfocusin() {
  126. state.dialog.on('focusout', EVENTS.onfocusout);
  127. state.dialog.off('focusin', EVENTS.onfocusin);
  128. trimUndoHistory();
  129. enableUndoButton(state.undoHistory.length);
  130. if (state.find) doSearch({canAdvance: false});
  131. },
  132. };
  133. const DIALOG_PROPS = {
  134. dialog: {
  135. onclick: EVENTS.onclick,
  136. onkeydown: EVENTS.onkeydown,
  137. },
  138. input: {
  139. oninput: EVENTS.oninput,
  140. },
  141. input2: {
  142. oninput() {
  143. state.replace = this.value;
  144. adjustTextareaSize(this);
  145. debounce(writeStorage, STORAGE_UPDATE_DELAY);
  146. },
  147. },
  148. };
  149. //endregion
  150. //region Commands
  151. const COMMANDS = {
  152. find(cm, {reverse = false} = {}) {
  153. state.reverse = reverse;
  154. focusDialog('find', cm);
  155. },
  156. findNext: cm => doSearch({reverse: false, cm}),
  157. findPrev: cm => doSearch({reverse: true, cm}),
  158. replace(cm) {
  159. state.reverse = false;
  160. focusDialog('replace', cm);
  161. },
  162. };
  163. COMMANDS.replaceAll = COMMANDS.replace;
  164. //endregion
  165. Object.assign(CodeMirror.commands, COMMANDS);
  166. readStorage();
  167. //region Find
  168. function initState({initReplace} = {}) {
  169. const text = state.find;
  170. const textChanged = text !== state.lastFind;
  171. if (textChanged) {
  172. state.numFound = 0;
  173. state.numApplies = -1;
  174. state.lastFind = text;
  175. const match = text && text.match(RX_MAYBE_REGEXP);
  176. const unicodeFlag = 'unicode' in RegExp.prototype ? 'u' : '';
  177. const string2regexpFlags = (state.icase ? 'gi' : 'g') + unicodeFlag;
  178. state.rx = match && tryRegExp(match[1], 'g' + match[2].replace(/[guy]/g, '') + unicodeFlag) ||
  179. text && (state.icase || text.includes('\n')) && stringAsRegExp(text, string2regexpFlags);
  180. state.rx2 = state.rx || text && stringAsRegExp(text, string2regexpFlags);
  181. state.cursorOptions = {
  182. caseFold: !state.rx && state.icase,
  183. multiline: true,
  184. };
  185. debounce(writeStorage, STORAGE_UPDATE_DELAY);
  186. }
  187. if (initReplace && state.replace !== state.lastReplace) {
  188. state.lastReplace = state.replace;
  189. state.replaceValue = state.replace.replace(/(\\r)?\\n/g, '\n').replace(/\\t/g, '\t');
  190. state.replaceHasRefs = /\$[$&`'\d]/.test(state.replaceValue);
  191. }
  192. const cmFocused = document.activeElement && document.activeElement.closest('.CodeMirror');
  193. state.activeAppliesTo = $(`.${APPLIES_VALUE_CLASS}:focus, .${APPLIES_VALUE_CLASS}.${TARGET_CLASS}`);
  194. state.cmStart = editor.closestVisible(
  195. cmFocused && document.activeElement ||
  196. state.activeAppliesTo ||
  197. state.cm);
  198. const cmExtra = $('body > :not(#sections) .CodeMirror');
  199. state.editors = cmExtra ? [cmExtra.CodeMirror] : editor.getEditors();
  200. }
  201. function doSearch({
  202. reverse = state.reverse,
  203. canAdvance = true,
  204. inApplies = true,
  205. cm,
  206. } = {}) {
  207. if (cm) setActiveEditor(cm);
  208. state.reverse = reverse;
  209. if (!state.find && !dialogShown()) {
  210. focusDialog('find', state.cm);
  211. return;
  212. }
  213. initState();
  214. const {cmStart} = state;
  215. const {index, found, foundInCode} = state.find && doSearchInEditors({cmStart, canAdvance, inApplies}) || {};
  216. if (!foundInCode) clearMarker();
  217. if (!found) makeTargetVisible(null);
  218. const radiateFrom = foundInCode ? index : state.editors.indexOf(cmStart);
  219. setupOverlay(radiateArray(state.editors, radiateFrom));
  220. enableReplaceButtons(foundInCode);
  221. if (state.find) {
  222. const firstSuccessfulSearch = foundInCode && !state.numFound;
  223. debounce(showTally, 0, firstSuccessfulSearch ? 1 : undefined);
  224. } else {
  225. showTally(0, 0);
  226. }
  227. state.firstRun = false;
  228. return found;
  229. }
  230. function doSearchInEditors({cmStart, canAdvance, inApplies}) {
  231. const query = state.rx || state.find;
  232. const reverse = state.reverse;
  233. const BOF = {line: 0, ch: 0};
  234. const EOF = getEOF(cmStart);
  235. const start = state.editors.indexOf(cmStart);
  236. const total = state.editors.length;
  237. let i = 0;
  238. let wrapAround = 0;
  239. let pos, index, cm;
  240. if (inApplies && state.activeAppliesTo) {
  241. if (doSearchInApplies(state.cmStart, canAdvance)) {
  242. return {found: true};
  243. }
  244. if (reverse) pos = EOF; else i++;
  245. } else {
  246. pos = getContinuationPos({cm: cmStart, reverse: !canAdvance || reverse});
  247. wrapAround = !reverse ?
  248. CodeMirror.cmpPos(pos, BOF) > 0 :
  249. CodeMirror.cmpPos(pos, EOF) < 0;
  250. }
  251. for (; i < total + wrapAround; i++) {
  252. index = (start + i * (reverse ? -1 : 1) + total) % total;
  253. cm = state.editors[index];
  254. if (i) {
  255. pos = !reverse ? BOF : {line: cm.doc.size, ch: 0};
  256. }
  257. const cursor = cm.getSearchCursor(query, pos, state.cursorOptions);
  258. if (cursor.find(reverse)) {
  259. makeMatchVisible(cm, cursor);
  260. return {found: true, foundInCode: true, index};
  261. }
  262. const cmForNextApplies = !reverse ? cm : state.editors[index ? index - 1 : total - 1];
  263. if (inApplies && doSearchInApplies(cmForNextApplies)) {
  264. return {found: true};
  265. }
  266. }
  267. }
  268. function doSearchInApplies(cm, canAdvance) {
  269. if (!state.searchInApplies) return;
  270. const inputs = editor.getSearchableInputs(cm);
  271. if (state.reverse) inputs.reverse();
  272. inputs.splice(0, inputs.indexOf(state.activeAppliesTo));
  273. for (const input of inputs) {
  274. const value = input.value;
  275. if (input === state.activeAppliesTo) {
  276. state.rx2.lastIndex = input.selectionStart + canAdvance;
  277. } else {
  278. state.rx2.lastIndex = 0;
  279. }
  280. const match = state.rx2.exec(value);
  281. if (!match) {
  282. continue;
  283. }
  284. const end = match.index + match[0].length;
  285. // scroll selected part into view in long inputs,
  286. // works only outside of current event handlers chain, hence timeout=0
  287. setTimeout(() => {
  288. input.setSelectionRange(end, end);
  289. input.setSelectionRange(match.index, end);
  290. });
  291. const canFocus = !state.dialog || !state.dialog.contains(document.activeElement);
  292. makeTargetVisible(!canFocus && input);
  293. editor.scrollToEditor(cm);
  294. if (canFocus) input.focus();
  295. state.cm = cm;
  296. clearMarker();
  297. return true;
  298. }
  299. }
  300. //endregion
  301. //region Replace
  302. function doReplace() {
  303. initState({initReplace: true});
  304. const cm = state.cmStart;
  305. const generation = cm.changeGeneration();
  306. const pos = getContinuationPos({cm, reverse: true});
  307. const cursor = doReplaceInEditor({cm, pos});
  308. if (!cursor) {
  309. return;
  310. }
  311. if (cursor.findNext()) {
  312. clearMarker();
  313. makeMatchVisible(cm, cursor);
  314. } else {
  315. doSearchInEditors({cmStart: getNextEditor(cm)});
  316. }
  317. getStateSafe(cm).unclosedOp = false;
  318. if (cm.curOp) cm.endOperation();
  319. if (cursor) {
  320. state.undoHistory.push([[cm, generation]]);
  321. enableUndoButton(true);
  322. }
  323. }
  324. function doReplaceAll() {
  325. initState({initReplace: true});
  326. clearMarker();
  327. const generations = new Map(state.editors.map(cm => [cm, cm.changeGeneration()]));
  328. const found = state.editors.filter(cm => doReplaceInEditor({cm, all: true}));
  329. if (found.length) {
  330. state.lastFind = null;
  331. state.undoHistory.push(found.map(cm => [cm, generations.get(cm)]));
  332. enableUndoButton(true);
  333. doSearch({canAdvance: false});
  334. }
  335. }
  336. function doReplaceInEditor({cm, pos, all = false}) {
  337. const cursor = cm.getSearchCursor(state.rx || state.find, pos, state.cursorOptions);
  338. const replace = state.replaceValue;
  339. let found;
  340. cursor.find();
  341. while (cursor.atOccurrence) {
  342. found = true;
  343. if (!cm.curOp) {
  344. cm.startOperation();
  345. getStateSafe(cm).unclosedOp = true;
  346. }
  347. if (state.rx) {
  348. const text = cm.getRange(cursor.pos.from, cursor.pos.to);
  349. cursor.replace(state.replaceHasRefs ? text.replace(state.rx, replace) : replace);
  350. } else {
  351. cursor.replace(replace);
  352. }
  353. if (!all) {
  354. makeMatchVisible(cm, cursor);
  355. return cursor;
  356. }
  357. cursor.findNext();
  358. }
  359. if (all) {
  360. getStateSafe(cm).searchPos = null;
  361. }
  362. return found;
  363. }
  364. function doUndo() {
  365. let undoneSome;
  366. saveWindowScrollPos();
  367. for (const [cm, generation] of state.undoHistory.pop() || []) {
  368. if (document.body.contains(cm.display.wrapper) && !cm.isClean(generation)) {
  369. cm.undo();
  370. cm.getAllMarks().forEach(marker =>
  371. marker !== state.marker &&
  372. marker.className === MATCH_CLASS &&
  373. marker.clear());
  374. undoneSome = true;
  375. }
  376. }
  377. enableUndoButton(state.undoHistory.length);
  378. if (state.undoHistory.length) {
  379. focusUndoButton();
  380. } else {
  381. state.input.focus();
  382. }
  383. if (undoneSome) {
  384. state.lastFind = null;
  385. restoreWindowScrollPos();
  386. doSearch({
  387. reverse: false,
  388. canAdvance: false,
  389. inApplies: false,
  390. });
  391. }
  392. }
  393. //endregion
  394. //region Overlay
  395. function setupOverlay(queue, debounced) {
  396. if (!queue.length) {
  397. return;
  398. }
  399. if (queue.length > 1 || !debounced) {
  400. debounce(setupOverlay, 0, queue, true);
  401. if (!debounced) {
  402. return;
  403. }
  404. }
  405. let canContinue = true;
  406. while (queue.length && canContinue) {
  407. const cm = queue.shift();
  408. if (!document.body.contains(cm.display.wrapper)) {
  409. continue;
  410. }
  411. const cmState = getStateSafe(cm);
  412. const query = state.rx2;
  413. if ((cmState.overlay || {}).query === query) {
  414. if (cmState.unclosedOp && cm.curOp) cm.endOperation();
  415. cmState.unclosedOp = false;
  416. continue;
  417. }
  418. if (cmState.overlay) {
  419. if (!cm.curOp) cm.startOperation();
  420. cm.removeOverlay(cmState.overlay);
  421. cmState.overlay = null;
  422. canContinue = false;
  423. }
  424. const hasMatches = query && cm.getSearchCursor(query, null, state.cursorOptions).find();
  425. if (hasMatches) {
  426. if (!cm.curOp) cm.startOperation();
  427. cmState.overlay = {
  428. query,
  429. token: tokenize,
  430. numFound: 0,
  431. tallyShownTime: 0,
  432. };
  433. cm.addOverlay(cmState.overlay);
  434. canContinue = false;
  435. }
  436. if (cmState.annotate) {
  437. if (!cm.curOp) cm.startOperation();
  438. cmState.annotate.clear();
  439. cmState.annotate = null;
  440. canContinue = false;
  441. }
  442. if (cmState.annotateTimer) {
  443. clearTimeout(cmState.annotateTimer);
  444. cmState.annotateTimer = 0;
  445. }
  446. if (hasMatches) {
  447. cmState.annotateTimer = setTimeout(annotateScrollbar, ANNOTATE_SCROLLBAR_DELAY,
  448. cm, query, state.icase);
  449. }
  450. cmState.unclosedOp = false;
  451. if (cm.curOp) cm.endOperation();
  452. }
  453. if (!queue.length) debounce.unregister(setupOverlay);
  454. }
  455. function tokenize(stream) {
  456. this.query.lastIndex = stream.pos;
  457. const match = this.query.exec(stream.string);
  458. if (match && match.index === stream.pos) {
  459. this.numFound++;
  460. const t = performance.now();
  461. if (t - this.tallyShownTime > 10) {
  462. this.tallyShownTime = t;
  463. debounce(showTally);
  464. }
  465. stream.pos += match[0].length || 1;
  466. return MATCH_TOKEN_NAME;
  467. } else if (match) {
  468. stream.pos = match.index;
  469. } else {
  470. stream.skipToEnd();
  471. }
  472. }
  473. function annotateScrollbar(cm, query, icase) {
  474. getStateSafe(cm).annotate = cm.showMatchesOnScrollbar(query, icase, ANNOTATE_SCROLLBAR_OPTIONS);
  475. debounce(showTally);
  476. }
  477. //endregion
  478. //region Dialog
  479. function focusDialog(type, cm) {
  480. setActiveEditor(cm);
  481. const dialogFocused = state.dialog && state.dialog.contains(document.activeElement);
  482. let sel = dialogFocused ? '' : getSelection().toString() || cm && cm.getSelection();
  483. sel = !sel.includes('\n') && !sel.includes('\r') && sel;
  484. if (sel) state.find = sel;
  485. if (!dialogShown(type)) {
  486. destroyDialog();
  487. createDialog(type);
  488. } else if (sel) {
  489. setInputValue(state.input, sel);
  490. }
  491. state.input.focus();
  492. state.input.select();
  493. if (state.find) {
  494. doSearch({canAdvance: false});
  495. }
  496. }
  497. function dialogShown(type) {
  498. return document.body.contains(state.input) &&
  499. (!type || state.dialog.dataset.type === type);
  500. }
  501. function createDialog(type) {
  502. state.originalFocus = document.activeElement;
  503. state.firstRun = true;
  504. const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
  505. Object.assign(dialog, DIALOG_PROPS.dialog);
  506. dialog.on('focusout', EVENTS.onfocusout);
  507. dialog.dataset.type = type;
  508. dialog.style.pointerEvents = 'auto';
  509. const content = $('[data-type="content"]', dialog);
  510. content.parentNode.replaceChild(t.template[type].cloneNode(true), content);
  511. createInput(0, 'input', state.find);
  512. createInput(1, 'input2', state.replace);
  513. toggleDataset($('[data-action="case"]', dialog), 'enabled', !state.icase);
  514. state.tally = $('[data-type="tally"]', dialog);
  515. const colors = {
  516. body: colorMimicry(document.body, {bg: 'backgroundColor'}),
  517. input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
  518. icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
  519. };
  520. document.documentElement.appendChild(
  521. $(DIALOG_STYLE_SELECTOR) ||
  522. $create('style' + DIALOG_STYLE_SELECTOR)
  523. ).textContent = `
  524. #search-replace-dialog {
  525. background-color: ${colors.body.bg};
  526. }
  527. #search-replace-dialog textarea {
  528. color: ${colors.body.fore};
  529. background-color: ${colors.input.bg};
  530. }
  531. #search-replace-dialog svg {
  532. fill: ${colors.icon.fill};
  533. }
  534. #search-replace-dialog [data-action="case"] {
  535. color: ${colors.icon.fill};
  536. }
  537. #search-replace-dialog[data-type="replace"] button:hover svg,
  538. #search-replace-dialog svg:hover {
  539. fill: inherit;
  540. }
  541. #search-replace-dialog [data-action="case"]:hover {
  542. color: inherit;
  543. }
  544. #search-replace-dialog [data-action="clear"] {
  545. background-color: ${colors.input.bg.replace(/[^,]+$/, '') + '.75)'};
  546. }
  547. `;
  548. document.body.appendChild(dialog);
  549. dispatchEvent(new Event('showHotkeyInTooltip'));
  550. adjustTextareaSize(state.input);
  551. if (type === 'replace') {
  552. adjustTextareaSize(state.input2);
  553. enableReplaceButtons(state.find !== '');
  554. enableUndoButton(state.undoHistory.length);
  555. }
  556. return dialog;
  557. }
  558. function createInput(index, name, value) {
  559. const input = state[name] = $$('textarea', state.dialog)[index];
  560. if (!input) {
  561. return;
  562. }
  563. input.value = value;
  564. Object.assign(input, DIALOG_PROPS[name]);
  565. input.parentElement.appendChild(t.template.clearSearch.cloneNode(true));
  566. $('[data-action]', input.parentElement)._input = input;
  567. }
  568. function destroyDialog({restoreFocus = false} = {}) {
  569. state.input = null;
  570. $remove(DIALOG_SELECTOR);
  571. debounce.unregister(doSearch);
  572. makeTargetVisible(null);
  573. if (restoreFocus) {
  574. setTimeout(focusNoScroll, 0, state.originalFocus);
  575. } else {
  576. saveWindowScrollPos();
  577. restoreWindowScrollPos({immediately: false});
  578. }
  579. }
  580. function adjustTextareaSize(el) {
  581. const oldWidth = parseFloat(el.style.width) || el.clientWidth;
  582. const widthHistory = el._widthHistory = el._widthHistory || new Map();
  583. const knownWidth = widthHistory.get(el.value);
  584. let newWidth;
  585. if (knownWidth) {
  586. newWidth = knownWidth;
  587. } else {
  588. const hasVerticalScrollbar = el.scrollHeight > el.clientHeight;
  589. newWidth = el.scrollWidth + (hasVerticalScrollbar ? el.scrollWidth - el.clientWidth : 0);
  590. newWidth += newWidth > oldWidth ? 50 : 0;
  591. widthHistory.set(el.value, newWidth);
  592. }
  593. if (newWidth !== oldWidth) {
  594. const dialogRightOffset = parseFloat(getComputedStyle(state.dialog).right);
  595. const dialogRight = state.dialog.getBoundingClientRect().right;
  596. const textRight = (state.input2 || state.input).getBoundingClientRect().right;
  597. newWidth = Math.min(newWidth,
  598. (window.innerWidth - dialogRightOffset - (dialogRight - textRight)) / (state.input2 ? 2 : 1) - 20);
  599. el.style.width = newWidth + 'px';
  600. }
  601. const numLines = el.value.split('\n').length;
  602. if (numLines !== Number(el.rows)) {
  603. el.rows = numLines;
  604. }
  605. el.style.overflowX = el.scrollWidth > el.clientWidth ? '' : 'hidden';
  606. }
  607. function enableReplaceButtons(enabled) {
  608. if (state.dialog && state.dialog.dataset.type === 'replace') {
  609. for (const el of $$('[data-action^="replace"]', state.dialog)) {
  610. el.disabled = !enabled;
  611. }
  612. }
  613. }
  614. function enableUndoButton(enabled) {
  615. if (state.dialog && state.dialog.dataset.type === 'replace') {
  616. for (const el of $$('[data-action="undo"]', state.dialog)) {
  617. el.disabled = !enabled;
  618. }
  619. }
  620. }
  621. function focusUndoButton() {
  622. for (const btn of $$('[data-action="undo"]', state.dialog)) {
  623. if (getComputedStyle(btn).display !== 'none') {
  624. btn.focus();
  625. break;
  626. }
  627. }
  628. }
  629. //endregion
  630. //region Utility
  631. function getStateSafe(cm) {
  632. return cm.state.search || (cm.state.search = {});
  633. }
  634. // determines search start position:
  635. // the cursor if it was moved or the last match
  636. function getContinuationPos({cm, reverse}) {
  637. const cmSearchState = getStateSafe(cm);
  638. const posType = reverse ? 'from' : 'to';
  639. const searchPos = (cmSearchState.searchPos || {})[posType];
  640. const cursorPos = cm.getCursor(posType);
  641. const preferCursor = !searchPos || CodeMirror.cmpPos(cursorPos, cmSearchState.cursorPos[posType]);
  642. return preferCursor ? cursorPos : searchPos;
  643. }
  644. function getEOF(cm) {
  645. const line = cm.doc.size - 1;
  646. return {line, ch: cm.getLine(line).length};
  647. }
  648. function getNextEditor(cm, step = 1) {
  649. const editors = state.editors;
  650. return editors[(editors.indexOf(cm) + step + editors.length) % editors.length];
  651. }
  652. // sets the editor to start the search in
  653. // e.g. when the user switched to another editor and invoked a search command
  654. function setActiveEditor(cm) {
  655. if (cm.display.wrapper.contains(document.activeElement)) {
  656. state.cm = cm;
  657. state.originalFocus = cm;
  658. }
  659. }
  660. // adds a class on the editor that contains the active match
  661. // instead of focusing it (in order to keep the minidialog focused)
  662. function makeTargetVisible(element) {
  663. const old = $('.' + TARGET_CLASS);
  664. if (old !== element) {
  665. if (old) {
  666. old.classList.remove(TARGET_CLASS);
  667. document.body.classList.remove('find-open');
  668. }
  669. if (element) {
  670. element.classList.add(TARGET_CLASS);
  671. document.body.classList.add('find-open');
  672. }
  673. }
  674. }
  675. // scrolls the editor to reveal the match
  676. function makeMatchVisible(cm, searchCursor) {
  677. const canFocus = !state.firstRun && (!state.dialog || !state.dialog.contains(document.activeElement));
  678. state.cm = cm;
  679. // scroll within the editor
  680. const pos = searchCursor.pos;
  681. Object.assign(getStateSafe(cm), {
  682. cursorPos: {
  683. from: cm.getCursor('from'),
  684. to: cm.getCursor('to'),
  685. },
  686. searchPos: pos,
  687. unclosedOp: !cm.curOp,
  688. });
  689. if (!cm.curOp) cm.startOperation();
  690. if (!state.firstRun) {
  691. cm.jumpToPos(pos.from, pos.to);
  692. }
  693. // focus or expose as the current search target
  694. clearMarker();
  695. if (canFocus) {
  696. cm.focus();
  697. makeTargetVisible(null);
  698. } else {
  699. makeTargetVisible(cm.display.wrapper);
  700. // mark the match
  701. state.marker = cm.state.search.marker = cm.markText(pos.from, pos.to, {
  702. className: MATCH_CLASS,
  703. clearOnEnter: true,
  704. });
  705. }
  706. }
  707. function clearMarker() {
  708. if (state.marker) state.marker.clear();
  709. }
  710. function showTally(num, numApplies) {
  711. if (!state.tally) return;
  712. if (num === undefined) {
  713. num = 0;
  714. for (const cm of state.editors) {
  715. const {annotate, overlay} = getStateSafe(cm);
  716. num +=
  717. ((annotate || {}).matches || []).length ||
  718. (overlay || {}).numFound ||
  719. 0;
  720. }
  721. state.numFound = num;
  722. }
  723. if (numApplies === undefined && state.searchInApplies && state.numApplies < 0) {
  724. numApplies = 0;
  725. const elements = state.find ? document.getElementsByClassName(APPLIES_VALUE_CLASS) : [];
  726. const {rx} = state;
  727. for (const el of elements) {
  728. const value = el.value;
  729. if (rx) {
  730. rx.lastIndex = 0;
  731. // preventing an infinite loop if matched an empty string and didn't advance
  732. for (let m; (m = rx.exec(value)) && ++numApplies && rx.lastIndex > m.index;) { /* NOP */ }
  733. } else {
  734. let i = -1;
  735. while ((i = value.indexOf(state.find, i + 1)) >= 0) numApplies++;
  736. }
  737. }
  738. state.numApplies = numApplies;
  739. } else {
  740. numApplies = state.numApplies;
  741. }
  742. const newText = num + (numApplies > 0 ? '+' + numApplies : '');
  743. if (state.tally.textContent !== newText) {
  744. state.tally.textContent = newText;
  745. const newTitle = t('searchNumberOfResults' + (numApplies ? '2' : ''));
  746. if (state.tally.title !== newTitle) state.tally.title = newTitle;
  747. }
  748. }
  749. function trimUndoHistory() {
  750. const history = state.undoHistory;
  751. for (let last; (last = history[history.length - 1]);) {
  752. const undoables = last.filter(([cm, generation]) =>
  753. document.body.contains(cm.display.wrapper) && !cm.isClean(generation));
  754. if (undoables.length) {
  755. history[history.length - 1] = undoables;
  756. break;
  757. }
  758. history.length--;
  759. }
  760. }
  761. function focusNoScroll(el) {
  762. if (el) {
  763. saveWindowScrollPos();
  764. el.focus({preventScroll: true});
  765. restoreWindowScrollPos({immediately: false});
  766. }
  767. }
  768. function saveWindowScrollPos() {
  769. state.scrollX = window.scrollX;
  770. state.scrollY = window.scrollY;
  771. }
  772. function restoreWindowScrollPos({immediately = true} = {}) {
  773. if (!immediately) {
  774. // run in the next microtask cycle
  775. Promise.resolve().then(restoreWindowScrollPos);
  776. return;
  777. }
  778. if (window.scrollX !== state.scrollX || window.scrollY !== state.scrollY) {
  779. window.scrollTo(state.scrollX, state.scrollY);
  780. }
  781. }
  782. // produces [i, i+1, i-1, i+2, i-2, i+3, i-3, ...]
  783. function radiateArray(arr, focalIndex) {
  784. const focus = arr[focalIndex];
  785. if (!focus) return arr;
  786. const result = [focus];
  787. const len = arr.length;
  788. for (let i = 1; i < len; i++) {
  789. if (focalIndex + i < len) {
  790. result.push(arr[focalIndex + i]);
  791. }
  792. if (focalIndex - i >= 0) {
  793. result.push(arr[focalIndex - i]);
  794. }
  795. }
  796. return result;
  797. }
  798. function readStorage() {
  799. chromeLocal.getValue('editor').then((editor = {}) => {
  800. state.find = editor.find || '';
  801. state.replace = editor.replace || '';
  802. state.icase = editor.icase || state.icase;
  803. });
  804. }
  805. function writeStorage() {
  806. chromeLocal.getValue('editor').then((editor = {}) =>
  807. chromeLocal.setValue('editor', Object.assign(editor, {
  808. find: state.find,
  809. replace: state.replace,
  810. icase: state.icase,
  811. })));
  812. }
  813. function setInputValue(input, value) {
  814. input.focus();
  815. input.select();
  816. // using execCommand to add to the input's undo history
  817. document.execCommand(value ? 'insertText' : 'delete', false, value);
  818. // some versions of Firefox ignore execCommand
  819. if (input.value !== value) {
  820. input.value = value;
  821. input.dispatchEvent(new Event('input', {bubbles: true}));
  822. }
  823. }
  824. //endregion
  825. })();