diff --git a/package-lock.json b/package-lock.json
index 65145ac..76884d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,6 +53,7 @@
"postcss-nested": "^7.0.2",
"postcss-preset-env": "^10.4.0",
"postcss-strip-inline-comments": "^0.1.5",
+ "raw-loader": "^4.0.2",
"resolve-url-loader": "^5.0.0",
"tailwindcss": "^4.1.17",
"ts-loader": "^9.5.4",
@@ -17838,6 +17839,95 @@
"node": ">= 0.8"
}
},
+ "node_modules/raw-loader": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+ "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/raw-loader/node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/raw-loader/node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/raw-loader/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/raw-loader/node_modules/loader-utils": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
+ "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=8.9.0"
+ }
+ },
+ "node_modules/raw-loader/node_modules/schema-utils": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
+ "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
diff --git a/package.json b/package.json
index 274b7b1..fe7bd0b 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,7 @@
"postcss-nested": "^7.0.2",
"postcss-preset-env": "^10.4.0",
"postcss-strip-inline-comments": "^0.1.5",
+ "raw-loader": "^4.0.2",
"resolve-url-loader": "^5.0.0",
"tailwindcss": "^4.1.17",
"ts-loader": "^9.5.4",
diff --git a/src/csui/Popup.vue b/src/csui/Popup.vue
index 60eb028..789e3d6 100644
--- a/src/csui/Popup.vue
+++ b/src/csui/Popup.vue
@@ -125,7 +125,6 @@ import BrowserDetect from '../ext/conf/BrowserDetect';
import CommsClient, {CommsOrigin} from '../ext/lib/comms/CommsClient';
import Settings from '../ext/lib/settings/Settings';
import EventBus from '../ext/lib/EventBus';
-import {ChromeShittinessMitigations as CSM} from '../common/js/ChromeShittinessMitigations';
import SupportLevelIndicator from '@csui/src/components/SupportLevelIndicator.vue'
import { LogAggregator } from '@src/ext/lib/logging/LogAggregator';
import { ComponentLogger } from '@src/ext/lib/logging/ComponentLogger';
diff --git a/src/ext/lib/settings/Settings.ts b/src/ext/lib/settings/Settings.ts
index 4d8a2e9..19497d3 100644
--- a/src/ext/lib/settings/Settings.ts
+++ b/src/ext/lib/settings/Settings.ts
@@ -61,7 +61,7 @@ class Settings {
constructor(options: SettingsOptions) {
// Options: activeSettings, updateCallback, logger
- this.logger = options.logAggregator && new ComponentLogger(options.logAggregator, 'Settings', {styles: SETTINGS_LOGGER_STYLES}) || undefined;;
+ this.logger = options.logAggregator && new ComponentLogger(options.logAggregator, 'Settings', {styles: SETTINGS_LOGGER_STYLES}) || undefined;
this.onSettingsChanged = options.onSettingsChanged;
this.afterSettingsSaved = options.afterSettingsSaved;
this.active = options.activeSettings ?? undefined;
@@ -73,6 +73,24 @@ class Settings {
chrome.storage.onChanged.addListener((changes, area) => {this.storageChangeListener(changes, area)});
}
+ updateOptions(options: SettingsOptions) {
+ if (options.logAggregator) {
+ this.logger = options.logAggregator && new ComponentLogger(options.logAggregator, 'Settings', {styles: SETTINGS_LOGGER_STYLES})
+ }
+
+ if (options.onSettingsChanged) {
+ this.onSettingsChanged = options.onSettingsChanged;
+ }
+
+ if (options.afterSettingsSaved) {
+ this.afterSettingsSaved = options.afterSettingsSaved;
+ }
+
+ if (options.activeSettings) {
+ this.active = options.activeSettings;
+ }
+ }
+
private storageChangeListener(changes, area) {
if (!changes.uwSettings) {
return;
diff --git a/src/main.css b/src/main.css
index fc1b12a..b2b33b7 100644
--- a/src/main.css
+++ b/src/main.css
@@ -38,4 +38,40 @@ html, body {
h1, h2, h3, h4, h5, h6 {
@apply font-mono;
}
+
+ h2 {
+ @apply text-[1.75em];
+ }
+
+ h3 {
+ @apply text-primary-300 text-[1.25em] mt-4 border-b-1 border-b-stone-700;
+ }
+
+ a {
+ @apply text-primary-400;
+
+ &:hover {
+ @apply text-primary-300;
+ }
+ }
+
+ p {
+ @apply mt-1 mb-2;
+ }
+
+ p, li {
+ @apply leading-tight;
+ }
+
+ ul {
+ @apply bg-stone-900/50 px-4 py-2 mt-1 mb-2;
+
+ li {
+ @apply ml-4 list-disc marker:text-primary-400 mt-1 mb-2;
+ }
+ }
+
+ small, strike {
+ @apply opacity-50;
+ }
}
diff --git a/src/ui/components/Debugging/Debugging.vue b/src/ui/components/Debugging/Debugging.vue
new file mode 100644
index 0000000..7622555
--- /dev/null
+++ b/src/ui/components/Debugging/Debugging.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
{{loggerPanel.title}} {{loggerPanel.subtitle}}
+
+
Logger configuration:
+
+
+
+
+
+ Revert
+
+
+ Save
+
+
+ Save and start logging
+
+
+
+
+
+
+
+
diff --git a/src/ui/components/ExtensionInfo/About.vue b/src/ui/components/ExtensionInfo/About.vue
new file mode 100644
index 0000000..f9b1ed2
--- /dev/null
+++ b/src/ui/components/ExtensionInfo/About.vue
@@ -0,0 +1,35 @@
+
+
+
About Ultrawidify
+
+
+
+
diff --git a/src/ui/components/ExtensionInfo/WhatsNew.vue b/src/ui/components/ExtensionInfo/WhatsNew.vue
new file mode 100644
index 0000000..a063451
--- /dev/null
+++ b/src/ui/components/ExtensionInfo/WhatsNew.vue
@@ -0,0 +1,80 @@
+
+
+
What's new
+
+
+
Changelog
+
+
+
+
+
Report problems
+
+
+
+
+
+
+
diff --git a/src/ui/components/ExtensionInfo/segments/ChangelogContent.vue b/src/ui/components/ExtensionInfo/segments/ChangelogContent.vue
new file mode 100644
index 0000000..586f994
--- /dev/null
+++ b/src/ui/components/ExtensionInfo/segments/ChangelogContent.vue
@@ -0,0 +1,59 @@
+
+ Full changelog for older versions is available here .
+
+ Changes in version 6.4.0 :
+
+ In-player UI can now appear in full-screen for websites that put players into top layer (allegedly).
+ Embedded sites now inherit settings of the parent frame. However, this hasn't been tested for all edge cases and may contain bugs.
+ Added validation to custom aspect ratio entry menu. Corrected parsing of aspect ratios given in the X:Y format, even though aspect ratios should be ideally given as a single number.
+ Autodetection can be set to stop after first aspect ratio detection, or after a period of no changes.
+
+ There is a new, experiental mode for autodetection. At this point, it can be manually enabled in the autodetection settings. It will become the default option in 2026.
+ If you enable experimental mode, please consider reporting problems in this thread on Github.
+
+
+ Autodetection can now detect hardcoded subtitles. You may change how extension handles subtitles in the settings.
+ Use of new experimental autodetection is required for subtitle handling options to work.
+
+
+ Keyboard shortcut settings have been split from UI settings, and are now presented in list form on the settings page.
+
+
+ Re-design of settings page.
+
+
+
+
+
+
diff --git a/src/ui/components/ExtensionInfo/segments/ExtensionInfoContent.vue b/src/ui/components/ExtensionInfo/segments/ExtensionInfoContent.vue
new file mode 100644
index 0000000..65af237
--- /dev/null
+++ b/src/ui/components/ExtensionInfo/segments/ExtensionInfoContent.vue
@@ -0,0 +1,93 @@
+
+
+
+
Ultrawidify
+
Version {{addonVersion}}/{{addonSource}} ({{addonChannel}})
+
Source code can be viewed on
github .
+
+
+
+ Please report undocumented features bugs using one of the following options (in order of preference):
+
+
+
+ When reporting bugs, please include the following info:
+
+
+
+ Ultrawidify version: {{addonVersion}}
+ Install source: {{addonSource ?? '[fill out]'}}
+ OS: [fill out]
+
+
+
+ In addition, please include a description of your issue, which should ideally include:
+
+
+
+ What doesn't work
+ Where it happens (which website)
+ Steps to reproduce the issue
+ What you expected to happen
+ What actually happened
+
+
+
+ Optionally, you can also include screenshots in your report (though if screenshots contain any personal info,
+ such as names or addresses, you should redact that).
+
+
+ Thank you monies
+
+ If you think I deserve money for the work I did up to this point, you can bankroll my caffeine addiction.
+
+
+ Donate on Paypal
+
+
+ If you're into that, you can also stoke my ego over at
+ instagram or
+ bluesky
+ by liking the stuff I put up on there mostly things related to mini-painting .
+
+
+
+
diff --git a/src/ui/components/KeyboardShortcuts/KeyboardShortcutSettings.vue b/src/ui/components/KeyboardShortcuts/KeyboardShortcutSettings.vue
index 4ce1244..63a0d23 100644
--- a/src/ui/components/KeyboardShortcuts/KeyboardShortcutSettings.vue
+++ b/src/ui/components/KeyboardShortcuts/KeyboardShortcutSettings.vue
@@ -5,7 +5,7 @@
Clear all shortcuts
- Reset to default
+ Reset to default
diff --git a/src/ui/components/SettingsWindowContent.vue b/src/ui/components/SettingsWindowContent.vue
index 20624e7..d8d5af0 100644
--- a/src/ui/components/SettingsWindowContent.vue
+++ b/src/ui/components/SettingsWindowContent.vue
@@ -1,8 +1,8 @@
-
+
-
+
@@ -50,24 +50,18 @@
-
+ >
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
-
Player UI options
-
Menu options and keyboard shortcuts
-
- Click 'add new' to add a new option. Click a button to edit or remove the keyboard shortcut.
-
-
-
-
-
-
CROP OPTIONS
-
-
-
-
-
-
-
-
-
-
STRETCH OPTIONS
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/ui/components/VideoSettings/components/ShortcutButton.vue b/src/ui/components/VideoSettings/components/ShortcutButton.vue
new file mode 100644
index 0000000..63a8fcf
--- /dev/null
+++ b/src/ui/components/VideoSettings/components/ShortcutButton.vue
@@ -0,0 +1,22 @@
+
+
+
+ {{label}}
+
+
+ {{shortcut ? `(${shortcut})` : ''}}
+
+
+
+
+
diff --git a/src/ui/components/common/mixins/CommsMixin.vue b/src/ui/components/common/mixins/CommsMixin.vue
new file mode 100644
index 0000000..1bf410c
--- /dev/null
+++ b/src/ui/components/common/mixins/CommsMixin.vue
@@ -0,0 +1,22 @@
+
diff --git a/src/ui/components/common/mixins/KeyboardShortcutParserMixin.ts b/src/ui/components/common/mixins/KeyboardShortcutParserMixin.vue
similarity index 92%
rename from src/ui/components/common/mixins/KeyboardShortcutParserMixin.ts
rename to src/ui/components/common/mixins/KeyboardShortcutParserMixin.vue
index a9856a7..42f9a38 100644
--- a/src/ui/components/common/mixins/KeyboardShortcutParserMixin.ts
+++ b/src/ui/components/common/mixins/KeyboardShortcutParserMixin.vue
@@ -1,3 +1,4 @@
+
diff --git a/src/ui/pages/popup/index.html b/src/ui/pages/popup/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/src/ui/pages/popup/popup.js b/src/ui/pages/popup/popup.js
new file mode 100644
index 0000000..6a1afed
--- /dev/null
+++ b/src/ui/pages/popup/popup.js
@@ -0,0 +1,30 @@
+import EventBus from '../../../ext/lib/EventBus';
+import { ComponentLogger } from '../../../ext/lib/logging/ComponentLogger';
+import { LogAggregator } from '../../../ext/lib/logging/LogAggregator';
+import Settings from '../../../ext/lib/settings/Settings';
+
+const loadedTabs = [
+ 'videoSettings',
+ 'extensionSettings',
+ 'changelog',
+ 'about',
+];
+
+async function load() {
+ const _uw_data = {
+ eventBus: new EventBus(),
+ settings: {},
+ settingsInitialized: false,
+ logAggregator: undefined,
+ logger: undefined,
+ site: undefined,
+ siteSettings: undefined,
+ comms: undefined,
+ };
+
+ _uw_data.logAggregator = new LogAggregator('🔵ext-popup🔵');
+ _uw_data.logger = new ComponentLogger(_uw_data.logAggregator, 'Popup');
+ _uw_data.settings = new Settings({logAggregator: _uw_data.logAggregator});
+
+ _uw_data.
+}
diff --git a/src/ui/pages/settings/App.vue b/src/ui/pages/settings/App.vue
index 8405966..429d7a8 100644
--- a/src/ui/pages/settings/App.vue
+++ b/src/ui/pages/settings/App.vue
@@ -1,16 +1,20 @@
-
+
-
-
Ultrawidify settings
+
+
Ultrawidify settings
Loading settings...
this.updateConfig()
- });
await this.settings.init();
this.settingsInitialized = true;
},
@@ -55,6 +72,140 @@ export default defineComponent({
updateConfig() {
this.settings.init();
this.$nextTick( () => this.$forceUpdate());
+ },
+
+ /**
+ * Initializes page when it's being loaded from the settings
+ */
+ async setupSettingsPage() {
+ this.role = 'settings';
+
+ this.logAggregator = new LogAggregator('');
+ this.logger = new ComponentLogger(this.logAggregator, 'App.vue');
+
+ this.settings = new Settings({
+ logAggregator: this.logAggregator,
+ onSettingsChanged: () => this.updateConfig()
+ });
+ },
+
+ /**
+ * Initializes page when it's being loaded as extension popup
+ */
+ async setupPopup() {
+ this.role = 'popup';
+ try {
+ this.logAggregator = new LogAggregator('🔵ext-popup🔵');
+ this.logger = new ComponentLogger(this.logAggregator, 'Popup');
+
+ this.settings = new Settings({afterSettingsSaved: () => this.updateConfig(), logAggregator: this.logAggregator});
+ await this.settings.init();
+ this.settingsInitialized = true;
+
+ // const port = chrome.runtime.connect({name: 'popup-port'});
+ // port.onMessage.addListener( (m,p) => this.processReceivedMessage(m,p));
+ // CSM.setProperty('port', port);
+
+ this.eventBus = new EventBus();
+ this.eventBus.subscribe(
+ 'set-current-site',
+ {
+ source: this,
+ function: (config, context) => {
+ if (this.site) {
+ if (!this.site.host) {
+ // dunno why this fix is needed, but sometimes it is
+ this.site.host = config.site.host;
+ }
+ }
+ this.site = config.site;
+ // this.selectedSite = this.selectedSite || config.site.host;
+ this.siteSettings = this.settings.getSiteSettings({site: this.site.host});
+ this.eventBus.setupPopupTunnelWorkaround({
+ origin: CommsOrigin.Popup,
+ comms: {
+ forwardTo: 'active'
+ }
+ });
+
+ this.loadHostnames();
+ this.loadFrames();
+ }
+ },
+ );
+ this.eventBus.subscribe(
+ 'open-popup-settings',
+ {
+ source: this,
+ function: (config) => {
+ this.selectTab(config.tab)
+ }
+ }
+ )
+
+ this.comms = new CommsClient('popup-port', this.logger, this.eventBus);
+ this.eventBus.setComms(this.comms);
+ this.eventBus.setupPopupTunnelWorkaround({
+ origin: CommsOrigin.Popup,
+ comms: {forwardTo: 'active'}
+ });
+
+
+ // ensure we'll clean player markings on popup close
+ window.addEventListener("unload", () => {
+ CSM.port.postMessage({
+ cmd: 'unmark-player',
+ forwardToAll: true,
+ });
+ // if (BrowserDetect.anyChromium) {
+ // chrome.extension.getBackgroundPage().sendUnmarkPlayer({
+ // cmd: 'unmark-player',
+ // forwardToAll: true,
+ // });
+ // }
+ });
+
+ // get info about current site from background script
+ while (true) {
+ this.requestSite();
+ await this.sleep(5000);
+ }
+ } catch (e) {
+ console.error('[Popup.vue::created()] An error happened:', e)
+ }
+ },
+
+ /**
+ * Initializes page when it's being loaded as in-page settings window
+ */
+ async setupIframe() {
+ this.role='ui-window';
+
+ this.logAggregator = new LogAggregator('settings-page');
+ this.logger = new ComponentLogger(this.logAggregator, 'SettingsPage');
+
+ this.settings = new Settings({
+ logAggregator: this.logAggregator,
+ onSettingsChanged: () => this.updateConfig()
+ });
+
+ if (this.defaultTab) {
+ this.selectedTab = this.defaultTab;
+ }
+ this.siteSettings = this.settings.getSiteSettings({site: this.site});
+ this.tabs.find(x => x.id === 'changelog').highlight = !this.settings.active?.whatsNewChecked;
+
+ this.eventBus?.subscribe(
+ 'uw-show-ui',
+ {
+ source: this,
+ function: () => {
+ if (this.inPlayer) {
+ return; // show-ui is only intended for global overlay
+ }
+ },
+ }
+ )
}
}
});
diff --git a/src/ui/res/img/alignment-indicators.svg b/src/ui/res/img/alignment-indicators.svg
new file mode 100644
index 0000000..02db377
--- /dev/null
+++ b/src/ui/res/img/alignment-indicators.svg
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ui/utils/video-alignment-indicator-handling.ts b/src/ui/utils/video-alignment-indicator-handling.ts
new file mode 100644
index 0000000..7f42d6b
--- /dev/null
+++ b/src/ui/utils/video-alignment-indicator-handling.ts
@@ -0,0 +1,68 @@
+import VideoAlignmentType from '@src/common/enums/VideoAlignmentType.enum';
+
+const positionMap: Record = {
+ [VideoAlignmentType.Left]: 'left',
+ [VideoAlignmentType.Center]: 'center',
+ [VideoAlignmentType.Right]: 'right',
+ [VideoAlignmentType.Top]: 'top',
+ [VideoAlignmentType.Bottom]: 'bottom',
+ [VideoAlignmentType.Default]: ''
+};
+
+const location2enum: Record = {
+ left: VideoAlignmentType.Left,
+ center: VideoAlignmentType.Center,
+ right: VideoAlignmentType.Right,
+ top: VideoAlignmentType.Top,
+ bottom: VideoAlignmentType.Bottom
+};
+
+
+/**
+ * Sets alignment indicator state of the video alignment SVG
+ * @param svg SVG element
+ * @param x horizontal alignment
+ * @param y vertical alignment
+ * @returns
+ */
+export function setVideoAlignmentIndicatorState(
+ svg: SVGSVGElement,
+ x: VideoAlignmentType,
+ y: VideoAlignmentType
+) {
+ // reset all indicators
+ svg.querySelectorAll('g').forEach(g => g.classList.remove('selected'));
+
+ // select the appropriate square
+ if (x === VideoAlignmentType.Default || y === VideoAlignmentType.Default) {
+ return;
+ }
+
+ const gId = `${positionMap[y]}-${positionMap[x]}`;
+ const selected = svg.getElementById(gId);
+ if (selected) selected.classList.add('selected');
+}
+
+/**
+ * Sets up interaction for the video alignment SVG
+ * @param svg SVG element
+ * @param callback function to be called, with x and y alignment (VideoAlignmentType)
+ */
+export function setupVideoAlignmentIndicatorInteraction(
+ svg: SVGSVGElement,
+ callback: (x: VideoAlignmentType, y: VideoAlignmentType) => void
+) {
+ svg.querySelectorAll('g').forEach(g => {
+ g.addEventListener('click', () => {
+ const [y, x] = g.id.split('-');
+
+ if (!x || !y)
+ return;
+
+ callback(
+ location2enum[x],
+ location2enum[y]
+ )
+ });
+ });
+}