mirror of
https://github.com/klzgrad/naiveproxy.git
synced 2024-11-25 06:46:09 +03:00
398 lines
12 KiB
JavaScript
398 lines
12 KiB
JavaScript
|
// 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.
|
||
|
|
||
|
// @ts-check
|
||
|
'use strict';
|
||
|
|
||
|
/**
|
||
|
* @fileoverview
|
||
|
* Methods for manipulating the state and the DOM of the page
|
||
|
*/
|
||
|
|
||
|
/** @type {HTMLFormElement} Form containing options and filters */
|
||
|
const form = document.getElementById('options');
|
||
|
|
||
|
/** Utilities for working with the DOM */
|
||
|
const dom = {
|
||
|
/**
|
||
|
* Create a document fragment from the given nodes
|
||
|
* @param {Iterable<Node>} nodes
|
||
|
* @returns {DocumentFragment}
|
||
|
*/
|
||
|
createFragment(nodes) {
|
||
|
const fragment = document.createDocumentFragment();
|
||
|
for (const node of nodes) fragment.appendChild(node);
|
||
|
return fragment;
|
||
|
},
|
||
|
/**
|
||
|
* Removes all the existing children of `parent` and inserts
|
||
|
* `newChild` in their place
|
||
|
* @param {Node} parent
|
||
|
* @param {Node | null} newChild
|
||
|
*/
|
||
|
replace(parent, newChild) {
|
||
|
while (parent.firstChild) parent.removeChild(parent.firstChild);
|
||
|
if (newChild != null) parent.appendChild(newChild);
|
||
|
},
|
||
|
/**
|
||
|
* Builds a text element in a single statement.
|
||
|
* @param {string} tagName Type of the element, such as "span".
|
||
|
* @param {string} text Text content for the element.
|
||
|
* @param {string} [className] Class to apply to the element.
|
||
|
*/
|
||
|
textElement(tagName, text, className) {
|
||
|
const element = document.createElement(tagName);
|
||
|
element.textContent = text;
|
||
|
if (className) element.className = className;
|
||
|
return element;
|
||
|
},
|
||
|
};
|
||
|
|
||
|
/** Build utilities for working with the state. */
|
||
|
function _initState() {
|
||
|
const _DEFAULT_FORM = new FormData(form);
|
||
|
|
||
|
/**
|
||
|
* State is represented in the query string and
|
||
|
* can be manipulated by this object. Keys in the query match with
|
||
|
* input names.
|
||
|
*/
|
||
|
let _filterParams = new URLSearchParams(location.search.slice(1));
|
||
|
const typeList = _filterParams.getAll(_TYPE_STATE_KEY);
|
||
|
_filterParams.delete(_TYPE_STATE_KEY);
|
||
|
for (const type of types(typeList)) {
|
||
|
_filterParams.append(_TYPE_STATE_KEY, type);
|
||
|
}
|
||
|
|
||
|
const state = Object.freeze({
|
||
|
/**
|
||
|
* Returns a string from the current query string state.
|
||
|
* @param {string} key
|
||
|
* @returns {string | null}
|
||
|
*/
|
||
|
get(key) {
|
||
|
return _filterParams.get(key);
|
||
|
},
|
||
|
/**
|
||
|
* Checks if a key is present in the query string state.
|
||
|
* @param {string} key
|
||
|
* @returns {boolean}
|
||
|
*/
|
||
|
has(key) {
|
||
|
return _filterParams.has(key);
|
||
|
},
|
||
|
/**
|
||
|
* Formats the filter state as a string.
|
||
|
*/
|
||
|
toString() {
|
||
|
const copy = new URLSearchParams(_filterParams);
|
||
|
const types = [...new Set(copy.getAll(_TYPE_STATE_KEY))];
|
||
|
if (types.length > 0) copy.set(_TYPE_STATE_KEY, types.join(''));
|
||
|
|
||
|
const queryString = copy.toString();
|
||
|
return queryString.length > 0 ? `?${queryString}` : '';
|
||
|
},
|
||
|
/**
|
||
|
* Saves a key and value into a temporary state not displayed in the URL.
|
||
|
* @param {string} key
|
||
|
* @param {string | null} value
|
||
|
*/
|
||
|
set(key, value) {
|
||
|
if (value == null) {
|
||
|
_filterParams.delete(key);
|
||
|
} else {
|
||
|
_filterParams.set(key, value);
|
||
|
}
|
||
|
history.replaceState(null, null, state.toString());
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// Update form inputs to reflect the state from URL.
|
||
|
for (const element of Array.from(form.elements)) {
|
||
|
if (element.name) {
|
||
|
const input = /** @type {HTMLInputElement} */ (element);
|
||
|
const values = _filterParams.getAll(input.name);
|
||
|
const [value] = values;
|
||
|
if (value) {
|
||
|
switch (input.type) {
|
||
|
case 'checkbox':
|
||
|
input.checked = values.includes(input.value);
|
||
|
break;
|
||
|
case 'radio':
|
||
|
input.checked = value === input.value;
|
||
|
break;
|
||
|
default:
|
||
|
input.value = value;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Yields only entries that have been modified in
|
||
|
* comparison to `_DEFAULT_FORM`.
|
||
|
* @param {FormData} modifiedForm
|
||
|
*/
|
||
|
function* onlyChangedEntries(modifiedForm) {
|
||
|
// Remove default values
|
||
|
for (const key of modifiedForm.keys()) {
|
||
|
const modifiedValues = modifiedForm.getAll(key);
|
||
|
const defaultValues = _DEFAULT_FORM.getAll(key);
|
||
|
|
||
|
const valuesChanged =
|
||
|
modifiedValues.length !== defaultValues.length ||
|
||
|
modifiedValues.some((v, i) => v !== defaultValues[i]);
|
||
|
if (valuesChanged) {
|
||
|
for (const value of modifiedValues) {
|
||
|
yield [key, value];
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Update the state when the form changes.
|
||
|
function _updateStateFromForm() {
|
||
|
const modifiedForm = new FormData(form);
|
||
|
_filterParams = new URLSearchParams(onlyChangedEntries(modifiedForm));
|
||
|
history.replaceState(null, null, state.toString());
|
||
|
}
|
||
|
|
||
|
form.addEventListener('change', _updateStateFromForm);
|
||
|
|
||
|
return state;
|
||
|
}
|
||
|
|
||
|
function _startListeners() {
|
||
|
const _SHOW_OPTIONS_STORAGE_KEY = 'show-options';
|
||
|
|
||
|
/** @type {HTMLFieldSetElement} */
|
||
|
const typesFilterContainer = document.getElementById('types-filter');
|
||
|
/** @type {HTMLInputElement} */
|
||
|
const methodCountInput = form.elements.namedItem('method_count');
|
||
|
/** @type {HTMLFieldSetElement} */
|
||
|
const byteunit = form.elements.namedItem('byteunit');
|
||
|
/** @type {HTMLCollectionOf<HTMLInputElement>} */
|
||
|
const typeCheckboxes = form.elements.namedItem(_TYPE_STATE_KEY);
|
||
|
/** @type {HTMLSpanElement} */
|
||
|
const sizeHeader = document.getElementById('size-header');
|
||
|
|
||
|
/**
|
||
|
* The settings dialog on the side can be toggled on and off by elements with
|
||
|
* a 'toggle-options' class.
|
||
|
*/
|
||
|
function _toggleOptions() {
|
||
|
const openedOptions = document.body.classList.toggle('show-options');
|
||
|
localStorage.setItem(_SHOW_OPTIONS_STORAGE_KEY, openedOptions.toString());
|
||
|
}
|
||
|
for (const button of document.getElementsByClassName('toggle-options')) {
|
||
|
button.addEventListener('click', _toggleOptions);
|
||
|
}
|
||
|
// Default to open if getItem returns null
|
||
|
if (localStorage.getItem(_SHOW_OPTIONS_STORAGE_KEY) !== 'false') {
|
||
|
document.body.classList.add('show-options');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Disable some fields when method_count is set
|
||
|
*/
|
||
|
function setMethodCountModeUI() {
|
||
|
if (methodCountInput.checked) {
|
||
|
byteunit.setAttribute('disabled', '');
|
||
|
typesFilterContainer.setAttribute('disabled', '');
|
||
|
sizeHeader.textContent = 'Methods';
|
||
|
} else {
|
||
|
byteunit.removeAttribute('disabled');
|
||
|
typesFilterContainer.removeAttribute('disabled');
|
||
|
sizeHeader.textContent = 'Size';
|
||
|
}
|
||
|
}
|
||
|
setMethodCountModeUI();
|
||
|
methodCountInput.addEventListener('change', setMethodCountModeUI);
|
||
|
|
||
|
/**
|
||
|
* Display error text on blur for regex inputs, if the input is invalid.
|
||
|
* @param {Event} event
|
||
|
*/
|
||
|
function checkForRegExError(event) {
|
||
|
const input = /** @type {HTMLInputElement} */ (event.currentTarget);
|
||
|
const errorBox = document.getElementById(
|
||
|
input.getAttribute('aria-describedby')
|
||
|
);
|
||
|
try {
|
||
|
new RegExp(input.value);
|
||
|
errorBox.textContent = '';
|
||
|
input.setAttribute('aria-invalid', 'false');
|
||
|
} catch (err) {
|
||
|
errorBox.textContent = err.message;
|
||
|
input.setAttribute('aria-invalid', 'true');
|
||
|
}
|
||
|
}
|
||
|
for (const input of document.getElementsByClassName('input-regex')) {
|
||
|
input.addEventListener('blur', checkForRegExError);
|
||
|
input.dispatchEvent(new Event('blur'));
|
||
|
}
|
||
|
|
||
|
document.getElementById('type-all').addEventListener('click', () => {
|
||
|
for (const checkbox of typeCheckboxes) {
|
||
|
checkbox.checked = true;
|
||
|
}
|
||
|
form.dispatchEvent(new Event('change'));
|
||
|
});
|
||
|
document.getElementById('type-none').addEventListener('click', () => {
|
||
|
for (const checkbox of typeCheckboxes) {
|
||
|
checkbox.checked = false;
|
||
|
}
|
||
|
form.dispatchEvent(new Event('change'));
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function _makeIconTemplateGetter() {
|
||
|
const _icons = document.getElementById('icons');
|
||
|
|
||
|
/**
|
||
|
* @type {{[type:string]: SVGSVGElement}} Icon elements
|
||
|
* that correspond to each symbol type.
|
||
|
*/
|
||
|
const symbolIcons = {
|
||
|
D: _icons.querySelector('.foldericon'),
|
||
|
C: _icons.querySelector('.componenticon'),
|
||
|
J: _icons.querySelector('.javaclassicon'),
|
||
|
F: _icons.querySelector('.fileicon'),
|
||
|
b: _icons.querySelector('.bssicon'),
|
||
|
d: _icons.querySelector('.dataicon'),
|
||
|
r: _icons.querySelector('.readonlyicon'),
|
||
|
t: _icons.querySelector('.codeicon'),
|
||
|
v: _icons.querySelector('.vtableicon'),
|
||
|
'*': _icons.querySelector('.generatedicon'),
|
||
|
x: _icons.querySelector('.dexicon'),
|
||
|
m: _icons.querySelector('.dexmethodicon'),
|
||
|
p: _icons.querySelector('.localpakicon'),
|
||
|
P: _icons.querySelector('.nonlocalpakicon'),
|
||
|
o: _icons.querySelector('.othericon'), // used as default icon
|
||
|
};
|
||
|
|
||
|
/** @type {Map<string, {color:string,description:string}>} */
|
||
|
const iconInfoCache = new Map();
|
||
|
|
||
|
/**
|
||
|
* Returns the SVG icon template element corresponding to the given type.
|
||
|
* @param {string} type Symbol type character.
|
||
|
* @param {boolean} readonly If true, the original template is returned.
|
||
|
* If false, a copy is returned that can be modified.
|
||
|
* @returns {SVGSVGElement}
|
||
|
*/
|
||
|
function getIconTemplate(type, readonly = false) {
|
||
|
const iconTemplate = symbolIcons[type] || symbolIcons[_OTHER_SYMBOL_TYPE];
|
||
|
return readonly ? iconTemplate : iconTemplate.cloneNode(true);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns style info about SVG icon template element corresponding to the
|
||
|
* given type.
|
||
|
* @param {string} type Symbol type character.
|
||
|
*/
|
||
|
function getIconStyle(type) {
|
||
|
let info = iconInfoCache.get(type);
|
||
|
if (info == null) {
|
||
|
const icon = getIconTemplate(type, true);
|
||
|
info = {
|
||
|
color: icon.getAttribute('fill'),
|
||
|
description: icon.querySelector('title').textContent,
|
||
|
};
|
||
|
iconInfoCache.set(type, info);
|
||
|
}
|
||
|
return info;
|
||
|
}
|
||
|
|
||
|
return {getIconTemplate, getIconStyle};
|
||
|
}
|
||
|
|
||
|
function _makeSizeTextGetter() {
|
||
|
const _SIZE_CHANGE_CUTOFF = 50000;
|
||
|
|
||
|
/**
|
||
|
* Create the contents for the size element of a tree node.
|
||
|
* The unit to use is selected from the current state.
|
||
|
*
|
||
|
* If in method count mode, size instead represents the amount of methods in
|
||
|
* the node. Otherwise, the original number of bytes will be displayed.
|
||
|
*
|
||
|
* @param {TreeNode} node Node whose size is the number of bytes to use for
|
||
|
* the size text
|
||
|
* @returns {GetSizeResult} Object with hover text title and
|
||
|
* size element body. Can be consumed by `_applySizeFunc()`
|
||
|
*/
|
||
|
function getSizeContents(node) {
|
||
|
if (state.has('method_count')) {
|
||
|
const {count: methodCount = 0} =
|
||
|
node.childStats[_DEX_METHOD_SYMBOL_TYPE] || {};
|
||
|
const methodStr = methodCount.toLocaleString(_LOCALE, {
|
||
|
useGrouping: true,
|
||
|
});
|
||
|
|
||
|
return {
|
||
|
element: document.createTextNode(methodStr),
|
||
|
description: `${methodStr} method${methodCount === 1 ? '' : 's'}`,
|
||
|
value: methodCount,
|
||
|
};
|
||
|
} else {
|
||
|
const bytes = node.size;
|
||
|
let unit = state.get('byteunit');
|
||
|
let suffix = _BYTE_UNITS[unit];
|
||
|
if (suffix == null) {
|
||
|
unit = 'MiB';
|
||
|
suffix = _BYTE_UNITS.MiB;
|
||
|
}
|
||
|
|
||
|
// Format the bytes as a number with 2 digits after the decimal point
|
||
|
const text = (bytes / suffix).toLocaleString(_LOCALE, {
|
||
|
minimumFractionDigits: 2,
|
||
|
maximumFractionDigits: 2,
|
||
|
});
|
||
|
const textNode = document.createTextNode(`${text} `);
|
||
|
|
||
|
// Display the suffix with a smaller font
|
||
|
const suffixElement = dom.textElement('small', unit);
|
||
|
|
||
|
const bytesGrouped = bytes.toLocaleString(_LOCALE, {useGrouping: true});
|
||
|
|
||
|
return {
|
||
|
element: dom.createFragment([textNode, suffixElement]),
|
||
|
description: `${bytesGrouped} bytes`,
|
||
|
value: bytes,
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set classes on an element based on the size it represents.
|
||
|
* @param {HTMLElement} sizeElement
|
||
|
* @param {number} value
|
||
|
*/
|
||
|
function setSizeClasses(sizeElement, value) {
|
||
|
const shouldHaveStyle =
|
||
|
state.has('diff_mode') && Math.abs(value) > _SIZE_CHANGE_CUTOFF;
|
||
|
if (shouldHaveStyle) {
|
||
|
if (value < 0) {
|
||
|
sizeElement.classList.add('shrunk');
|
||
|
sizeElement.classList.remove('grew');
|
||
|
} else {
|
||
|
sizeElement.classList.remove('shrunk');
|
||
|
sizeElement.classList.add('grew');
|
||
|
}
|
||
|
} else {
|
||
|
sizeElement.classList.remove('shrunk', 'grew');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return {getSizeContents, setSizeClasses};
|
||
|
}
|
||
|
|
||
|
/** Utilities for working with the state */
|
||
|
const state = _initState();
|
||
|
const {getIconTemplate, getIconStyle} = _makeIconTemplateGetter();
|
||
|
const {getSizeContents, setSizeClasses} = _makeSizeTextGetter();
|
||
|
_startListeners();
|