Skip to content

Instantly share code, notes, and snippets.

@eligrey
Last active December 4, 2024 00:24
Show Gist options
  • Save eligrey/c18267a9a9fa1d98226b133c22167c91 to your computer and use it in GitHub Desktop.
Save eligrey/c18267a9a9fa1d98226b133c22167c91 to your computer and use it in GitHub Desktop.
Persist text input userscript
// ==UserScript==
// @name Persist text input
// @description Persists input in text fields between navigations
// @author Eli Grey, The Chromium Authors
// @namespace https://eligrey.com
// @version 1.0.0
// @match *://*/*
// @grant none
// @run-at document-end
// @charset UTF-8
// @license MIT
// ==/UserScript==
/*
How to install:
1. Install the Tampermonkey extension for your browser
2. Click the 'Raw' button at the top of this text to install the userscript
*/
/*!
MIT License
Copyright (c) 2024 Eli Grey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
// CONFIGURATION
// Time in ms after the document is loaded to stop attempting to resolve & restore input after
// DOM mutations. Set to -1 to always keep mutation observer active, which has a performance cost.
const MUTATION_OBSERVER_CUTOFF = -1;
// Reduces specificity of generated XPath selectors when enabled.
const REDUCED_XPATH_SPECIFICITY = false;
// END CONFIGURATION
const getInputMapsKey = () => location.pathname + location.search;
// restore input
let inputMaps;
const resolveInputMap = () => {
const { inputCache } = sessionStorage;
inputMaps = inputCache ? JSON.parse(inputCache) : [];
const key = getInputMapsKey();
return new Map(
(inputMaps.find(([page]) => page === key)?.[1] || []).map(([xpath, value]) =>
[document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue, value]
)
);
}
let inputMap;
const updateInputMap = () => {
inputMap = resolveInputMap();
};
const restoreInput = () => {
updateInputMap();
for (const [target, value] of inputMap) {
if (target) {
target.value = value;
}
}
};
const mutationObserver = new MutationObserver((mutations) => {
if (mutations.some(({ type, addedNodes }) => type === 'childList' && addedNodes.length !== 0)) {
restoreInput();
}
});
mutationObserver.observe(document, {
subtree: true,
childList: true,
});
restoreInput();
// track input
addEventListener('input', ({ target }) => {
if (!inputMap) {
updateInputMap();
}
if (target instanceof Element) {
inputMap.set(target, target.value);
}
})
// persist input
addEventListener('pagehide', () => {
if (!inputMap) {
updateInputMap();
}
const key = getInputMapsKey();
const index = inputMaps.findIndex(([page]) => page === key);
const serialized = [key, [...inputMap].map(
([target, value]) => [Elements.DOMPath.xPath(target, REDUCED_XPATH_SPECIFICITY), value]
)];
const found = index !== -1;
inputMaps.splice(found ? index : inputMaps.length, found, serialized);
sessionStorage.inputCache = JSON.stringify(inputMaps);
});
// auto-remove mutation observer
if (MUTATION_OBSERVER_CUTOFF !== -1) {
const scheduleObserverRemoval = () => {
setTimeout(() => {
mutationObserver.disconnect();
}, MUTATION_OBSERVER_CUTOFF);
}
if (document.readyState === 'complete') {
scheduleObserverRemoval();
} else {
addEventListener('load', scheduleObserverRemoval);
}
}
// utils from https://stackoverflow.com/a/58677712/78436
// and https://github.com/chromium/chromium/blob/77578ccb4082ae20a9326d9e673225f1189ebb63/third_party/blink/renderer/devtools/front_end/elements/DOMPath.js#L242
/*!
* Copyright 2018 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file at
* https://chromium.googlesource.com/devtools/devtools-frontend/+/29905850a9777d36fbca9900cd306003c7bfe824/LICENSE
/
const Elements = {};
Elements.DOMPath = {};
/**
* @param {!Node} node
* @param {boolean=} optimized
* @return {string}
*/
Elements.DOMPath.xPath = function (node, optimized) {
if (node.nodeType === Node.DOCUMENT_NODE) {
return '/';
}
const steps = [];
let contextNode = node;
while (contextNode) {
const step = Elements.DOMPath._xPathValue(contextNode, optimized);
if (!step) {
break;
} // Error - bail out early.
steps.push(step);
if (step.optimized) {
break;
}
contextNode = contextNode.parentNode;
}
steps.reverse();
return (steps.length && steps[0].optimized ? '' : '/') + steps.join('/');
};
/**
* @param {!Node} node
* @param {boolean=} optimized
* @return {?Elements.DOMPath.Step}
*/
Elements.DOMPath._xPathValue = function (node, optimized) {
let ownValue;
const ownIndex = Elements.DOMPath._xPathIndex(node);
if (ownIndex === -1) {
return null;
} // Error.
switch (node.nodeType) {
case Node.ELEMENT_NODE:
if (optimized && node.getAttribute('id')) {
return new Elements.DOMPath.Step('//*[@id="' + node.getAttribute('id') + '"]', true);
}
ownValue = node.localName;
break;
case Node.ATTRIBUTE_NODE:
ownValue = '@' + node.nodeName;
break;
case Node.TEXT_NODE:
case Node.CDATA_SECTION_NODE:
ownValue = 'text()';
break;
case Node.PROCESSING_INSTRUCTION_NODE:
ownValue = 'processing-instruction()';
break;
case Node.COMMENT_NODE:
ownValue = 'comment()';
break;
case Node.DOCUMENT_NODE:
ownValue = '';
break;
default:
ownValue = '';
break;
}
if (ownIndex > 0) {
ownValue += '[' + ownIndex + ']';
}
return new Elements.DOMPath.Step(ownValue, node.nodeType === Node.DOCUMENT_NODE);
};
/**
* @param {!Node} node
* @return {number}
*/
Elements.DOMPath._xPathIndex = function (node) {
// Returns -1 in case of error, 0 if no siblings matching the same expression,
// <XPath index among the same expression-matching sibling nodes> otherwise.
function areNodesSimilar(left, right) {
if (left === right) {
return true;
}
if (left.nodeType === Node.ELEMENT_NODE && right.nodeType === Node.ELEMENT_NODE) {
return left.localName === right.localName;
}
if (left.nodeType === right.nodeType) {
return true;
}
// XPath treats CDATA as text nodes.
const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
return leftType === rightType;
}
const siblings = node.parentNode ? node.parentNode.children : null;
if (!siblings) {
return 0;
} // Root node - no siblings.
let hasSameNamedElements;
for (let i = 0; i < siblings.length; ++i) {
if (areNodesSimilar(node, siblings[i]) && siblings[i] !== node) {
hasSameNamedElements = true;
break;
}
}
if (!hasSameNamedElements) {
return 0;
}
let ownIndex = 1; // XPath indices start with 1.
for (let i = 0; i < siblings.length; ++i) {
if (areNodesSimilar(node, siblings[i])) {
if (siblings[i] === node) {
return ownIndex;
}
++ownIndex;
}
}
return -1; // An error occurred: |node| not found in parent's children.
};
/**
* @unrestricted
*/
Elements.DOMPath.Step = class {
/**
* @param {string} value
* @param {boolean} optimized
*/
constructor(value, optimized) {
this.value = value;
this.optimized = optimized || false;
}
/**
* @override
* @return {string}
*/
toString() {
return this.value;
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment