Compare commits

..

No commits in common. "master" and "v1.5.16" have entirely different histories.

281 changed files with 82133 additions and 50383 deletions

View File

@ -203,7 +203,7 @@ rules:
prefer-const: [1, {destructuring: all, ignoreReadBeforeAssign: true}] prefer-const: [1, {destructuring: all, ignoreReadBeforeAssign: true}]
quote-props: [0] quote-props: [0]
quotes: [1, single, avoid-escape] quotes: [1, single, avoid-escape]
radix: [2, always] radix: [2, as-needed]
require-jsdoc: [0] require-jsdoc: [0]
require-yield: [2] require-yield: [2]
semi-spacing: [2, {before: false, after: true}] semi-spacing: [2, {before: false, after: true}]

View File

@ -24,9 +24,6 @@ If not, then provide details describing which page the feature will effect, e.g.
## Adding translations ## Adding translations
You can help us translate the extension on [Transifex](https://www.transifex.com/github-7/Stylus). You can help us translate the extension on [Transifex](https://www.transifex.com/github-7/Stylus).
Only the languages supported by the web store are allowed:
https://developer.chrome.com/docs/webstore/i18n/#localeTable
## Pull requests ## Pull requests

9
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,9 @@
* **Browser**:
* **Operating System**:
* **Stylus Version**:
* **Screenshot**:
<!--
Please make sure you checked that your issue wasn't already addressed.
If the issue persists, please help us identifying the cause by providing the above details.
-->

View File

@ -1,48 +0,0 @@
---
name: Bug Report
about: Create a report about a bug you experienced while using Stylus.
title: "[Bug] Replace with title"
assignees: ''
---
<!--
⚠⚠ Do not delete this issue template! ⚠⚠
Reported issues must use this template and have all the necessary information provided.
Incomplete reports are likely to be ignored and closed.
-->
<!--
Thank you for taking the time to create a report about a bug.
Ensure that there are no other existing reports for this bug.
Please check if the issue is resolved after a restart of the browser.
Additionally, you should check if the issue persists in a new browser profile.
Remember to fill out every section on this report and remove any that are not needed.
Finally, place a brief description in the title of this report.
-->
# Bug Report
### Bug Description
<!-- Provide a clear and concise description, which will allow us to properly troubleshoot this bug. -->
### Screenshots
<!-- If applicable, add screenshots to help explain this bug. -->
### CSS Code
<!--
If the bug is related to (user)CSS or the editor,
please post the code (with a service like pastebin) in this bug report.
-->
### System Information
<!--
Specify the browser name and version as well as the Stylus version you are using.
Please do an online search for help if you are not familiar with how to get this information.
-->
- OS: <!-- e.g. Windows, macOS, Linux -->
- Browser: <!-- e.g. Chrome 91, Firefox 90, Edge 91, Safari 14 -->
- Stylus Version: <!-- e.g. 1.5.21 -->
### Additional Context
<!-- Provide any additional information about this bug. -->

View File

@ -7,7 +7,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v1
with: with:
node-version: '14' node-version: '14'
- run: npm install - run: npm install

8
.gitignore vendored
View File

@ -1,8 +1,8 @@
*.zip
.DS_Store .DS_Store
.eslintcache pull_locales_login.rb
.transifexrc
.vscode .vscode
desktop.ini
node_modules/ node_modules/
yarn.lock yarn.lock
*.zip
.eslintcache
.transifexrc

View File

@ -21,9 +21,12 @@ Stylus is a fork of Stylish for Chrome, also compatible with Firefox as a WebExt
## Screenshots ## Screenshots
Manager | Editor | Popup search | Popup config | Manager config | Options ![Style manager](https://user-images.githubusercontent.com/1310400/34453460-214eaa5c-ed67-11e7-843b-d8960b71db6e.png)
-|-|-|-|-|- ![Style editor](https://user-images.githubusercontent.com/1310400/34459585-3932cd94-ee05-11e7-9a1b-679522dddfb3.png)
![Style manager](https://user-images.githubusercontent.com/1310400/34453460-214eaa5c-ed67-11e7-843b-d8960b71db6e.png) | ![Style editor](https://user-images.githubusercontent.com/1310400/34459585-3932cd94-ee05-11e7-9a1b-679522dddfb3.png) | ![Popup inline search](https://user-images.githubusercontent.com/1310400/34453463-21a44368-ed67-11e7-93b2-e1c8f5aac868.png) | ![Popup config for usercss](https://user-images.githubusercontent.com/1310400/34453462-218a589a-ed67-11e7-9040-7d0469eeadc3.png) | ![Style manager config for usercss](https://user-images.githubusercontent.com/1310400/34453464-21bdaf9c-ed67-11e7-8517-62d2f02e1918.png) | ![Options](https://user-images.githubusercontent.com/1310400/34453461-216aee4c-ed67-11e7-92db-ea21c1da5826.png) ![Popup config for usercss](https://user-images.githubusercontent.com/1310400/34453462-218a589a-ed67-11e7-9040-7d0469eeadc3.png)
![Popup inline search](https://user-images.githubusercontent.com/1310400/34453463-21a44368-ed67-11e7-93b2-e1c8f5aac868.png)
![Style manager config for usercss](https://user-images.githubusercontent.com/1310400/34453464-21bdaf9c-ed67-11e7-8517-62d2f02e1918.png)
![Options](https://user-images.githubusercontent.com/1310400/34453461-216aee4c-ed67-11e7-92db-ea21c1da5826.png)
## Help ## Help
@ -50,7 +53,7 @@ Copyright &copy; 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com)
Current Stylus: Current Stylus:
Copyright &copy; 2017-2022 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors) Copyright &copy; 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors)
**[GNU GPLv3](./LICENSE)** **[GNU GPLv3](./LICENSE)**

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1586
_locales/ca/messages.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1586
_locales/hr/messages.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,379 +0,0 @@
{
"InaccessibleFileHint": {
"message": "Stylus không thể truy cập một số kiểu tập tin (chẳng hạn như PDF và JSON)."
},
"addStyleLabel": {
"message": "Viết bảng định kiểu mới"
},
"addStyleTitle": {
"message": "Thêm bảng định kiểu"
},
"alphaChannel": {
"message": "Độ mờ"
},
"appliesAdd": {
"message": "Thêm"
},
"appliesDisplay": {
"message": "Áp dụng với: $applies$",
"placeholders": {
"applies": {
"content": "$1"
}
}
},
"appliesDisplayTruncatedSuffix": {
"message": "và một số khác"
},
"appliesDomainOption": {
"message": "Các địa chỉ thuộc tên miền này"
},
"appliesHelp": {
"message": "Dùng tuỳ chọn \"Áp dụng với\" để giới hạn các địa chỉ cho đoạn mã này"
},
"appliesLabel": {
"message": "Áp dụng với"
},
"appliesLineWidgetLabel": {
"message": "Hiển thị thông tin \"Áp dụng với\""
},
"appliesLineWidgetWarning": {
"message": "Không hoạt động với CSS tối giản"
},
"appliesRegexpOption": {
"message": "URL khớp với biểu thức chính quy"
},
"appliesRemove": {
"message": "Xoá"
},
"appliesRemoveError": {
"message": "Không thể xoá mục \"Áp dụng với\" cuối cùng"
},
"appliesSpecify": {
"message": "Chỉ định"
},
"appliesToEverything": {
"message": "Tất cả"
},
"appliesUrlPrefixOption": {
"message": "URL bắt đầu bằng"
},
"author": {
"message": "Tác giả"
},
"backupButtons": {
"message": "Sao lưu"
},
"backupMessage": {
"message": "Để nhập tập tin sao lưu, kéo và thả nó vào trang này hoặc nhấp vào nút Nhập.\n\nĐể xuất một bản sao lưu tương thích với Stylus trước phiên bản 1.5.18, nhấp chuột phải hoặc nhấn giữ Shift khi nhấp chuột trái vào nút Xuất."
},
"bckpInstStyles": {
"message": "Xuất bảng định kiểu"
},
"checkForUpdate": {
"message": "Kiểm tra bản cập nhật mới"
},
"checkingForUpdate": {
"message": "Đang kiểm tra..."
},
"clickToUninstall": {
"message": "Nhấp để huỷ kích hoạt"
},
"cm_autoCloseBrackets": {
"message": "Tự động đóng ngoặc và nháy"
},
"cm_autoCloseBracketsTooltip": {
"message": "Tự động thêm dấu đóng tương ứng khi nhập một trong các dấu (, [, {, ' và \"."
},
"cm_autocompleteOnTyping": {
"message": "Tự động hoàn thành"
},
"cm_colorpicker": {
"message": "Bộ chọn màu cho màu CSS"
},
"cm_indentWithTabs": {
"message": "Lùi đầu dòng thông minh bằng tab"
},
"cm_lineWrapping": {
"message": "Gập dòng dài"
},
"cm_linter": {
"message": "Trình phân tích cú pháp"
},
"cm_matchHighlight": {
"message": "Làm nổi"
},
"cm_matchHighlightSelection": {
"message": "Chỉ vùng được chọn"
},
"cm_matchHighlightToken": {
"message": "Token nằm dưới con trỏ văn bản"
},
"cm_selectByTokens": {
"message": "Nhấp đúp để chọn token"
},
"cm_selectByTokensTooltip": {
"message": "Ví dụ về token: .foo-bar-2 #aabbcc 0.32 !important\nKhi tắt: Chọn từ (phân tách bằng dấu câu)."
},
"cm_smartIndent": {
"message": "Lùi đầu dòng thông minh"
},
"cm_tabSize": {
"message": "Chiều rộng tab"
},
"cm_theme": {
"message": "Chủ đề"
},
"colorpickerSwitchFormatTooltip": {
"message": "Đổi định dạng: HEX → RGB → HSL.\nNhấn giữ phím Shift khi nhấp để đảo thứ tự.\nPhím tắt: PgUp và PgDn."
},
"colorpickerTooltip": {
"message": "Mở bộ chọn màu"
},
"configOnChange": {
"message": "khi thay đổi"
},
"configOnChangeTooltip": {
"message": "Tự động lưu và áp dụng"
},
"configureStyle": {
"message": "Thiết lập"
},
"configureStyleOnHomepage": {
"message": "Thiết lập trên trang chủ"
},
"confirmCancel": {
"message": "Huỷ"
},
"confirmClose": {
"message": "Đóng"
},
"confirmDefault": {
"message": "Dùng mặc định"
},
"confirmDelete": {
"message": "Xoá"
},
"confirmDiscardChanges": {
"message": "Huỷ thay đổi?"
},
"confirmNo": {
"message": "Không"
},
"confirmSave": {
"message": "Lưu"
},
"confirmStop": {
"message": "Dừng"
},
"confirmYes": {
"message": "Có"
},
"connectingDropbox": {
"message": "Đang kết nối với Dropbox..."
},
"connectingDropboxNotAllowed": {
"message": "Tính năng kết nối với Dropbox chỉ khả dụng khi cài ứng dụng trực tiếp từ cửa hàng web"
},
"copied": {
"message": "Đã chép vào khay nhớ tạm"
},
"copy": {
"message": "Chép vào khay nhớ tạm"
},
"dateAbbrDay": {
"message": "$value$ ngày",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateAbbrHour": {
"message": "$value$ giờ",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateAbbrMonth": {
"message": "$value$ tháng",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateAbbrYear": {
"message": "$value$ năm",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateInstalled": {
"message": "Ngày cài đặt"
},
"dateUpdated": {
"message": "Ngày cập nhật"
},
"defaultTheme": {
"message": "mặc định"
},
"deleteStyleConfirm": {
"message": "Bạn có chắc chắn muốn xoá bảng định kiểu này không?"
},
"deleteStyleLabel": {
"message": "Xoá"
},
"disableAllStyles": {
"message": "Tắt tất cả bảng định kiểu"
},
"disableAllStylesOff": {
"message": "Bật tất cả bảng định kiểu"
},
"disableStyleLabel": {
"message": "Vô hiệu hoá"
},
"draftAction": {
"message": "Chọn \"Có\" để tải bản nháp hoặc \"Không\" để xoá nó đi."
},
"editDeleteText": {
"message": "Xoá"
},
"editGotoLine": {
"message": "Đi đến dòng (hoặc dòng:cột)"
},
"editStyleHeading": {
"message": "Sửa bảng định kiểu"
},
"editStyleLabel": {
"message": "Sửa"
},
"editStyleTitle": {
"message": "Sửa bảng định kiểu $stylename$",
"placeholders": {
"stylename": {
"content": "$1"
}
}
},
"editorSettings": {
"message": "Cài đặt trình soạn thảo"
},
"enableStyleLabel": {
"message": "Kích hoạt"
},
"exportCompatible": {
"message": "Xuất (chế độ tương thích)"
},
"exportLabel": {
"message": "Xuất"
},
"exportSavedSuccess": {
"message": "Lưu thành công"
},
"externalFeedback": {
"message": "Phản hồi"
},
"externalHomepage": {
"message": "Trang chủ"
},
"externalLink": {
"message": "Liên kết ngoài"
},
"externalSupport": {
"message": "Ủng hộ"
},
"genericAdd": {
"message": "Thêm"
},
"genericDescription": {
"message": "Mô tả"
},
"genericDisabledLabel": {
"message": "Vô hiệu hoá"
},
"genericEnabledLabel": {
"message": "Kích hoạt"
},
"genericError": {
"message": "Lỗi"
},
"genericHistoryLabel": {
"message": "Lịch sử"
},
"genericNext": {
"message": "Sau"
},
"genericPrevious": {
"message": "Trước"
},
"genericResetLabel": {
"message": "Đặt lại"
},
"genericSavedMessage": {
"message": "Đã lưu"
},
"genericTest": {
"message": "Thứ"
},
"genericTitle": {
"message": "Tiêu đề"
},
"genericUnknown": {
"message": "Không xác định"
},
"helpAlt": {
"message": "Trợ giúp"
},
"importLabel": {
"message": "Nhập"
},
"importReportLegendAdded": {
"message": "đã thêm"
},
"importReportLegendIdentical": {
"message": "Đã bỏ qua các tệp trùng lặp"
},
"importReportLegendInvalid": {
"message": "Đã bỏ qua các tệp không hợp lệ"
},
"importReportLegendUpdatedBoth": {
"message": "đã cập nhật siêu thông tin và mã"
},
"importReportLegendUpdatedCode": {
"message": "đã cập nhật mã"
},
"importReportLegendUpdatedMeta": {
"message": "đã cập nhật siêu thông tin"
},
"importReportTitle": {
"message": "Đã nhập xong"
},
"installUpdateFromLabel": {
"message": "Kiểm tra bản cập nhật mới"
},
"license": {
"message": "Giấy phép"
},
"linterCSSLintIncompatible": {
"message": "CSSLint không hỗ trợ bộ tiền xử lý $preprocessorname$",
"placeholders": {
"preprocessorname": {
"content": "$1"
}
}
},
"linterJSONError": {
"message": "Định dạng JSON không hợp lệ"
},
"styleEnabledLabel": {
"message": "Kích hoạt"
},
"styleSaveLabel": {
"message": "Lưu"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,41 +6,35 @@
/* global syncMan */ /* global syncMan */
/* global updateMan */ /* global updateMan */
/* global usercssMan */ /* global usercssMan */
/* global usoApi */ /* global
/* global uswApi */ FIREFOX
/* global FIREFOX UA activateTab openURL */ // toolbox.js URLS
/* global colorScheme */ // color-scheme.js activateTab
download
findExistingTab
getActiveTab
isTabReplaceable
openURL
*/ // toolbox.js
'use strict'; 'use strict';
//#region API //#region API
addAPI(/** @namespace API */ { addAPI(/** @namespace API */ {
/** Temporary storage for data needed elsewhere e.g. in a content script */
data: ((data = {}) => ({
del: key => delete data[key],
get: key => data[key],
has: key => key in data,
pop: key => {
const val = data[key];
delete data[key];
return val;
},
set: (key, val) => {
data[key] = val;
},
}))(),
styles: styleMan, styles: styleMan,
sync: syncMan, sync: syncMan,
updater: updateMan, updater: updateMan,
usercss: usercssMan, usercss: usercssMan,
uso: usoApi,
usw: uswApi,
colorScheme,
/** @type {BackgroundWorker} */ /** @type {BackgroundWorker} */
worker: createWorker({url: '/background/background-worker'}), worker: createWorker({url: '/background/background-worker'}),
download(url, opts) {
return typeof url === 'string' && url.startsWith(URLS.uso) &&
this.sender.url.startsWith(URLS.uso) &&
download(url, opts || {});
},
/** @returns {string} */ /** @returns {string} */
getTabUrlPrefix() { getTabUrlPrefix() {
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1]; return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];
@ -58,24 +52,10 @@ addAPI(/** @namespace API */ {
async openEditor(params) { async openEditor(params) {
const u = new URL(chrome.runtime.getURL('edit.html')); const u = new URL(chrome.runtime.getURL('edit.html'));
u.search = new URLSearchParams(params); u.search = new URLSearchParams(params);
const wnd = chrome.windows && prefs.get('openEditInWindow'); const wnd = prefs.get('openEditInWindow');
const wndPos = wnd && prefs.get('windowPosition'); const wndPos = wnd && prefs.get('windowPosition');
const wndBase = wnd && prefs.get('openEditInWindow.popup') ? {type: 'popup'} : {}; const wndBase = wnd && prefs.get('openEditInWindow.popup') ? {type: 'popup'} : {};
const ffBug = wnd && FIREFOX; // https://bugzil.la/1271047 const ffBug = wnd && FIREFOX; // https://bugzil.la/1271047
if (wndPos) {
const {left, top, width, height} = wndPos;
const r = left + width;
const b = top + height;
const peek = 32;
if (isNaN(r) || r < peek || left > screen.availWidth - peek || width < 100) {
delete wndPos.left;
delete wndPos.width;
}
if (isNaN(b) || b < peek || top > screen.availHeight - peek || height < 100) {
delete wndPos.top;
delete wndPos.height;
}
}
const tab = await openURL({ const tab = await openURL({
url: `${u}`, url: `${u}`,
currentWindow: null, currentWindow: null,
@ -87,25 +67,30 @@ addAPI(/** @namespace API */ {
/** @returns {Promise<chrome.tabs.Tab>} */ /** @returns {Promise<chrome.tabs.Tab>} */
async openManage({options = false, search, searchMode} = {}) { async openManage({options = false, search, searchMode} = {}) {
const setUrlParams = url => { let url = chrome.runtime.getURL('manage.html');
const u = new URL(url); if (search) {
if (search) u.searchParams.set('search', search); url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`;
if (searchMode) u.searchParams.set('searchMode', searchMode);
if (options) u.hash = '#stylus-options';
return u.href;
};
const base = chrome.runtime.getURL('manage.html');
const url = setUrlParams(base);
const tabs = await browser.tabs.query({url: base + '*'});
const same = tabs.find(t => t.url === url);
let tab = same || tabs[0];
if (!tab) {
API.prefsDb.get('badFavs'); // prime the cache to avoid flicker/delay when opening the page
tab = await openURL({url, newTab: true});
} else if (!same) {
msg.sendTab(tab.id, {method: 'pushState', url: setUrlParams(tab.url)});
} }
return activateTab(tab); // activateTab unminimizes the window if (options) {
url += '#stylus-options';
}
let tab = await findExistingTab({
url,
currentWindow: null,
ignoreHash: true,
ignoreSearch: true,
});
if (tab) {
await activateTab(tab);
if (url !== (tab.pendingUrl || tab.url)) {
await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error);
}
return tab;
}
tab = await getActiveTab();
return isTabReplaceable(tab, url)
? activateTab(tab, {url})
: browser.tabs.create({url}).then(activateTab); // activateTab unminimizes the window
}, },
/** /**
@ -158,25 +143,10 @@ if (chrome.commands) {
} }
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => { chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason === 'install') { if (reason === 'update') {
if (UA.mobile) prefs.set('manage.newUI', false); const [a, b, c] = (previousVersion || '').split('.');
if (UA.windows) prefs.set('editor.keyMap', 'sublime'); if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
} require(['/background/remove-unused-storage']);
// TODO: remove this before 1.5.23 as it's only for a few users who installed git 26b75e77
if (reason === 'update' && previousVersion === '1.5.22') {
for (const dbName of ['drafts', prefs.STORAGE_KEY]) {
try {
indexedDB.open(dbName).onsuccess = async e => {
const idb = /** @type IDBDatabase */ e.target.result;
const ta = idb.objectStoreNames[0] === 'data' && idb.transaction(['data']);
if (ta && ta.objectStore('data').autoIncrement) {
ta.abort();
idb.close();
await new Promise(setTimeout);
indexedDB.deleteDatabase(dbName);
}
};
} catch (e) {}
} }
} }
}); });
@ -193,8 +163,6 @@ msg.on((msg, sender) => {
//#endregion //#endregion
Promise.all([ Promise.all([
browser.extension.isAllowedFileSchemeAccess()
.then(res => API.data.set('hasFileAccess', res)),
bgReady.styles, bgReady.styles,
/* These are loaded conditionally. /* These are loaded conditionally.
Each item uses `require` individually so IDE can jump to the source and track usage. */ Each item uses `require` individually so IDE can jump to the source and track usage. */
@ -208,6 +176,6 @@ Promise.all([
require(['/background/context-menus']), require(['/background/context-menus']),
]).then(() => { ]).then(() => {
bgReady._resolveAll(); bgReady._resolveAll();
msg.ready = true; msg.isBgReady = true;
msg.broadcast({method: 'backgroundReady'}); msg.broadcast({method: 'backgroundReady'});
}); });

View File

@ -1,91 +0,0 @@
/* global prefs */
/* exported colorScheme */
'use strict';
const colorScheme = (() => {
const changeListeners = new Set();
const kSTATE = 'schemeSwitcher.enabled';
const kSTART = 'schemeSwitcher.nightStart';
const kEND = 'schemeSwitcher.nightEnd';
const SCHEMES = ['dark', 'light'];
const isDark = {
never: null,
dark: true,
light: false,
system: false,
time: false,
};
let isDarkNow = false;
prefs.subscribe(kSTATE, () => update());
prefs.subscribe([kSTART, kEND], (key, value) => {
updateTimePreferDark();
createAlarm(key, value);
}, {runNow: true});
chrome.alarms.onAlarm.addListener(({name}) => {
if (name === kSTART || name === kEND) {
updateTimePreferDark();
}
});
return {
SCHEMES,
onChange(listener) {
changeListeners.add(listener);
},
isDark: () => isDarkNow,
/** @param {StyleObj} _ */
shouldIncludeStyle({preferScheme: ps}) {
return prefs.get(kSTATE) === 'never' ||
!SCHEMES.includes(ps) ||
isDarkNow === (ps === 'dark');
},
updateSystemPreferDark(val) {
update('system', val);
return true;
},
};
function calcTime(key) {
const [h, m] = prefs.get(key).split(':');
return (h * 3600 + m * 60) * 1000;
}
function createAlarm(key, value) {
const date = new Date();
const [h, m] = value.split(':');
date.setHours(h, m, 0, 0);
if (date.getTime() < Date.now()) {
date.setDate(date.getDate() + 1);
}
chrome.alarms.create(key, {
when: date.getTime(),
periodInMinutes: 24 * 60,
});
}
function updateTimePreferDark() {
const now = Date.now() - new Date().setHours(0, 0, 0, 0);
const start = calcTime(kSTART);
const end = calcTime(kEND);
const val = start > end ?
now >= start || now < end :
now >= start && now < end;
update('time', val);
}
function update(type, val) {
if (type) {
if (isDark[type] === val) return;
isDark[type] = val;
}
val = isDark[prefs.get(kSTATE)];
if (isDarkNow !== val) {
isDarkNow = val;
for (const listener of changeListeners) {
listener(isDarkNow);
}
}
}
})();

View File

@ -5,19 +5,16 @@
* Common stuff that's loaded first so it's immediately available to all background scripts * Common stuff that's loaded first so it's immediately available to all background scripts
*/ */
window.bgReady = {}; /* global bgReady */ /* exported
addAPI
bgReady
compareRevision
*/
const bgReady = {};
bgReady.styles = new Promise(r => (bgReady._resolveStyles = r)); bgReady.styles = new Promise(r => (bgReady._resolveStyles = r));
bgReady.all = new Promise(r => (bgReady._resolveAll = r)); bgReady.all = new Promise(r => (bgReady._resolveAll = r));
const uuidIndex = Object.assign(new Map(), {
custom: {},
/** `obj` must have a unique `id`, a UUIDv4 `_id`, and Date.now() for `_rev`. */
addCustomId(obj, {get = () => obj, set}) {
Object.defineProperty(uuidIndex.custom, obj.id, {get, set});
},
});
/* exported addAPI */
function addAPI(methods) { function addAPI(methods) {
for (const [key, val] of Object.entries(methods)) { for (const [key, val] of Object.entries(methods)) {
const old = API[key]; const old = API[key];
@ -29,64 +26,6 @@ function addAPI(methods) {
} }
} }
/* exported createCache */ function compareRevision(rev1, rev2) {
/** Creates a FIFO limit-size map. */ return rev1 - rev2;
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get(id) {
const item = map.get(id);
return item && item.data;
},
set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
},
delete(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
},
clear() {
map.clear();
index = lastIndex = 0;
},
has: id => map.has(id),
*entries() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
*values() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
} }

View File

@ -1,61 +1,80 @@
/* global browserCommands */// background.js /* global browserCommands */// background.js
/* global msg */ /* global msg */
/* global prefs */ /* global prefs */
/* global CHROME URLS ignoreChromeError */// toolbox.js /* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
'use strict'; 'use strict';
chrome.management.getSelf(ext => { (() => {
const contextMenus = Object.assign({ const contextMenus = {
'show-badge': { 'show-badge': {
title: 'menuShowBadge', title: 'menuShowBadge',
click: togglePref, click: info => prefs.set(info.menuItemId, info.checked),
}, },
'disableAll': { 'disableAll': {
title: 'disableAllStyles', title: 'disableAllStyles',
click: browserCommands.styleDisableAll, click: browserCommands.styleDisableAll,
}, },
'open-manager': { 'open-manager': {
title: 'optionsOpenManager', title: 'openStylesManager',
click: browserCommands.openManage, click: browserCommands.openManage,
}, },
'open-options': { 'open-options': {
title: 'openOptions', title: 'openOptions',
click: browserCommands.openOptions, click: browserCommands.openOptions,
}, },
}, ext.installType === 'development' && {
'reload': { 'reload': {
presentIf: async () => (await browser.management.getSelf()).installType === 'development',
title: 'reload', title: 'reload',
click: browserCommands.reload, click: browserCommands.reload,
}, },
}, CHROME && {
'editor.contextDelete': { 'editor.contextDelete': {
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
title: 'editDeleteText', title: 'editDeleteText',
type: 'normal', type: 'normal',
contexts: ['editable'], contexts: ['editable'],
documentUrlPatterns: [URLS.ownOrigin + '*'], documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
click: (info, tab) => { click: (info, tab) => {
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension') msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension')
.catch(msg.ignoreError); .catch(msg.ignoreError);
}, },
}, },
}); };
// "Delete" item in context menu for browsers that don't have it
if (CHROME &&
// looking at the end of UA string
/(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) &&
// skip forks with Flash as those are likely to have the menu e.g. CentBrowser
!Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) {
prefs.__defaults['editor.contextDelete'] = true;
}
const keys = Object.keys(contextMenus);
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'),
CHROME >= 62 && CHROME <= 64 ? toggleCheckmarkBugged : toggleCheckmark);
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && prefs.knownKeys.includes(id)),
togglePresence);
createContextMenus(keys);
createContextMenus(Object.keys(contextMenus));
chrome.contextMenus.onClicked.addListener((info, tab) => chrome.contextMenus.onClicked.addListener((info, tab) =>
contextMenus[info.menuItemId].click(info, tab)); contextMenus[info.menuItemId].click(info, tab));
function createContextMenus(ids) { async function createContextMenus(ids) {
for (const id of ids) { for (const id of ids) {
const item = Object.assign({id, contexts: ['browser_action']}, contextMenus[id]); let item = contextMenus[id];
if (item.presentIf && !await item.presentIf()) {
continue;
}
item = Object.assign({id}, item);
delete item.presentIf;
item.title = chrome.i18n.getMessage(item.title); item.title = chrome.i18n.getMessage(item.title);
if (typeof prefs.defaults[id] === 'boolean') { if (!item.type && typeof prefs.defaults[id] === 'boolean') {
if (item.type) { item.type = 'checkbox';
prefs.subscribe(id, togglePresence); item.checked = prefs.get(id);
} else { }
item.type = 'checkbox'; if (!item.contexts) {
item.checked = prefs.get(id); item.contexts = ['browser_action'];
prefs.subscribe(id, CHROME >= 62 && CHROME <= 64 ? toggleCheckmarkBugged : toggleCheckmark);
}
} }
delete item.click; delete item.click;
chrome.contextMenus.create(item, ignoreChromeError); chrome.contextMenus.create(item, ignoreChromeError);
@ -72,11 +91,6 @@ chrome.management.getSelf(ext => {
createContextMenus([id]); createContextMenus([id]);
} }
/** @param {chrome.contextMenus.OnClickData} info */
function togglePref(info) {
prefs.set(info.menuItemId, info.checked);
}
function togglePresence(id, checked) { function togglePresence(id, checked) {
if (checked) { if (checked) {
createContextMenus([id]); createContextMenus([id]);
@ -84,4 +98,4 @@ chrome.management.getSelf(ext => {
chrome.contextMenus.remove(id, ignoreChromeError); chrome.contextMenus.remove(id, ignoreChromeError);
} }
} }
}); })();

View File

@ -2,17 +2,17 @@
'use strict'; 'use strict';
/* exported createChromeStorageDB */ /* exported createChromeStorageDB */
function createChromeStorageDB(PREFIX) { function createChromeStorageDB() {
let INC; let INC;
const isMain = !PREFIX;
if (!PREFIX) PREFIX = 'style-';
return { const PREFIX = 'style-';
const METHODS = {
delete(id) { delete(id) {
return chromeLocal.remove(PREFIX + id); return chromeLocal.remove(PREFIX + id);
}, },
// FIXME: we don't use this method at all. Should we remove this?
get(id) { get(id) {
return chromeLocal.getValue(PREFIX + id); return chromeLocal.getValue(PREFIX + id);
}, },
@ -21,9 +21,7 @@ function createChromeStorageDB(PREFIX) {
const all = await chromeLocal.get(); const all = await chromeLocal.get();
if (!INC) prepareInc(all); if (!INC) prepareInc(all);
return Object.entries(all) return Object.entries(all)
.map(([key, val]) => key.startsWith(PREFIX) && .map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val)
(!isMain || Number(key.slice(PREFIX.length))) &&
val)
.filter(Boolean); .filter(Boolean);
}, },
@ -61,4 +59,8 @@ function createChromeStorageDB(PREFIX) {
} }
} }
} }
return function dbExecChromeStorage(method, ...args) {
return METHODS[method](...args);
};
} }

View File

@ -1,8 +1,5 @@
/* global addAPI */// common.js
/* global chromeLocal */// storage-util.js /* global chromeLocal */// storage-util.js
/* global cloneError */// worker-util.js /* global cloneError */// worker-util.js
/* global deepCopy */// toolbox.js
/* global prefs */
'use strict'; 'use strict';
/* /*
@ -14,49 +11,16 @@
/* exported db */ /* exported db */
const db = (() => { const db = (() => {
let exec = async (...args) => ( const DATABASE = 'stylish';
exec = await tryUsingIndexedDB().catch(useChromeStorage) const STORE = 'styles';
)(...args);
const DB = 'stylish';
const FALLBACK = 'dbInChromeStorage'; const FALLBACK = 'dbInChromeStorage';
const ID_AS_KEY = {[DB]: true}; const dbApi = {
const getStoreName = dbName => dbName === DB ? 'styles' : 'data'; async exec(...args) {
const cache = {}; dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage);
const proxies = {}; return dbApi.exec(...args);
const proxyHandler = { },
get: ({dbName}, cmd) =>
(...args) =>
(dbName === DB ? exec : cachedExec)(dbName, cmd, ...args),
}; };
/** return dbApi;
* @param {string} dbName
* @return {IDBObjectStore | {putMany: function(items:?[]):Promise<?[]>}}
*/
const getProxy = dbName => proxies[dbName] || (
(proxies[dbName] = new Proxy({dbName}, proxyHandler))
);
addAPI(/** @namespace API */ {
drafts: getProxy('drafts'),
/** Storage for big items that may exceed 8kB limit of chrome.storage.sync.
* To make an item syncable register it with uuidIndex.addCustomId. */
prefsDb: getProxy(prefs.STORAGE_KEY),
});
return {
styles: getProxy(DB),
};
async function cachedExec(dbName, cmd, a, b) {
const hub = cache[dbName] || (cache[dbName] = {});
const res = cmd === 'get' && a in hub ? hub[a] : await exec(...arguments);
if (cmd === 'get') {
hub[a] = deepCopy(res);
} else if (cmd === 'put') {
hub[ID_AS_KEY[dbName] ? a.id : b] = deepCopy(a);
} else if (cmd === 'delete') {
delete hub[a];
}
return res;
}
async function tryUsingIndexedDB() { async function tryUsingIndexedDB() {
// we use chrome.storage.local fallback if IndexedDB doesn't save data, // we use chrome.storage.local fallback if IndexedDB doesn't save data,
@ -76,9 +40,9 @@ const db = (() => {
async function testDB() { async function testDB() {
const id = `${performance.now()}.${Math.random()}.${Date.now()}`; const id = `${performance.now()}.${Math.random()}.${Date.now()}`;
await dbExecIndexedDB(DB, 'put', {id}); await dbExecIndexedDB('put', {id});
const e = await dbExecIndexedDB(DB, 'get', id); const e = await dbExecIndexedDB('get', id);
await dbExecIndexedDB(DB, 'delete', e.id); // throws if `e` or id is null await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null
} }
async function useChromeStorage(err) { async function useChromeStorage(err) {
@ -88,18 +52,12 @@ const db = (() => {
console.warn('Failed to access indexedDB. Switched to storage API.', err); console.warn('Failed to access indexedDB. Switched to storage API.', err);
} }
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */ await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */
const BASES = {}; return createChromeStorageDB();
return (dbName, method, ...args) => (
BASES[dbName] || (
BASES[dbName] = createChromeStorageDB(dbName !== DB && `${dbName}-`)
)
)[method](...args);
} }
async function dbExecIndexedDB(dbName, method, ...args) { async function dbExecIndexedDB(method, ...args) {
const mode = method.startsWith('get') ? 'readonly' : 'readwrite'; const mode = method.startsWith('get') ? 'readonly' : 'readwrite';
const storeName = getStoreName(dbName); const store = (await open()).transaction([STORE], mode).objectStore(STORE);
const store = (await open(dbName)).transaction([storeName], mode).objectStore(storeName);
const fn = method === 'putMany' ? putMany : storeRequest; const fn = method === 'putMany' ? putMany : storeRequest;
return fn(store, method, ...args); return fn(store, method, ...args);
} }
@ -117,34 +75,21 @@ const db = (() => {
return Promise.all(items.map(item => storeRequest(store, 'put', item))); return Promise.all(items.map(item => storeRequest(store, 'put', item)));
} }
function open(name) { function open() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(name, 2); const request = indexedDB.open(DATABASE, 2);
request.onsuccess = e => resolve(create(e)); request.onsuccess = () => resolve(request.result);
request.onerror = reject; request.onerror = reject;
request.onupgradeneeded = create; request.onupgradeneeded = create;
}); });
} }
function create(event) { function create(event) {
/** @type IDBDatabase */ if (event.oldVersion === 0) {
const idb = event.target.result; event.target.result.createObjectStore(STORE, {
const dbName = idb.name;
const sn = getStoreName(dbName);
if (!idb.objectStoreNames.contains(sn)) {
if (event.type === 'success') {
idb.close();
return new Promise(resolve => {
indexedDB.deleteDatabase(dbName).onsuccess = () => {
resolve(open(dbName));
};
});
}
idb.createObjectStore(sn, ID_AS_KEY[dbName] ? {
keyPath: 'id', keyPath: 'id',
autoIncrement: true, autoIncrement: true,
} : undefined); });
} }
return idb;
} }
})(); })();

View File

@ -1,22 +1,18 @@
/* global API */// msg.js /* global API */// msg.js
/* global addAPI bgReady */// common.js /* global addAPI bgReady */// common.js
/* global colorScheme */
/* global prefs */ /* global prefs */
/* global tabMan */ /* global tabMan */
/* global CHROME FIREFOX UA debounce ignoreChromeError */// toolbox.js /* global CHROME FIREFOX VIVALDI debounce ignoreChromeError */// toolbox.js
'use strict'; 'use strict';
/* exported iconMan */ /* exported iconMan */
const iconMan = (() => { const iconMan = (() => {
const ICON_SIZES = FIREFOX || CHROME && !UA.vivaldi ? [16, 32] : [19, 38]; const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
const staleBadges = new Set(); const staleBadges = new Set();
const imageDataCache = new Map(); const imageDataCache = new Map();
const badgeOvr = {color: '', text: ''}; const badgeOvr = {color: '', text: ''};
// https://github.com/openstyles/stylus/issues/1287 Fenix can't use custom ImageData
const FIREFOX_ANDROID = FIREFOX && UA.mobile;
let isDark;
// https://github.com/openstyles/stylus/issues/335 // https://github.com/openstyles/stylus/issues/335
let hasCanvas = FIREFOX_ANDROID ? false : loadImage(`/images/icon/${ICON_SIZES[0]}.png`) let hasCanvas = loadImage(`/images/icon/${ICON_SIZES[0]}.png`)
.then(({data}) => (hasCanvas = data.some(b => b !== 255))); .then(({data}) => (hasCanvas = data.some(b => b !== 255)));
addAPI(/** @namespace API */ { addAPI(/** @namespace API */ {
@ -38,17 +34,13 @@ const iconMan = (() => {
chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => { chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
if (!frameId) tabMan.set(tabId, 'styleIds', undefined); if (!frameId) tabMan.set(tabId, 'styleIds', undefined);
}); });
chrome.runtime.onConnect.addListener(port => { chrome.runtime.onConnect.addListener(port => {
if (port.name === 'iframe') { if (port.name === 'iframe') {
port.onDisconnect.addListener(onPortDisconnected); port.onDisconnect.addListener(onPortDisconnected);
} }
}); });
colorScheme.onChange(val => {
isDark = val;
if (prefs.get('iconset') === -1) {
debounce(refreshAllIcons);
}
});
bgReady.all.then(() => { bgReady.all.then(() => {
prefs.subscribe([ prefs.subscribe([
'disableAll', 'disableAll',
@ -100,10 +92,9 @@ const iconMan = (() => {
} }
function getIconName(hasStyles = false) { function getIconName(hasStyles = false) {
const i = prefs.get('iconset'); const iconset = prefs.get('iconset') === 1 ? 'light/' : '';
const prefix = i === 0 || i === -1 && isDark ? '' : 'light/';
const postfix = prefs.get('disableAll') ? 'x' : !hasStyles ? 'w' : ''; const postfix = prefs.get('disableAll') ? 'x' : !hasStyles ? 'w' : '';
return `${prefix}$SIZE$${postfix}`; return `${iconset}$SIZE$${postfix}`;
} }
function refreshIcon(tabId, force = false) { function refreshIcon(tabId, force = false) {
@ -141,7 +132,7 @@ const iconMan = (() => {
// Caches imageData for icon paths // Caches imageData for icon paths
async function loadImage(url) { async function loadImage(url) {
const {OffscreenCanvas} = !FIREFOX && self.createImageBitmap && self || {}; const {OffscreenCanvas} = self.createImageBitmap && self || {};
const img = OffscreenCanvas const img = OffscreenCanvas
? await createImageBitmap(await (await fetch(url)).blob()) ? await createImageBitmap(await (await fetch(url)).blob())
: await new Promise((resolve, reject) => : await new Promise((resolve, reject) =>

View File

@ -42,9 +42,8 @@ const navMan = (() => {
/** @this {string} type */ /** @this {string} type */
function onFakeNavigation(data) { function onFakeNavigation(data) {
const {url, frameId} = data;
onNavigation.call(this, data); onNavigation.call(this, data);
msg.sendTab(data.tabId, {method: 'urlChanged', url}, {frameId}) msg.sendTab(data.tabId, {method: 'urlChanged'}, {frameId: data.frameId})
.catch(msg.ignoreError); .catch(msg.ignoreError);
} }
})(); })();
@ -66,21 +65,6 @@ bgReady.all.then(() => {
{hostEquals: 'sleazyfork.org', urlMatches}, {hostEquals: 'sleazyfork.org', urlMatches},
], ],
}); });
/*
* Removes the Get Stylus button on style pages.
* Not using manifest.json as adding a content script disables the extension on update.
*/
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
chrome.tabs.executeScript(tabId, {
file: '/content/install-hook-userstylesworld.js',
runAt: 'document_start',
});
}, {
url: [
{hostEquals: 'userstyles.world'},
],
});
/* /*
* FF misses some about:blank iframes so we inject our content script explicitly * FF misses some about:blank iframes so we inject our content script explicitly
*/ */

View File

@ -0,0 +1,103 @@
/* global addAPI */// common.js
'use strict';
/* CURRENTLY UNUSED */
(() => {
// begin:nanographql - Tiny graphQL client library
// Author: yoshuawuyts (https://github.com/yoshuawuyts)
// License: MIT
// Modified by DecentM to fit project standards
const getOpname = /(query|mutation) ?([\w\d-_]+)? ?\(.*?\)? \{/;
const gql = str => {
str = Array.isArray(str) ? str.join('') : str;
const name = getOpname.exec(str);
return variables => {
const data = {query: str};
if (variables) data.variables = JSON.stringify(variables);
if (name && name.length) {
const operationName = name[2];
if (operationName) data.operationName = name[2];
}
return JSON.stringify(data);
};
};
// end:nanographql
const api = 'https://api.openusercss.org';
const doQuery = async ({id}, queryString) => {
const query = gql(queryString);
return (await fetch(api, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: query({
id,
}),
})).json();
};
addAPI(/** @namespace- API */ { // TODO: remove "-" when this is implemented
/**
* This function can be used to retrieve a theme object from the
* GraphQL API, set above
*
* Example:
* chrome.runtime.sendMessage({
* 'method': 'oucThemeById',
* 'id': '5a2f819f7c57c751001b49df'
* }, console.log);
*
* @param {ID} $0.id MongoDB style ID
* @returns {Promise.<{data: object}>} The GraphQL result with the `theme` object
*/
oucThemeById: params => doQuery(params, `
query($id: ID!) {
theme(id: $id) {
_id
title
description
createdAt
lastUpdate
version
screenshots
user {
_id
displayname
}
}
}
`),
/**
* This function can be used to retrieve a user object from the
* GraphQL API, set above
*
* Example:
* chrome.runtime.sendMessage({
* 'method': 'oucUserById',
* 'id': '5a2f0361ba666f0b00b9c827'
* }, console.log);
*
* @param {ID} $0.id MongoDB style ID
* @returns {Promise.<{data: object}>} The GraphQL result with the `user` object
*/
oucUserById: params => doQuery(params, `
query($id: ID!) {
user(id: $id) {
_id
displayname
avatarUrl
smallAvatarUrl
bio
}
}
`),
});
})();

View File

@ -0,0 +1,15 @@
/* global chromeLocal */// storage-util.js
'use strict';
// Removing unused stuff from storage on extension update
// TODO: delete this by the middle of 2021
try {
localStorage.clear();
} catch (e) {}
setTimeout(async () => {
const del = Object.keys(await chromeLocal.get())
.filter(key => key.startsWith('usoSearchCache'));
if (del.length) chromeLocal.remove(del);
}, 15e3);

View File

@ -1,12 +1,10 @@
/* global API msg */// msg.js /* global API msg */// msg.js
/* global CHROME URLS deepEqual isEmptyObj mapObj stringAsRegExp tryRegExp tryURL */// toolbox.js /* global URLS stringAsRegExp tryRegExp */// toolbox.js
/* global bgReady createCache uuidIndex */// common.js /* global bgReady compareRevision */// common.js
/* global calcStyleDigest styleCodeEmpty */// sections-util.js /* global calcStyleDigest styleCodeEmpty styleSectionGlobal */// sections-util.js
/* global db */ /* global db */
/* global prefs */ /* global prefs */
/* global tabMan */ /* global tabMan */
/* global usercssMan */
/* global colorScheme */
'use strict'; 'use strict';
/* /*
@ -18,26 +16,18 @@ The live preview feature relies on `runtime.connect` and `port.onDisconnect`
to cleanup the temporary code. See livePreview in /edit. to cleanup the temporary code. See livePreview in /edit.
*/ */
const styleUtil = {};
/* exported styleMan */
const styleMan = (() => { const styleMan = (() => {
Object.assign(styleUtil, {
id2style,
handleSave,
uuid2style,
});
//#region Declarations //#region Declarations
/** @typedef {{ /** @typedef {{
style: StyleObj, style: StyleObj
preview?: StyleObj, preview?: StyleObj
appliesTo: Set<string>, appliesTo: Set<string>
}} StyleMapData */ }} StyleMapData */
/** @type {Map<number,StyleMapData>} */ /** @type {Map<number,StyleMapData>} */
const dataMap = new Map(); const dataMap = new Map();
const uuidIndex = new Map();
/** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */ /** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */
/** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */ /** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */
const cachedStyleForUrl = createCache({ const cachedStyleForUrl = createCache({
@ -52,69 +42,16 @@ const styleMan = (() => {
const compileRe = createCompiler(text => `^(${text})$`); const compileRe = createCompiler(text => `^(${text})$`);
const compileSloppyRe = createCompiler(text => `^${text}$`); const compileSloppyRe = createCompiler(text => `^${text}$`);
const compileExclusion = createCompiler(buildExclusion); const compileExclusion = createCompiler(buildExclusion);
const uuidv4 = crypto.randomUUID ? crypto.randomUUID.bind(crypto) : (() => {
const seeds = crypto.getRandomValues(new Uint16Array(8));
// 00001111-2222-M333-N444-555566667777
seeds[3] = seeds[3] & 0x0FFF | 0x4000; // UUID version 4, M = 4
seeds[4] = seeds[4] & 0x3FFF | 0x8000; // UUID variant 1, N = 8..0xB
return Array.from(seeds, hex4dashed).join('');
});
const MISSING_PROPS = { const MISSING_PROPS = {
name: style => `ID: ${style.id}`, name: style => `ID: ${style.id}`,
_id: () => uuidv4(), _id: () => uuidv4(),
_rev: () => Date.now(), _rev: () => Date.now(),
}; };
const DELETE_IF_NULL = ['id', 'customName', 'md5Url', 'originalMd5']; const DELETE_IF_NULL = ['id', 'customName', 'md5Url', 'originalMd5'];
const INJ_ORDER = 'injectionOrder';
const order = {main: {}, prio: {}};
const orderWrap = {
id: INJ_ORDER,
value: mapObj(order, () => []),
_id: `${chrome.runtime.id}-${INJ_ORDER}`,
_rev: 0,
};
uuidIndex.addCustomId(orderWrap, {set: setOrder});
class MatchQuery {
constructor(url) {
this.url = url;
}
get urlWithoutHash() {
return this._set('urlWithoutHash', this.url.split('#', 1)[0]);
}
get urlWithoutParams() {
return this._set('urlWithoutParams', this.url.split(/[?#]/, 1)[0]);
}
get domain() {
return this._set('domain', tryURL(this.url).hostname);
}
get isOwnPage() {
return this._set('isOwnPage', this.url.startsWith(URLS.ownOrigin));
}
_set(name, value) {
Object.defineProperty(this, name, {value});
return value;
}
}
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = Promise.all([init(), prefs.ready]); let ready = init();
chrome.runtime.onConnect.addListener(port => { chrome.runtime.onConnect.addListener(handleLivePreview);
if (port.name === 'livePreview') {
handleLivePreview(port);
} else if (port.name.startsWith('draft:')) {
handleDraft(port);
}
});
colorScheme.onChange(value => {
msg.broadcastExtension({method: 'colorScheme', value});
for (const {style} of dataMap.values()) {
if (colorScheme.SCHEMES.includes(style.preferScheme)) {
broadcastStyleUpdated(style, 'colorScheme');
}
}
});
//#endregion //#endregion
//#region Exports //#region Exports
@ -124,28 +61,17 @@ const styleMan = (() => {
/** @returns {Promise<number>} style id */ /** @returns {Promise<number>} style id */
async delete(id, reason) { async delete(id, reason) {
if (ready.then) await ready; if (ready.then) await ready;
const {style, appliesTo} = dataMap.get(id); const data = id2data(id);
const sync = reason !== 'sync'; await db.exec('delete', id);
const uuid = style._id; if (reason !== 'sync') {
db.styles.delete(id); API.sync.delete(data.style._id, Date.now());
if (sync) API.sync.delete(uuid, Date.now()); }
for (const url of appliesTo) { for (const url of data.appliesTo) {
const cache = cachedStyleForUrl.get(url); const cache = cachedStyleForUrl.get(url);
if (cache) delete cache.sections[id]; if (cache) delete cache.sections[id];
} }
dataMap.delete(id); dataMap.delete(id);
uuidIndex.delete(uuid); uuidIndex.delete(data.style._id);
mapObj(orderWrap.value, (group, type) => {
delete order[type][id];
const i = group.indexOf(uuid);
if (i >= 0) group.splice(i, 1);
});
setOrder(orderWrap, {calc: false});
if (style._usw && style._usw.token) {
// Must be called after the style is deleted from dataMap
API.usw.revoke(id);
}
API.drafts.delete(id);
await msg.broadcast({ await msg.broadcast({
method: 'styleDeleted', method: 'styleDeleted',
style: {id}, style: {id},
@ -153,23 +79,32 @@ const styleMan = (() => {
return id; return id;
}, },
/** @returns {Promise<number>} style id */
async deleteByUUID(_id, rev) {
if (ready.then) await ready;
const id = uuidIndex.get(_id);
const oldDoc = id && id2style(id);
if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) {
// FIXME: does it make sense to set reason to 'sync' in deleteByUUID?
return styleMan.delete(id, 'sync');
}
},
/** @returns {Promise<StyleObj>} */ /** @returns {Promise<StyleObj>} */
async editSave(style) { async editSave(style) {
if (ready.then) await ready; if (ready.then) await ready;
style = mergeWithMapped(style); style = mergeWithMapped(style);
style.updateDate = Date.now(); style.updateDate = Date.now();
return saveStyle(style, {reason: 'editSave'}); return handleSave(await saveStyle(style), 'editSave');
}, },
/** @returns {Promise<?StyleObj>} */ /** @returns {Promise<?StyleObj>} */
async find(...filters) { async find(filter) {
if (ready.then) await ready; if (ready.then) await ready;
for (const filter of filters) { const filterEntries = Object.entries(filter);
const filterEntries = Object.entries(filter); for (const {style} of dataMap.values()) {
for (const {style} of dataMap.values()) { if (filterEntries.every(([key, val]) => style[key] === val)) {
if (filterEntries.every(([key, val]) => style[key] === val)) { return style;
return style;
}
} }
} }
return null; return null;
@ -178,58 +113,25 @@ const styleMan = (() => {
/** @returns {Promise<StyleObj[]>} */ /** @returns {Promise<StyleObj[]>} */
async getAll() { async getAll() {
if (ready.then) await ready; if (ready.then) await ready;
return getAllAsArray(); return Array.from(dataMap.values(), data2style);
}, },
/** @returns {Promise<Object<string,StyleObj[]>>}>} */ /** @returns {Promise<StyleObj>} */
async getAllOrdered(keys) { async getByUUID(uuid) {
if (ready.then) await ready; if (ready.then) await ready;
const res = mapObj(orderWrap.value, group => group.map(uuid2style).filter(Boolean)); return id2style(uuidIndex.get(uuid));
if (res.main.length + res.prio.length < dataMap.size) {
for (const {style} of dataMap.values()) {
if (!(style.id in order.main) && !(style.id in order.prio)) {
res.main.push(style);
}
}
}
return keys
? mapObj(res, group => group.map(style => mapObj(style, null, keys)))
: res;
},
getOrder: () => orderWrap.value,
/** @returns {Promise<string | {[remoteId:string]: styleId}>}>} */
async getRemoteInfo(id) {
if (ready.then) await ready;
if (id) return calcRemoteId(id2style(id));
const res = {};
for (const {style} of dataMap.values()) {
const [rid, vars] = calcRemoteId(style);
if (rid) res[rid] = [style.id, vars];
}
return res;
}, },
/** @returns {Promise<StyleSectionsToApply>} */ /** @returns {Promise<StyleSectionsToApply>} */
async getSectionsByUrl(url, id, isInitialApply) { async getSectionsByUrl(url, id, isInitialApply) {
if (ready.then) await ready; if (ready.then) await ready;
if (isInitialApply && prefs.get('disableAll')) { if (isInitialApply && prefs.get('disableAll')) {
return { return {disableAll: true};
cfg: {
disableAll: true,
},
};
}
// TODO: enable in FF when it supports sourceURL comment in style elements (also options.html)
const {exposeStyleName} = CHROME && prefs.__values;
const sender = CHROME && this && this.sender || {};
if (sender.frameId === 0) {
/* Chrome hides text frament from location.href of the page e.g. #:~:text=foo
so we'll use the real URL reported by webNavigation API.
TODO: if FF will do the same, this won't work as is: FF reports onCommitted too late */
url = tabMan.get(sender.tab.id, 'url', 0) || url;
} }
/* Chrome hides text frament from location.href of the page e.g. #:~:text=foo
so we'll use the real URL reported by webNavigation API */
const {tab, frameId} = this && this.sender || {};
url = tab && tabMan.get(tab.id, 'url', frameId) || url;
let cache = cachedStyleForUrl.get(url); let cache = cachedStyleForUrl.get(url);
if (!cache) { if (!cache) {
cache = { cache = {
@ -241,9 +143,9 @@ const styleMan = (() => {
} else if (cache.maybeMatch.size) { } else if (cache.maybeMatch.size) {
buildCache(cache, url, Array.from(cache.maybeMatch, id2data).filter(Boolean)); buildCache(cache, url, Array.from(cache.maybeMatch, id2data).filter(Boolean));
} }
return Object.assign({cfg: {exposeStyleName, order}}, return id
id ? mapObj(cache.sections, null, [id]) ? cache.sections[id] ? {[id]: cache.sections[id]} : {}
: cache.sections); : cache.sections;
}, },
/** @returns {Promise<StyleObj>} */ /** @returns {Promise<StyleObj>} */
@ -260,12 +162,10 @@ const styleMan = (() => {
const result = []; const result = [];
const styles = id const styles = id
? [id2style(id)].filter(Boolean) ? [id2style(id)].filter(Boolean)
: getAllAsArray(); : Array.from(dataMap.values(), data2style);
const query = new MatchQuery(url); const query = createMatchQuery(url);
for (const style of styles) { for (const style of styles) {
let excluded = false; let excluded = false;
let excludedScheme = false;
let included = false;
let sloppy = false; let sloppy = false;
let sectionMatched = false; let sectionMatched = false;
const match = urlMatchStyle(query, style); const match = urlMatchStyle(query, style);
@ -273,17 +173,14 @@ const styleMan = (() => {
// if (match === false) { // if (match === false) {
// continue; // continue;
// } // }
if (match === 'included') {
included = true;
}
if (match === 'excluded') { if (match === 'excluded') {
excluded = true; excluded = true;
} }
if (match === 'excludedScheme') {
excludedScheme = true;
}
for (const section of style.sections) { for (const section of style.sections) {
const match = urlMatchSection(query, section, true); if (styleSectionGlobal(section) && styleCodeEmpty(section.code)) {
continue;
}
const match = urlMatchSection(query, section);
if (match) { if (match) {
if (match === 'sloppy') { if (match === 'sloppy') {
sloppy = true; sloppy = true;
@ -292,9 +189,8 @@ const styleMan = (() => {
break; break;
} }
} }
if (sectionMatched || included) { if (sectionMatched) {
result.push(/** @namespace StylesByUrlResult */ { result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy});
style, excluded, sloppy, excludedScheme, sectionMatched, included});
} }
} }
return result; return result;
@ -303,16 +199,18 @@ const styleMan = (() => {
/** @returns {Promise<StyleObj[]>} */ /** @returns {Promise<StyleObj[]>} */
async importMany(items) { async importMany(items) {
if (ready.then) await ready; if (ready.then) await ready;
for (const style of items) { items.forEach(beforeSave);
beforeSave(style); const events = await db.exec('putMany', items);
if (style.sourceCode && style.usercssData) { return Promise.all(items.map((item, i) => {
await usercssMan.buildCode(style); afterSave(item, events[i]);
} return handleSave(item, 'import');
} }));
const events = await db.styles.putMany(items); },
return Promise.all(items.map((item, i) =>
handleSave(item, {reason: 'import'}, events[i]) /** @returns {Promise<StyleObj>} */
)); async import(data) {
if (ready.then) await ready;
return handleSave(await saveStyle(data), 'import');
}, },
/** @returns {Promise<StyleObj>} */ /** @returns {Promise<StyleObj>} */
@ -320,23 +218,46 @@ const styleMan = (() => {
if (ready.then) await ready; if (ready.then) await ready;
reason = reason || dataMap.has(style.id) ? 'update' : 'install'; reason = reason || dataMap.has(style.id) ? 'update' : 'install';
style = mergeWithMapped(style); style = mergeWithMapped(style);
const url = !style.url && style.updateUrl && (
URLS.extractUsoArchiveInstallUrl(style.updateUrl) ||
URLS.extractGreasyForkInstallUrl(style.updateUrl)
);
if (url) style.url = style.installationUrl = url;
style.originalDigest = await calcStyleDigest(style); style.originalDigest = await calcStyleDigest(style);
// FIXME: update updateDate? what about usercss config? // FIXME: update updateDate? what about usercss config?
return saveStyle(style, {reason}); return handleSave(await saveStyle(style), reason);
}, },
save: saveStyle, /** @returns {Promise<?StyleObj>} */
async putByUUID(doc) {
async setOrder(value) {
if (ready.then) await ready; if (ready.then) await ready;
return setOrder({value}, {broadcast: true, sync: true}); const id = uuidIndex.get(doc._id);
if (id) {
doc.id = id;
} else {
delete doc.id;
}
const oldDoc = id && id2style(id);
let diff = -1;
if (oldDoc) {
diff = compareRevision(oldDoc._rev, doc._rev);
if (diff > 0) {
API.sync.put(oldDoc._id, oldDoc._rev);
return;
}
}
if (diff < 0) {
doc.id = await db.exec('put', doc);
uuidIndex.set(doc._id, doc.id);
return handleSave(doc, 'sync');
}
}, },
/** @returns {Promise<number>} style id */ /** @returns {Promise<number>} style id */
async toggle(id, enabled) { async toggle(id, enabled) {
if (ready.then) await ready; if (ready.then) await ready;
const style = Object.assign({}, id2style(id), {enabled}); const style = Object.assign({}, id2style(id), {enabled});
await saveStyle(style, {reason: 'toggle'}); handleSave(await saveStyle(style), 'toggle', false);
return id; return id;
}, },
@ -350,14 +271,6 @@ const styleMan = (() => {
removeExclusion: removeIncludeExclude.bind(null, 'exclusions'), removeExclusion: removeIncludeExclude.bind(null, 'exclusions'),
/** @returns {Promise<?StyleObj>} */ /** @returns {Promise<?StyleObj>} */
removeInclusion: removeIncludeExclude.bind(null, 'inclusions'), removeInclusion: removeIncludeExclude.bind(null, 'inclusions'),
async config(id, prop, value) {
if (ready.then) await ready;
const style = Object.assign({}, id2style(id));
const {preview = {}} = dataMap.get(id);
style[prop] = preview[prop] = value;
return saveStyle(style, {reason: 'config'});
},
}; };
//#endregion //#endregion
@ -370,23 +283,12 @@ const styleMan = (() => {
/** @returns {?StyleObj} */ /** @returns {?StyleObj} */
function id2style(id) { function id2style(id) {
return (dataMap.get(Number(id)) || {}).style; return (dataMap.get(id) || {}).style;
} }
/** @returns {?StyleObj} */ /** @returns {?StyleObj} */
function uuid2style(uuid) { function data2style(data) {
return id2style(uuidIndex.get(uuid)); return data && data.style;
}
function calcRemoteId({md5Url, updateUrl, usercssData: ucd} = {}) {
let id;
id = (id = /\d+/.test(md5Url) || URLS.extractUsoArchiveId(updateUrl)) && `uso-${id}`
|| (id = URLS.extractUSwId(updateUrl)) && `usw-${id}`
|| '';
return id && [
id,
ucd && !isEmptyObj(ucd.vars),
];
} }
/** @returns {StyleObj} */ /** @returns {StyleObj} */
@ -407,7 +309,6 @@ const styleMan = (() => {
style, style,
appliesTo: new Set(), appliesTo: new Set(),
}); });
uuidIndex.set(style._id, style.id);
} }
/** @returns {StyleObj} */ /** @returns {StyleObj} */
@ -417,12 +318,10 @@ const styleMan = (() => {
style); style);
} }
function handleDraft(port) {
const id = port.name.split(':').pop();
port.onDisconnect.addListener(() => API.drafts.delete(Number(id) || id));
}
function handleLivePreview(port) { function handleLivePreview(port) {
if (port.name !== 'livePreview') {
return;
}
let id; let id;
port.onMessage.addListener(style => { port.onMessage.addListener(style => {
if (!id) id = style.id; if (!id) id = style.id;
@ -450,7 +349,7 @@ const styleMan = (() => {
throw new Error('The rule already exists'); throw new Error('The rule already exists');
} }
style[type] = list.concat([rule]); style[type] = list.concat([rule]);
return saveStyle(style, {reason: 'config'}); return handleSave(await saveStyle(style), 'styleSettings');
} }
async function removeIncludeExclude(type, id, rule) { async function removeIncludeExclude(type, id, rule) {
@ -461,10 +360,10 @@ const styleMan = (() => {
return; return;
} }
style[type] = list.filter(r => r !== rule); style[type] = list.filter(r => r !== rule);
return saveStyle(style, {reason: 'config'}); return handleSave(await saveStyle(style), 'styleSettings');
} }
function broadcastStyleUpdated(style, reason, method = 'styleUpdated') { function broadcastStyleUpdated(style, reason, method = 'styleUpdated', codeIsUpdated = true) {
const {id} = style; const {id} = style;
const data = id2data(id); const data = id2data(id);
const excluded = new Set(); const excluded = new Set();
@ -474,10 +373,10 @@ const styleMan = (() => {
cache.maybeMatch.add(id); cache.maybeMatch.add(id);
continue; continue;
} }
const code = getAppliedCode(new MatchQuery(url), style); const code = getAppliedCode(createMatchQuery(url), style);
if (code) { if (code) {
updated.add(url); updated.add(url);
buildCacheEntry(cache, style, code); cache.sections[id] = {id, code};
} else { } else {
excluded.add(url); excluded.add(url);
delete cache.sections[id]; delete cache.sections[id];
@ -487,6 +386,7 @@ const styleMan = (() => {
return msg.broadcast({ return msg.broadcast({
method, method,
reason, reason,
codeIsUpdated,
style: { style: {
id, id,
md5Url: style.md5Url, md5Url: style.md5Url,
@ -508,39 +408,39 @@ const styleMan = (() => {
style._id = uuidv4(); style._id = uuidv4();
} }
style._rev = Date.now(); style._rev = Date.now();
fixKnownProblems(style); fixUsoMd5Issue(style);
} }
async function saveStyle(style, handlingOptions) { function afterSave(style, newId) {
if (style.id == null) {
style.id = newId;
}
uuidIndex.set(style._id, style.id);
API.sync.put(style._id, style._rev);
}
async function saveStyle(style) {
beforeSave(style); beforeSave(style);
const newId = await db.styles.put(style); const newId = await db.exec('put', style);
return handleSave(style, handlingOptions, newId); afterSave(style, newId);
return style;
} }
function handleSave(style, {reason, broadcast = true}, id = style.id) { function handleSave(style, reason, codeIsUpdated) {
if (style.id == null) style.id = id; const data = id2data(style.id);
const data = id2data(id);
const method = data ? 'styleUpdated' : 'styleAdded'; const method = data ? 'styleUpdated' : 'styleAdded';
if (!data) { if (!data) {
storeInMap(style); storeInMap(style);
} else { } else {
data.style = style; data.style = style;
} }
if (reason !== 'sync') { broadcastStyleUpdated(style, reason, method, codeIsUpdated);
API.sync.putDoc(style);
}
if (broadcast) broadcastStyleUpdated(style, reason, method);
return style; return style;
} }
// get styles matching a URL, including sloppy regexps and excluded items. // get styles matching a URL, including sloppy regexps and excluded items.
function getAppliedCode(query, data) { function getAppliedCode(query, data) {
const result = urlMatchStyle(query, data); if (urlMatchStyle(query, data) !== true) {
if (result === 'included') {
// return all sections
return data.sections.map(s => s.code);
}
if (result !== true) {
return; return;
} }
const code = []; const code = [];
@ -553,19 +453,23 @@ const styleMan = (() => {
} }
async function init() { async function init() {
const orderPromise = API.prefsDb.get(orderWrap.id); const styles = await db.exec('getAll') || [];
const styles = await db.styles.getAll() || []; const updated = styles.filter(style =>
const updated = await Promise.all(styles.map(fixKnownProblems).filter(Boolean)); addMissingProps(style) +
addCustomName(style));
if (updated.length) { if (updated.length) {
await db.styles.putMany(updated); await db.exec('putMany', updated);
}
for (const style of styles) {
fixUsoMd5Issue(style);
storeInMap(style);
uuidIndex.set(style._id, style.id);
} }
setOrder(await orderPromise, {store: false});
styles.forEach(storeInMap);
ready = true; ready = true;
bgReady._resolveStyles(); bgReady._resolveStyles();
} }
function fixKnownProblems(style, initIndex, initArray) { function addMissingProps(style) {
let res = 0; let res = 0;
for (const key in MISSING_PROPS) { for (const key in MISSING_PROPS) {
if (!style[key]) { if (!style[key]) {
@ -573,53 +477,22 @@ const styleMan = (() => {
res = 1; res = 1;
} }
} }
/* Upgrade the old way of customizing local names */ return res;
}
/** Upgrades the old way of customizing local names */
function addCustomName(style) {
let res = 0;
const {originalName} = style; const {originalName} = style;
if (originalName) { if (originalName) {
res = 1;
if (originalName !== style.name) { if (originalName !== style.name) {
style.customName = style.name; style.customName = style.name;
style.name = originalName; style.name = originalName;
} }
delete style.originalName; delete style.originalName;
res = 1;
} }
/* wrong homepage url in 1.5.20-1.5.21 due to commit 1e5f118d */ return res;
for (const key of ['url', 'installationUrl']) {
const url = style[key];
const fixedUrl = url && url.replace(/([^:]\/)\//, '$1');
if (fixedUrl !== url) {
res = 1;
style[key] = fixedUrl;
}
}
let url;
/* USO bug, duplicate "update" subdomain, see #523 */
if ((url = style.md5Url) && url.includes('update.update.userstyles')) {
res = style.md5Url = url.replace('update.update.userstyles', 'update.userstyles');
}
/* Default homepage URL for external styles installed from a known distro */
if (
(!style.url || !style.installationUrl) &&
(url = style.updateUrl) &&
(url = URLS.extractGreasyForkInstallUrl(url) ||
URLS.extractUsoArchiveInstallUrl(url) ||
URLS.extractUSwInstallUrl(url)
)
) {
if (!style.url) res = style.url = url;
if (!style.installationUrl) res = style.installationUrl = url;
}
/* @import must precede `vars` that we add at beginning */
if (
initArray &&
!isEmptyObj((style.usercssData || {}).vars) &&
style.sections.some(({code}) =>
code.startsWith(':root {\n --') &&
/@import\s/i.test(code))
) {
return usercssMan.buildCode(style);
}
return res && style;
} }
function urlMatchStyle(query, style) { function urlMatchStyle(query, style) {
@ -632,68 +505,43 @@ const styleMan = (() => {
if (!style.enabled) { if (!style.enabled) {
return 'disabled'; return 'disabled';
} }
if (!colorScheme.shouldIncludeStyle(style)) {
return 'excludedScheme';
}
if (
style.inclusions &&
style.inclusions.some(r => compileExclusion(r).test(query.urlWithoutParams))
) {
return 'included';
}
return true; return true;
} }
function urlMatchSection(query, section, skipEmptyGlobal) { function urlMatchSection(query, section) {
let dd, ddL, pp, ppL, rr, rrL, uu, uuL;
if ( if (
(dd = section.domains) && (ddL = dd.length) && dd.some(urlMatchDomain, query) || section.domains &&
(pp = section.urlPrefixes) && (ppL = pp.length) && pp.some(urlMatchPrefix, query) || section.domains.some(d => d === query.domain || query.domain.endsWith(`.${d}`))
/* Per the specification the fragment portion is ignored in @-moz-document:
https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc
but the spec is outdated and doesn't account for SPA sites,
so we only respect it for `url()` function */
(uu = section.urls) && (uuL = uu.length) && (
uu.includes(query.url) ||
uu.includes(query.urlWithoutHash)
) ||
(rr = section.regexps) && (rrL = rr.length) && rr.some(urlMatchRegexp, query)
) { ) {
return true; return true;
} }
if (section.urlPrefixes && section.urlPrefixes.some(p => p && query.url.startsWith(p))) {
return true;
}
// as per spec the fragment portion is ignored in @-moz-document:
// https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc
// but the spec is outdated and doesn't account for SPA sites
// so we only respect it for `url()` function
if (section.urls && (
section.urls.includes(query.url) ||
section.urls.includes(query.urlWithoutHash)
)) {
return true;
}
if (section.regexps && section.regexps.some(r => compileRe(r).test(query.url))) {
return true;
}
/* /*
According to CSS4 @document specification the entire URL must match. According to CSS4 @document specification the entire URL must match.
Stylish-for-Chrome implemented it incorrectly since the very beginning. Stylish-for-Chrome implemented it incorrectly since the very beginning.
We'll detect styles that abuse the bug by finding the sections that We'll detect styles that abuse the bug by finding the sections that
would have been applied by Stylish but not by us as we follow the spec. would have been applied by Stylish but not by us as we follow the spec.
*/ */
if (rrL && rr.some(urlMatchRegexpSloppy, query)) { if (section.regexps && section.regexps.some(r => compileSloppyRe(r).test(query.url))) {
return 'sloppy'; return 'sloppy';
} }
// TODO: check for invalid regexps? // TODO: check for invalid regexps?
return !rrL && !ppL && !uuL && !ddL && return styleSectionGlobal(section);
!query.isOwnPage && // We allow only intentionally targeted sections for own pages
(!skipEmptyGlobal || !styleCodeEmpty(section.code));
}
/** @this {MatchQuery} */
function urlMatchDomain(d) {
const _d = this.domain;
return d === _d ||
_d[_d.length - d.length - 1] === '.' && _d.endsWith(d);
}
/** @this {MatchQuery} */
function urlMatchPrefix(p) {
return p && this.url.startsWith(p);
}
/** @this {MatchQuery} */
function urlMatchRegexp(r) {
return (!this.isOwnPage || /\bextension\b/.test(r)) &&
compileRe(r).test(this.url);
}
/** @this {MatchQuery} */
function urlMatchRegexpSloppy(r) {
return (!this.isOwnPage || /\bextension\b/.test(r)) &&
compileSloppyRe(r).test(this.url);
} }
function createCompiler(compile) { function createCompiler(compile) {
@ -733,28 +581,82 @@ const styleMan = (() => {
'$'; '$';
} }
// The md5Url provided by USO includes a duplicate "update" subdomain (see #523),
// This fixes any already installed styles containing this error
function fixUsoMd5Issue(style) {
if (style && style.md5Url && style.md5Url.includes('update.update.userstyles')) {
style.md5Url = style.md5Url.replace('update.update.userstyles', 'update.userstyles');
}
}
function createMatchQuery(url) {
let urlWithoutHash;
let urlWithoutParams;
let domain;
return {
url,
get urlWithoutHash() {
if (!urlWithoutHash) {
urlWithoutHash = url.split('#')[0];
}
return urlWithoutHash;
},
get urlWithoutParams() {
if (!urlWithoutParams) {
const u = createURL(url);
urlWithoutParams = u.origin + u.pathname;
}
return urlWithoutParams;
},
get domain() {
if (!domain) {
const u = createURL(url);
domain = u.hostname;
}
return domain;
},
};
}
function buildCache(cache, url, styleList) { function buildCache(cache, url, styleList) {
const query = new MatchQuery(url); const query = createMatchQuery(url);
for (const {style, appliesTo, preview} of styleList) { for (const {style, appliesTo, preview} of styleList) {
const code = getAppliedCode(query, preview || style); const code = getAppliedCode(query, preview || style);
if (code) { if (code) {
buildCacheEntry(cache, style, code); const id = style.id;
cache.sections[id] = {id, code};
appliesTo.add(url); appliesTo.add(url);
} }
} }
} }
function buildCacheEntry(cache, style, code = style.code) { function createURL(url) {
cache.sections[style.id] = { try {
code, return new URL(url);
id: style.id, } catch (err) {
name: style.customName || style.name, return {
}; hash: '',
host: '',
hostname: '',
href: '',
origin: '',
password: '',
pathname: '',
port: '',
protocol: '',
search: '',
searchParams: new URLSearchParams(),
username: '',
};
}
} }
/** @returns {StyleObj[]} */ function uuidv4() {
function getAllAsArray() { const seeds = crypto.getRandomValues(new Uint16Array(8));
return Array.from(dataMap.values(), v => v.style); // 00001111-2222-M333-N444-555566667777
seeds[3] = seeds[3] & 0x0FFF | 0x4000; // UUID version 4, M = 4
seeds[4] = seeds[4] & 0x3FFF | 0x8000; // UUID variant 1, N = 8..0xB
return Array.from(seeds, hex4dashed).join('');
} }
/** uuidv4 helper: converts to a 4-digit hex string and adds "-" at required positions */ /** uuidv4 helper: converts to a 4-digit hex string and adds "-" at required positions */
@ -762,30 +664,66 @@ const styleMan = (() => {
return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : ''); return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : '');
} }
async function setOrder(data, {broadcast, calc = true, store = true, sync} = {}) {
if (!data || !data.value || deepEqual(data.value, orderWrap.value)) {
return;
}
Object.assign(orderWrap, data, sync && {_rev: Date.now()});
if (calc) {
for (const [type, group] of Object.entries(data.value)) {
const dst = order[type] = {};
group.forEach((uuid, i) => {
const id = uuidIndex.get(uuid);
if (id) dst[id] = i;
});
}
}
if (broadcast) {
msg.broadcast({method: 'styleSort', order});
}
if (store) {
await API.prefsDb.put(orderWrap, orderWrap.id);
}
if (sync) {
API.sync.putDoc(orderWrap);
}
}
//#endregion //#endregion
})(); })();
/** Creates a FIFO limit-size map. */
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get(id) {
const item = map.get(id);
return item && item.data;
},
set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
},
delete(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
},
clear() {
map.clear();
index = lastIndex = 0;
},
has: id => map.has(id),
*entries() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
*values() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
}

View File

@ -56,7 +56,6 @@
return NOP; return NOP;
} }
return API.styles.getSectionsByUrl(url, id).then(sections => { return API.styles.getSectionsByUrl(url, id).then(sections => {
delete sections.cfg;
const tasks = []; const tasks = [];
for (const section of Object.values(sections)) { for (const section of Object.values(sections)) {
const styleId = section.id; const styleId = section.id;

View File

@ -1,5 +1,5 @@
/* global API */// msg.js /* global API */// msg.js
/* global CHROME URLS ignoreChromeError */// toolbox.js /* global CHROME ignoreChromeError */// toolbox.js
/* global prefs */ /* global prefs */
'use strict'; 'use strict';
@ -49,12 +49,6 @@
if (CHROME && !off) { if (CHROME && !off) {
chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]}); chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]});
} }
if (CHROME) {
chrome.webRequest.onBeforeRequest.addListener(openNamedStyle, {
urls: [URLS.ownOrigin + '*.user.css'],
types: ['main_frame'],
}, ['blocking']);
}
state.csp = csp; state.csp = csp;
state.off = off; state.off = off;
state.xhr = xhr; state.xhr = xhr;
@ -118,8 +112,8 @@
// Allow style assets // Allow style assets
patchCspSrc(src, 'img-src', 'data:', '*'); patchCspSrc(src, 'img-src', 'data:', '*');
patchCspSrc(src, 'font-src', 'data:', '*'); patchCspSrc(src, 'font-src', 'data:', '*');
// Allow our DOM styles, allow @import from any URL // Allow our DOM styles
patchCspSrc(src, 'style-src', "'unsafe-inline'", '*'); patchCspSrc(src, 'style-src', "'unsafe-inline'");
// Allow our XHR cookies in CSP sandbox (known case: raw github urls) // Allow our XHR cookies in CSP sandbox (known case: raw github urls)
if (src.sandbox && !src.sandbox.includes('allow-same-origin')) { if (src.sandbox && !src.sandbox.includes('allow-same-origin')) {
src.sandbox.push('allow-same-origin'); src.sandbox.push('allow-same-origin');
@ -152,14 +146,6 @@
} }
} }
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
function openNamedStyle(req) {
if (!req.url.includes('?')) { // skipping our usercss installer
chrome.tabs.update(req.tabId, {url: 'edit.html?id=' + req.url.split('#')[1]});
return {cancel: true};
}
}
function req2key(req) { function req2key(req) {
return req.tabId + ':' + req.frameId; return req.tabId + ':' + req.frameId;
} }

View File

@ -1,10 +1,8 @@
/* global API msg */// msg.js /* global API msg */// msg.js
/* global bgReady uuidIndex */// common.js /* global chromeLocal */// storage-util.js
/* global chromeLocal chromeSync */// storage-util.js /* global compareRevision */// common.js
/* global db */
/* global iconMan */ /* global iconMan */
/* global prefs */ /* global prefs */
/* global styleUtil */
/* global tokenMan */ /* global tokenMan */
'use strict'; 'use strict';
@ -20,7 +18,6 @@ const syncMan = (() => {
disconnecting: 'disconnecting', disconnecting: 'disconnecting',
}); });
const STORAGE_KEY = 'sync/state/'; const STORAGE_KEY = 'sync/state/';
const NO_LOGIN = ['webdav'];
const status = /** @namespace SyncManager.Status */ { const status = /** @namespace SyncManager.Status */ {
STATES, STATES,
state: STATES.disconnected, state: STATES.disconnected,
@ -30,12 +27,11 @@ const syncMan = (() => {
errorMessage: null, errorMessage: null,
login: false, login: false,
}; };
const compareRevision = (rev1, rev2) => rev1 - rev2;
let lastError = null; let lastError = null;
let ctrl; let ctrl;
let currentDrive; let currentDrive;
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = bgReady.styles.then(() => { let ready = prefs.ready.then(() => {
ready = true; ready = true;
prefs.subscribe('sync.enabled', prefs.subscribe('sync.enabled',
(_, val) => val === 'none' (_, val) => val === 'none'
@ -44,9 +40,9 @@ const syncMan = (() => {
{runNow: true}); {runNow: true});
}); });
chrome.alarms.onAlarm.addListener(async ({name}) => { chrome.alarms.onAlarm.addListener(info => {
if (name === 'syncNow') { if (info.name === 'syncNow') {
await syncMan.syncNow(); syncMan.syncNow();
} }
}); });
@ -82,21 +78,11 @@ const syncMan = (() => {
} }
}, },
async putDoc({_id, _rev}) { async put(...args) {
if (ready.then) await ready; if (ready.then) await ready;
if (!currentDrive) return; if (!currentDrive) return;
schedule(); schedule();
return ctrl.put(_id, _rev); return ctrl.put(...args);
},
async setDriveOptions(driveName, options) {
const key = `secure/sync/driveOptions/${driveName}`;
await chromeSync.setValue(key, options);
},
async getDriveOptions(driveName) {
const key = `secure/sync/driveOptions/${driveName}`;
return await chromeSync.getValue(key) || {};
}, },
async start(name, fromPref = false) { async start(name, fromPref = false) {
@ -104,14 +90,14 @@ const syncMan = (() => {
if (!ctrl) await initController(); if (!ctrl) await initController();
if (currentDrive) return; if (currentDrive) return;
currentDrive = await getDrive(name); currentDrive = getDrive(name);
ctrl.use(currentDrive); ctrl.use(currentDrive);
status.state = STATES.connecting; status.state = STATES.connecting;
status.currentDriveName = currentDrive.name; status.currentDriveName = currentDrive.name;
emitStatusChange(); emitStatusChange();
if (fromPref || NO_LOGIN.includes(currentDrive.name)) { if (fromPref) {
status.login = true; status.login = true;
} else { } else {
try { try {
@ -164,9 +150,9 @@ const syncMan = (() => {
status.errorMessage = null; status.errorMessage = null;
lastError = null; lastError = null;
} catch (err) { } catch (err) {
err.message = translateErrorMessage(err);
status.errorMessage = err.message; status.errorMessage = err.message;
lastError = err; lastError = err;
if (isGrantError(err)) { if (isGrantError(err)) {
status.login = false; status.login = false;
} }
@ -179,35 +165,19 @@ const syncMan = (() => {
//#region Utils //#region Utils
async function initController() { async function initController() {
await require(['/vendor/db-to-cloud/db-to-cloud']); /* global dbToCloud */ await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */
ctrl = dbToCloud.dbToCloud({ ctrl = dbToCloud.dbToCloud({
onGet: styleUtil.uuid2style, onGet(id) {
async onPut(doc) { return API.styles.getByUUID(id);
const id = uuidIndex.get(doc._id);
const oldCust = uuidIndex.custom[id];
const oldDoc = oldCust || styleUtil.id2style(id);
const diff = oldDoc ? compareRevision(oldDoc._rev, doc._rev) : -1;
if (!diff) return;
if (diff > 0) {
syncMan.putDoc(oldDoc);
} else if (oldCust) {
uuidIndex.custom[id] = doc;
} else {
delete doc.id;
if (id) doc.id = id;
doc.id = await db.styles.put(doc);
await styleUtil.handleSave(doc, {reason: 'sync'});
}
}, },
onDelete(_id, rev) { onPut(doc) {
const id = uuidIndex.get(_id); return API.styles.putByUUID(doc);
const oldDoc = styleUtil.id2style(id); },
return oldDoc && onDelete(id, rev) {
compareRevision(oldDoc._rev, rev) <= 0 && return API.styles.deleteByUUID(id, rev);
API.styles.delete(id, 'sync');
}, },
async onFirstSync() { async onFirstSync() {
for (const i of Object.values(uuidIndex.custom).concat(await API.styles.getAll())) { for (const i of await API.styles.getAll()) {
ctrl.put(i._id, i._rev); ctrl.put(i._id, i._rev);
} }
}, },
@ -229,9 +199,6 @@ const syncMan = (() => {
setState(drive, state) { setState(drive, state) {
return chromeLocal.setValue(STORAGE_KEY + drive.name, state); return chromeLocal.setValue(STORAGE_KEY + drive.name, state);
}, },
retryMaxAttempts: 10,
retryExp: 1.2,
retryDelay: 6,
}); });
} }
@ -241,16 +208,12 @@ const syncMan = (() => {
} }
function isNetworkError(err) { function isNetworkError(err) {
return ( return err.name === 'TypeError' && /networkerror|failed to fetch/i.test(err.message);
err.name === 'TypeError' && /networkerror|failed to fetch/i.test(err.message) ||
err.code === 502
);
} }
function isGrantError(err) { function isGrantError(err) {
if (err.code === 401) return true; if (err.code === 401) return true;
if (err.code === 400 && /invalid_grant/.test(err.message)) return true; if (err.code === 400 && /invalid_grant/.test(err.message)) return true;
if (err.name === 'TokenError') return true;
return false; return false;
} }
@ -270,38 +233,21 @@ const syncMan = (() => {
} }
} }
async function getDrive(name) { function getDrive(name) {
if (name === 'dropbox' || name === 'google' || name === 'onedrive' || name === 'webdav') { if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
const options = await syncMan.getDriveOptions(name); return dbToCloud.drive[name]({
options.getAccessToken = () => tokenMan.getToken(name); getAccessToken: () => tokenMan.getToken(name),
options.fetch = name === 'webdav' ? fetchWebDAV.bind(options) : fetch; });
return dbToCloud.drive[name](options);
} }
throw new Error(`unknown cloud name: ${name}`); throw new Error(`unknown cloud name: ${name}`);
} }
/** @this {Object} DriveOptions */
function fetchWebDAV(url, init = {}) {
init.credentials = 'omit'; // circumventing nextcloud CSRF token error
init.headers = Object.assign({}, init.headers, {
Authorization: `Basic ${btoa(`${this.username || ''}:${this.password || ''}`)}`,
});
return fetch(url, init);
}
function schedule(delay = SYNC_DELAY) { function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', { chrome.alarms.create('syncNow', {
delayInMinutes: delay, // fractional values are supported delayInMinutes: delay,
periodInMinutes: SYNC_INTERVAL, periodInMinutes: SYNC_INTERVAL,
}); });
} }
function translateErrorMessage(err) {
if (err.name === 'LockError') {
return browser.i18n.getMessage('syncErrorLock', new Date(err.expire).toLocaleString([], {timeStyle: 'short'}));
}
return err.message || String(err);
}
//#endregion //#endregion
})(); })();

View File

@ -1,4 +1,4 @@
/* global FIREFOX getActiveTab waitForTabUrl URLS */// toolbox.js /* global FIREFOX getActiveTab waitForTabUrl */// toolbox.js
/* global chromeLocal */// storage-util.js /* global chromeLocal */// storage-util.js
'use strict'; 'use strict';
@ -32,11 +32,10 @@ const tokenMan = (() => {
}, },
tokenURL: 'https://oauth2.googleapis.com/token', tokenURL: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive.appdata'], scopes: ['https://www.googleapis.com/auth/drive.appdata'],
// FIXME: https://github.com/openstyles/stylus/issues/1248 revoke: token => {
// revoke: token => { const params = {token};
// const params = {token}; return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
// return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`); },
// },
}, },
onedrive: { onedrive: {
flow: 'code', flow: 'code',
@ -44,42 +43,23 @@ const tokenMan = (() => {
clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w', clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w',
authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
redirect_uri: FIREFOX ?
'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' :
'https://' + location.hostname + '.chromiumapp.org/',
scopes: ['Files.ReadWrite.AppFolder', 'offline_access'], scopes: ['Files.ReadWrite.AppFolder', 'offline_access'],
}, },
userstylesworld: {
flow: 'code',
clientId: 'zeDmKhJIfJqULtcrGMsWaxRtWHEimKgS',
clientSecret: 'wqHsvTuThQmXmDiVvOpZxPwSIbyycNFImpAOTxjaIRqDbsXcTOqrymMJKsOMuibFaij' +
'ZZAkVYTDbLkQuYFKqgpMsMlFlgwQOYHvHFbgxQHDTwwdOroYhOwFuekCwXUlk',
authURL: URLS.usw + 'api/oauth/style/link',
tokenURL: URLS.usw + 'api/oauth/token',
redirect_uri: 'https://gusted.xyz/callback_helper/',
},
}; };
const NETWORK_LATENCY = 30; // seconds const NETWORK_LATENCY = 30; // seconds
const DEFAULT_REDIRECT_URI = 'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/';
let alwaysUseTab = !chrome.windows || (FIREFOX ? false : null); let alwaysUseTab = FIREFOX ? false : null;
class TokenError extends Error {
constructor(provider, message) {
super(`[${provider}] ${message}`);
this.name = 'TokenError';
this.provider = provider;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, TokenError);
}
}
}
return { return {
buildKeys(name, hooks) { buildKeys(name) {
const prefix = `secure/token/${hooks ? hooks.keyName(name) : name}/`;
const k = { const k = {
TOKEN: `${prefix}token`, TOKEN: `secure/token/${name}/token`,
EXPIRE: `${prefix}expire`, EXPIRE: `secure/token/${name}/expire`,
REFRESH: `${prefix}refresh`, REFRESH: `secure/token/${name}/refresh`,
}; };
k.LIST = Object.values(k); k.LIST = Object.values(k);
return k; return k;
@ -89,8 +69,8 @@ const tokenMan = (() => {
return AUTH[name].clientId; return AUTH[name].clientId;
}, },
async getToken(name, interactive, hooks) { async getToken(name, interactive) {
const k = tokenMan.buildKeys(name, hooks); const k = tokenMan.buildKeys(name);
const obj = await chromeLocal.get(k.LIST); const obj = await chromeLocal.get(k.LIST);
if (obj[k.TOKEN]) { if (obj[k.TOKEN]) {
if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) { if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
@ -101,14 +81,14 @@ const tokenMan = (() => {
} }
} }
if (!interactive) { if (!interactive) {
throw new TokenError(name, 'Token is missing'); throw new Error(`Invalid token: ${name}`);
} }
return authUser(k, name, interactive, hooks); return authUser(name, k, interactive);
}, },
async revokeToken(name, hooks) { async revokeToken(name) {
const provider = AUTH[name]; const provider = AUTH[name];
const k = tokenMan.buildKeys(name, hooks); const k = tokenMan.buildKeys(name);
if (provider.revoke) { if (provider.revoke) {
try { try {
const token = await chromeLocal.getValue(k.TOKEN); const token = await chromeLocal.getValue(k.TOKEN);
@ -123,7 +103,7 @@ const tokenMan = (() => {
async function refreshToken(name, k, obj) { async function refreshToken(name, k, obj) {
if (!obj[k.REFRESH]) { if (!obj[k.REFRESH]) {
throw new TokenError(name, 'No refresh token'); throw new Error('No refresh token');
} }
const provider = AUTH[name]; const provider = AUTH[name];
const body = { const body = {
@ -143,15 +123,15 @@ const tokenMan = (() => {
return handleTokenResult(result, k); return handleTokenResult(result, k);
} }
async function authUser(keys, name, interactive = false, hooks = null) { async function authUser(name, k, interactive = false) {
await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow']); await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
/* global webextLaunchWebAuthFlow */ /* global webextLaunchWebAuthFlow */
const provider = AUTH[name]; const provider = AUTH[name];
const state = Math.random().toFixed(8).slice(2); const state = Math.random().toFixed(8).slice(2);
const query = { const query = {
response_type: provider.flow, response_type: provider.flow,
client_id: provider.clientId, client_id: provider.clientId,
redirect_uri: provider.redirect_uri || DEFAULT_REDIRECT_URI, redirect_uri: provider.redirect_uri || chrome.identity.getRedirectURL(),
state, state,
}; };
if (provider.scopes) { if (provider.scopes) {
@ -163,25 +143,17 @@ const tokenMan = (() => {
if (alwaysUseTab == null) { if (alwaysUseTab == null) {
alwaysUseTab = await detectVivaldiWebRequestBug(); alwaysUseTab = await detectVivaldiWebRequestBug();
} }
if (hooks) hooks.query(query);
const url = `${provider.authURL}?${new URLSearchParams(query)}`; const url = `${provider.authURL}?${new URLSearchParams(query)}`;
const width = Math.min(screen.availWidth - 100, 800);
const height = Math.min(screen.availHeight - 100, 800);
const wnd = !alwaysUseTab && await browser.windows.getLastFocused();
const finalUrl = await webextLaunchWebAuthFlow({ const finalUrl = await webextLaunchWebAuthFlow({
url, url,
alwaysUseTab, alwaysUseTab,
interactive, interactive,
redirect_uri: query.redirect_uri, redirect_uri: query.redirect_uri,
windowOptions: wnd && Object.assign({ windowOptions: {
state: 'normal', state: 'normal',
width, width: Math.min(screen.width - 100, 800),
height, height: Math.min(screen.height - 100, 800),
}, wnd.state !== 'minimized' && { },
// Center the popup to the current window
top: Math.ceil(wnd.top + (wnd.height - width) / 2),
left: Math.ceil(wnd.left + (wnd.width - width) / 2),
}),
}); });
const params = new URLSearchParams( const params = new URLSearchParams(
provider.flow === 'token' ? provider.flow === 'token' ?
@ -189,7 +161,7 @@ const tokenMan = (() => {
new URL(finalUrl).search.slice(1) new URL(finalUrl).search.slice(1)
); );
if (params.get('state') !== state) { if (params.get('state') !== state) {
throw new TokenError(name, `Unexpected state: ${params.get('state')}, expected: ${state}`); throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`);
} }
let result; let result;
if (provider.flow === 'token') { if (provider.flow === 'token') {
@ -205,14 +177,13 @@ const tokenMan = (() => {
grant_type: 'authorization_code', grant_type: 'authorization_code',
client_id: provider.clientId, client_id: provider.clientId,
redirect_uri: query.redirect_uri, redirect_uri: query.redirect_uri,
state,
}; };
if (provider.clientSecret) { if (provider.clientSecret) {
body.client_secret = provider.clientSecret; body.client_secret = provider.clientSecret;
} }
result = await postQuery(provider.tokenURL, body); result = await postQuery(provider.tokenURL, body);
} }
return handleTokenResult(result, keys); return handleTokenResult(result, k);
} }
async function handleTokenResult(result, k) { async function handleTokenResult(result, k) {
@ -248,7 +219,7 @@ const tokenMan = (() => {
// Workaround for https://github.com/openstyles/stylus/issues/1182 // Workaround for https://github.com/openstyles/stylus/issues/1182
// Note that modern Vivaldi isn't exposed in `navigator.userAgent` but it adds `extData` to tabs // Note that modern Vivaldi isn't exposed in `navigator.userAgent` but it adds `extData` to tabs
const anyTab = await getActiveTab() || (await browser.tabs.query({}))[0]; const anyTab = await getActiveTab() || (await browser.tabs.query({}))[0];
if (anyTab && !(anyTab.extData || anyTab.vivExtData)) { if (anyTab && !anyTab.extData) {
return false; return false;
} }
let bugged = true; let bugged = true;

View File

@ -1,8 +1,7 @@
/* global API */// msg.js /* global API */// msg.js
/* global RX_META URLS debounce deepMerge download ignoreChromeError */// toolbox.js /* global RX_META URLS debounce download ignoreChromeError */// toolbox.js
/* global calcStyleDigest styleSectionsEqual */ // sections-util.js /* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js
/* global chromeLocal */// storage-util.js /* global chromeLocal */// storage-util.js
/* global compareVersion */// cmpver.js
/* global db */ /* global db */
/* global prefs */ /* global prefs */
'use strict'; 'use strict';
@ -23,14 +22,13 @@ const updateMan = (() => {
ERROR_JSON: 'error: JSON is invalid', ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style', ERROR_VERSION: 'error: version is older than installed style',
}; };
const USO_STYLES_API = `${URLS.uso}api/v1/styles/`;
const RH_ETAG = {responseHeaders: ['etag']}; // a hashsum of file contents const RH_ETAG = {responseHeaders: ['etag']}; // a hashsum of file contents
const RX_DATE2VER = new RegExp([ const RX_DATE2VER = new RegExp([
/^(\d{4})/, /^(\d{4})/,
/(0[1-9]|1(?:0|[12](?=\d\d))?|[2-9])/, // in ambiguous cases like yyyy123 the month will be 1 /(1(?:0|[12](?=\d\d))?|[2-9])/, // in ambiguous cases like yyyy123 the month will be 1
/(0[1-9]|[1-2][0-9]?|3[0-1]?|[4-9])/, /([1-2][0-9]?|3[0-1]?|[4-9])/,
/\.([01][0-9]?|2[0-3]?|[3-9])/, /\.(0|1[0-9]?|2[0-3]?|[3-9])/,
/\.([0-5][0-9]?|[6-9])$/, /\.(0|[1-5][0-9]?|[6-9])$/,
].map(rx => rx.source).join('')); ].map(rx => rx.source).join(''));
const ALARM_NAME = 'scheduledUpdate'; const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3; const MIN_INTERVAL_MS = 60e3;
@ -38,7 +36,6 @@ const updateMan = (() => {
503, // service unavailable 503, // service unavailable
429, // too many requests 429, // too many requests
]; ];
let usoReferers = 0;
let lastUpdateTime; let lastUpdateTime;
let checkingAll = false; let checkingAll = false;
let logQueue = []; let logQueue = [];
@ -65,7 +62,7 @@ const updateMan = (() => {
checkingAll = true; checkingAll = true;
const port = observe && chrome.runtime.connect({name: 'updater'}); const port = observe && chrome.runtime.connect({name: 'updater'});
const styles = (await API.styles.getAll()) const styles = (await API.styles.getAll())
.filter(style => style.updateUrl && style.updatable !== false); .filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length}); if (port) port.postMessage({count: styles.length});
log(''); log('');
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`); log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
@ -80,17 +77,17 @@ const updateMan = (() => {
/** /**
* @param {{ * @param {{
id?: number, id?: number
style?: StyleObj, style?: StyleObj
port?: chrome.runtime.Port, port?: chrome.runtime.Port
save?: boolean, save?: boolean = true
ignoreDigest?: boolean, ignoreDigest?: boolean
}} opts }} opts
* @returns {{ * @returns {{
style: StyleObj, style: StyleObj
updated?: boolean, updated?: boolean
error?: any, error?: any
STATES: UpdaterStates, STATES: UpdaterStates
}} }}
Original style digests are calculated in these cases: Original style digests are calculated in these cases:
@ -115,13 +112,12 @@ const updateMan = (() => {
save, save,
} = opts; } = opts;
if (!id) id = style.id; if (!id) id = style.id;
const {md5Url} = style; const ucd = style.usercssData;
let {usercssData: ucd, updateUrl} = style;
let res, state; let res, state;
try { try {
await checkIfEdited(); await checkIfEdited();
res = { res = {
style: await (ucd && !md5Url ? updateUsercss : updateUSO)().then(maybeSave), style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
updated: true, updated: true,
}; };
state = STATES.UPDATED; state = STATES.UPDATED;
@ -130,7 +126,7 @@ const updateMan = (() => {
err && err.message || err && err.message ||
err; err;
res = {error, style, STATES}; res = {error, style, STATES};
state = `${STATES.SKIPPED} (${Array.isArray(err) ? err[0].message : error})`; state = `${STATES.SKIPPED} (${error})`;
} }
log(`${state} #${id} ${style.customName || style.name}`); log(`${state} #${id} ${style.customName || style.name}`);
if (port) port.postMessage(res); if (port) port.postMessage(res);
@ -145,59 +141,90 @@ const updateMan = (() => {
} }
async function updateUSO() { async function updateUSO() {
const md5 = await tryDownload(md5Url); const url = URLS.makeUsoArchiveCodeUrl(style.md5Url.match(/\d+/)[0]);
const req = await tryDownload(url, RH_ETAG).catch(() => null);
if (req) {
return updateToUSOArchive(url, req);
}
const md5 = await tryDownload(style.md5Url);
if (!md5 || md5.length !== 32) { if (!md5 || md5.length !== 32) {
return Promise.reject(STATES.ERROR_MD5); return Promise.reject(STATES.ERROR_MD5);
} }
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5); return Promise.reject(STATES.SAME_MD5);
} }
let varsUrl = ''; const json = await tryDownload(style.updateUrl, {responseType: 'json'});
if (!ucd) { if (!styleJSONseemsValid(json)) {
ucd = {}; return Promise.reject(STATES.ERROR_JSON);
varsUrl = updateUrl;
updateUrl = style.updateUrl = `${USO_STYLES_API}${md5Url.match(/\/(\d+)/)[1]}`;
}
usoSpooferStart();
let json;
try {
json = await tryDownload(style.updateUrl, {responseType: 'json'});
json = await updateUsercss(json.css) ||
(await API.uso.toUsercss(json)).style;
if (varsUrl) await API.uso.useVarsUrl(json, varsUrl);
} finally {
usoSpooferStop();
} }
// USO may not provide a correctly updated originalMd5 (#555) // USO may not provide a correctly updated originalMd5 (#555)
json.originalMd5 = md5; json.originalMd5 = md5;
return json; return json;
} }
async function updateUsercss(css) { async function updateToUSOArchive(url, req) {
let oldVer = ucd.version; // UserCSS metadata may be embedded in the original USO style so let's use its updateURL
let {etag: oldEtag, updateUrl} = style; const [meta2] = req.response.replace(RX_META, '').match(RX_META) || [];
const m2 = (css || URLS.extractUsoArchiveId(updateUrl)) && if (meta2 && meta2.includes('@updateURL')) {
await getUsoEmbeddedMeta(css); const {updateUrl} = await API.usercss.buildMeta({sourceCode: meta2}).catch(() => ({}));
if (m2 && m2.updateUrl) { if (updateUrl) {
updateUrl = m2.updateUrl; url = updateUrl;
oldVer = m2.usercssData.version || '0'; req = await tryDownload(url, RH_ETAG);
oldEtag = ''; }
} else if (css) {
return;
} }
if (oldEtag && oldEtag === await downloadEtag()) { const json = await API.usercss.buildMeta({
id,
etag: req.headers.etag,
md5Url: null,
originalMd5: null,
sourceCode: req.response,
updateUrl: url,
url: URLS.extractUsoArchiveInstallUrl(url),
});
const varUrlValues = style.updateUrl.split('?')[1];
const varData = json.usercssData.vars;
if (varUrlValues && varData) {
const IK = 'ik-';
const IK_LEN = IK.length;
for (let [key, val] of new URLSearchParams(varUrlValues)) {
if (!key.startsWith(IK)) continue;
key = key.slice(IK_LEN);
const varDef = varData[key];
if (!varDef) continue;
if (varDef.options) {
let sel = val.startsWith(IK) && getVarOptByName(varDef, val.slice(IK_LEN));
if (!sel) {
key += '-custom';
sel = getVarOptByName(varDef, key + '-dropdown');
if (sel) varData[key].value = val;
}
if (sel) varDef.value = sel.name;
} else {
varDef.value = val;
}
}
}
return API.usercss.buildCode(json);
}
async function updateUsercss() {
if (style.etag && style.etag === await downloadEtag()) {
return Promise.reject(STATES.SAME_CODE); return Promise.reject(STATES.SAME_CODE);
} }
// TODO: when sourceCode is > 100kB use http range request(s) for version check // TODO: when sourceCode is > 100kB use http range request(s) for version check
const {headers: {etag}, response} = await tryDownload(updateUrl, RH_ETAG); const {headers: {etag}, response} = await tryDownload(style.updateUrl, RH_ETAG);
const json = await API.usercss.buildMeta({sourceCode: response, etag, updateUrl}); /* There's a bug? in Chrome which occurs only in a packaged crx:
const delta = compareVersion(json.usercssData.version, oldVer); * DOM script for semver fires 'load' event before the script actually runs.
* Since the conditions for the bug are rare we'll simply load in parallel */
const [json] = await Promise.all([
API.usercss.buildMeta({sourceCode: response, etag}),
require(['/vendor/semver-bundle/semver']), /* global semverCompare */
]);
const delta = semverCompare(json.usercssData.version, ucd.version);
let err; let err;
if (!delta && !ignoreDigest) { if (!delta && !ignoreDigest) {
// re-install is invalid in a soft upgrade // re-install is invalid in a soft upgrade
err = response === style.sourceCode err = response === style.sourceCode ? STATES.SAME_CODE : STATES.SAME_VERSION;
? STATES.SAME_CODE
: !URLS.isLocalhost(updateUrl) && STATES.SAME_VERSION;
} }
if (delta < 0) { if (delta < 0) {
// downgrade is always invalid // downgrade is always invalid
@ -206,7 +233,7 @@ const updateMan = (() => {
if (err && etag && !style.etag) { if (err && etag && !style.etag) {
// first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce // first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce
style.etag = etag; style.etag = etag;
await db.styles.put(style); await db.exec('put', style);
} }
return err return err
? Promise.reject(err) ? Promise.reject(err)
@ -236,7 +263,6 @@ const updateMan = (() => {
let {retryDelay = 1000} = opts; let {retryDelay = 1000} = opts;
while (true) { while (true) {
try { try {
params = deepMerge(params || {}, {headers: {'Cache-Control': 'no-cache'}});
return await download(url, params); return await download(url, params);
} catch (code) { } catch (code) {
if (!RETRY_ERRORS.includes(code) || if (!RETRY_ERRORS.includes(code) ||
@ -256,18 +282,16 @@ const updateMan = (() => {
} }
function getDateFromVer(style) { function getDateFromVer(style) {
const m = RX_DATE2VER.exec((style.usercssData || {}).version); const m = style.updateUrl.startsWith(URLS.usoArchiveRaw) &&
style.usercssData.version.match(RX_DATE2VER);
if (m) { if (m) {
m[2]--; // month is 0-based in `Date` constructor m[2]--; // month is 0-based in `Date` constructor
return new Date(...m.slice(1)).getTime(); return new Date(...m.slice(1)).getTime();
} }
} }
/** UserCSS metadata may be embedded in the original USO style so let's use its updateURL */ function getVarOptByName(varDef, name) {
function getUsoEmbeddedMeta(code = style.sourceCode) { return varDef.options.find(o => o.name === name);
const isRaw = arguments[0];
const m = code.includes('@updateURL') && (isRaw ? code : code.replace(RX_META, '')).match(RX_META);
return m && API.usercss.buildMeta({sourceCode: m[0]}).catch(() => null);
} }
} }
@ -317,32 +341,4 @@ const updateMan = (() => {
logLastWriteTime = Date.now(); logLastWriteTime = Date.now();
logQueue = []; logQueue = [];
} }
function usoSpooferStart() {
if (++usoReferers === 1) {
chrome.webRequest.onBeforeSendHeaders.addListener(
usoSpoofer,
{types: ['xmlhttprequest'], urls: [USO_STYLES_API + '*']},
['blocking', 'requestHeaders', chrome.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS]
.filter(Boolean));
}
}
function usoSpooferStop() {
if (--usoReferers <= 0) {
usoReferers = 0;
chrome.webRequest.onBeforeSendHeaders.removeListener(usoSpoofer);
}
}
/** @param {chrome.webRequest.WebResponseHeadersDetails | browser.webRequest._OnBeforeSendHeadersDetails} info */
function usoSpoofer(info) {
if (info.tabId < 0 && URLS.ownOrigin.startsWith(info.initiator || info.originUrl || '')) {
const {requestHeaders: hh} = info;
const i = (hh.findIndex(h => /^referer$/i.test(h.name)) + 1 || hh.push({})) - 1;
hh[i].name = 'referer';
hh[i].value = URLS.uso;
return {requestHeaders: hh};
}
}
})(); })();

View File

@ -35,9 +35,9 @@ bgReady.all.then(() => {
chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, { chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
urls: [ urls: [
URLS.usw + 'api/style/*.user.css', URLS.usoArchiveRaw + 'usercss/*.user.css',
...URLS.usoArchiveRaw.map(s => s + 'usercss/*.user.css'), '*://greasyfork.org/scripts/*/code/*.user.css',
...['greasy', 'sleazy'].map(s => `*://${s}fork.org/scripts/*/code/*.user.css`), '*://sleazyfork.org/scripts/*/code/*.user.css',
...[].concat( ...[].concat(
...Object.entries(maybeDistro) ...Object.entries(maybeDistro)
.map(([host, {glob}]) => makeUsercssGlobs(host, glob))), .map(([host, {glob}]) => makeUsercssGlobs(host, glob))),
@ -74,10 +74,6 @@ bgReady.all.then(() => {
) && download(url); ) && download(url);
} }
function makeInstallerUrl(url) {
return `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
}
function makeUsercssGlobs(host, path) { function makeUsercssGlobs(host, path) {
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(','); return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
} }
@ -86,11 +82,11 @@ bgReady.all.then(() => {
if (url.includes('.user.') && if (url.includes('.user.') &&
/^(https?|file|ftps?):/.test(url) && /^(https?|file|ftps?):/.test(url) &&
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) && /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
!oldUrl.startsWith(makeInstallerUrl(url))) { !oldUrl.startsWith(URLS.installUsercss)) {
const inTab = url.startsWith('file:') && !chrome.app; const inTab = url.startsWith('file:') && !chrome.app;
const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url); const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
if (!/^\s*</.test(code) && RX_META.test(code)) { if (!/^\s*</.test(code) && RX_META.test(code)) {
await openInstallerPage(tabId, url, {code, inTab}); openInstallerPage(tabId, url, {code, inTab});
} }
} }
} }
@ -103,33 +99,25 @@ bgReady.all.then(() => {
openInstallerPage(tabId, url, {}); openInstallerPage(tabId, url, {});
// Silently suppress navigation. // Silently suppress navigation.
// Don't redirect to the install URL as it'll flash the text! // Don't redirect to the install URL as it'll flash the text!
return {cancel: true}; return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
} }
} }
async function openInstallerPage(tabId, url, {code, inTab} = {}) { function openInstallerPage(tabId, url, {code, inTab} = {}) {
const newUrl = makeInstallerUrl(url); const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
if (inTab) { if (inTab) {
const tab = await browser.tabs.get(tabId); browser.tabs.get(tabId).then(tab =>
return openURL({ openURL({
url: `${newUrl}&tabId=${tabId}`, url: `${newUrl}&tabId=${tabId}`,
active: tab.active, active: tab.active,
index: tab.index + 1, index: tab.index + 1,
openerTabId: tabId, openerTabId: tabId,
currentWindow: null, currentWindow: null,
}); }));
} } else {
const timer = setTimeout(clearInstallCode, 10e3, url); const timer = setTimeout(clearInstallCode, 10e3, url);
installCodeCache[url] = {code, timer}; installCodeCache[url] = {code, timer};
try { chrome.tabs.update(tabId, {url: newUrl});
await browser.tabs.update(tabId, {url: newUrl});
} catch (err) {
// FIXME: remove this when kiwi supports tabs.update
// https://github.com/openstyles/stylus/issues/1367
if (/Tabs cannot be edited right now/i.test(err.message)) {
return browser.tabs.create({url: newUrl});
}
throw err;
} }
} }

View File

@ -12,12 +12,10 @@ const usercssMan = {
name: null, name: null,
}), }),
/** `src` is a style or vars */ async assignVars(style, oldStyle) {
async assignVars(style, src) {
const meta = style.usercssData; const meta = style.usercssData;
const meta2 = src.usercssData; const vars = meta.vars;
const {vars} = meta; const oldVars = (oldStyle.usercssData || {}).vars;
const oldVars = meta2 ? meta2.vars : src;
if (vars && oldVars) { if (vars && oldVars) {
// The type of var might be changed during the update. Set value to null if the value is invalid. // The type of var might be changed during the update. Set value to null if the value is invalid.
for (const [key, v] of Object.entries(vars)) { for (const [key, v] of Object.entries(vars)) {
@ -42,15 +40,13 @@ const usercssMan = {
const style = await usercssMan.buildMeta({sourceCode}); const style = await usercssMan.buildMeta({sourceCode});
const dup = (checkDup || assignVars) && const dup = (checkDup || assignVars) &&
await usercssMan.find(styleId ? {id: styleId} : style); await usercssMan.find(styleId ? {id: styleId} : style);
let log;
if (!metaOnly) { if (!metaOnly) {
if (vars || assignVars) { if (vars || assignVars) {
await usercssMan.assignVars(style, vars || dup); await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup);
} }
await usercssMan.buildCode(style); await usercssMan.buildCode(style);
log = style.log; // extracting the non-enumerable prop, otherwise it won't survive messaging
} }
return {style, dup, log}; return {style, dup};
}, },
async buildCode(style) { async buildCode(style) {
@ -59,14 +55,12 @@ const usercssMan = {
const i = match.index; const i = match.index;
const j = i + match[0].length; const j = i + match[0].length;
const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j); const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j);
const {sections, errors, log} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars); const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars);
const recoverable = errors.every(e => e.recoverable); const recoverable = errors.every(e => e.recoverable);
if (!sections.length || !recoverable) { if (!sections.length || !recoverable) {
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.'; throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
} }
style.sections = sections; style.sections = sections;
// adding a non-enumerable prop so it won't be written to storage
if (log) Object.defineProperty(style, 'log', {value: log});
return style; return style;
}, },
@ -117,11 +111,7 @@ const usercssMan = {
}, },
async editSave(style) { async editSave(style) {
style = await usercssMan.parse(style); return API.styles.editSave(await usercssMan.parse(style));
return {
log: style.log, // extracting the non-enumerable prop, otherwise it won't survive messaging
style: await API.styles.editSave(style),
};
}, },
async find(styleOrData) { async find(styleOrData) {
@ -139,18 +129,17 @@ const usercssMan = {
} }
}, },
async install(style, opts) { async install(style) {
return API.styles.install(await usercssMan.parse(style, opts)); return API.styles.install(await usercssMan.parse(style));
}, },
async parse(style, {dup, vars} = {}) { async parse(style) {
style = await usercssMan.buildMeta(style); style = await usercssMan.buildMeta(style);
// preserve style.vars during update // preserve style.vars during update
if (dup || (dup = await usercssMan.find(style))) { const dup = await usercssMan.find(style);
if (dup) {
style.id = dup.id; style.id = dup.id;
} await usercssMan.assignVars(style, dup);
if (vars || (vars = dup)) {
await usercssMan.assignVars(style, vars);
} }
return usercssMan.buildCode(style); return usercssMan.buildCode(style);
}, },

View File

@ -1,158 +0,0 @@
/* global URLS stringAsRegExp */// toolbox.js
/* global usercssMan */
'use strict';
const usoApi = {};
(() => {
const pingers = {};
usoApi.pingback = (usoId, delay) => {
clearTimeout(pingers[usoId]);
delete pingers[usoId];
if (delay > 0) {
return new Promise(resolve => (pingers[usoId] = setTimeout(ping, delay, usoId, resolve)));
} else if (delay !== false) {
return ping(usoId);
}
};
/**
* Replicating USO-Archive format
* https://github.com/33kk/uso-archive/blob/flomaster/lib/uso.js
* https://github.com/33kk/uso-archive/blob/flomaster/lib/converters.js
*/
usoApi.toUsercss = async (data, {metaOnly = true, varsUrl} = {}) => {
const badKeys = {};
const newKeys = [];
const descr = JSON.stringify(data.description.trim());
const vars = (data.style_settings || []).map(makeVar, {badKeys, newKeys}).join('');
const sourceCode = `\
/* ==UserStyle==
@name ${data.name}
@namespace USO Archive
@version ${data.updated.replace(/-/g, '').replace(/[T:]/g, '.').slice(0, 14)}
@description ${/^"['`]|\\/.test(descr) ? descr : descr.slice(1, -1)}
@author ${(data.user || {}).name || '?'}
@license ${makeLicense(data.license)}${vars ? '\n@preprocessor uso' + vars : ''}`
.replace(/\*\//g, '*\\/') +
`==/UserStyle== */\n${newKeys[0] ? useNewKeys(data.css, badKeys) : data.css}`;
const {style} = await usercssMan.build({sourceCode, metaOnly});
usoApi.useVarsUrl(style, varsUrl);
return {style, badKeys, newKeys};
};
usoApi.useVarsUrl = (style, url) => {
if (!/\?ik-/.test(url)) {
return;
}
const cfg = {badKeys: {}, newKeys: []};
const {vars} = style.usercssData;
if (!vars) {
return;
}
for (let [key, val] of new URLSearchParams(url.split('?')[1])) {
if (!key.startsWith('ik-')) continue;
key = makeKey(key.slice(3), cfg);
const v = vars[key];
if (!v) continue;
if (v.options) {
let sel = val.startsWith('ik-') && optByName(v, makeKey(val.slice(3), cfg));
if (!sel) {
key += '-custom';
sel = optByName(v, key + '-dropdown');
if (sel) vars[key].value = val;
}
if (sel) v.value = sel.name;
} else {
v.value = val;
}
}
return true;
};
async function ping(id, resolve) {
await fetch(`${URLS.uso}styles/install/${id}?source=stylish-ch`);
if (resolve) resolve(true);
return true;
}
function makeKey(key, {badKeys, newKeys}) {
let res = badKeys[key];
if (!res) {
res = key.replace(/[^-\w]/g, '-');
res += newKeys.includes(res) ? '-' : '';
if (key !== res) {
badKeys[key] = res;
newKeys.push(res);
}
}
return res;
}
function makeLicense(s) {
return !s ? 'NO-REDISTRIBUTION' :
s === 'publicdomain' ? 'CC0-1.0' :
s.startsWith('ccby') ? `${s.toUpperCase().match(/(..)/g).join('-')}-4.0` :
s;
}
function makeVar({
label,
setting_type: type,
install_key: ik,
style_setting_options: opts,
}) {
const cfg = this;
let value, suffix;
ik = makeKey(ik, cfg);
label = JSON.stringify(label);
switch (type) {
case 'color':
value = opts[0].value;
break;
case 'text':
value = JSON.stringify(opts[0].value);
break;
case 'image': {
const ikCust = `${ik}-custom`;
opts.push({
label: 'Custom',
install_key: `${ikCust}-dropdown`,
value: `/*[[${ikCust}]]*/`,
});
suffix = `\n@advanced text ${ikCust} ${label.slice(0, -1)} (Custom)" "https://foo.com/123.jpg"`;
type = 'dropdown';
} // fallthrough
case 'dropdown':
value = '';
for (const o of opts) {
const def = o.default ? '*' : '';
const val = o.value;
const s = ` ${makeKey(o.install_key, cfg)} ${JSON.stringify(o.label + def)} <<<EOT${
val.includes('\n') ? '\n' : ' '}${val} EOT;\n`;
value = def ? s + value : value + s;
}
value = `{\n${value}}`;
break;
default:
value = '"ERROR: unknown type"';
}
return `\n@advanced ${type} ${ik} ${label} ${value}${suffix || ''}`;
}
function optByName(v, name) {
return v.options.find(o => o.name === name);
}
function useNewKeys(css, badKeys) {
const rxsKeys = stringAsRegExp(Object.keys(badKeys).join('\n'), '', true).replace(/\n/g, '|');
const rxUsoVars = new RegExp(`(/\\*\\[\\[)(${rxsKeys})(?=]]\\*/)`, 'g');
return css.replace(rxUsoVars, (s, a, key) => a + badKeys[key]);
}
})();

View File

@ -1,120 +0,0 @@
/* global API msg */// msg.js
/* global URLS */ // toolbox.js
/* global tokenMan */
'use strict';
const uswApi = (() => {
//#region Internals
class TokenHooks {
constructor(id) {
this.id = id;
}
keyName(name) {
return `${name}/${this.id}`;
}
query(query) {
return Object.assign(query, {vendor_data: this.id});
}
}
function fakeUsercssHeader(style) {
const {name, _usw: u = {}} = style;
const meta = Object.entries({
'@name': u.name || name || '?',
'@version': // Same as USO-archive version: YYYYMMDD.hh.mm
new Date().toISOString().replace(/^(\d+)-(\d+)-(\d+)T(\d+):(\d+).+/, '$1$2$3.$4.$5'),
'@namespace': u.namespace !== '?' && u.namespace ||
u.username && `userstyles.world/user/${u.username}` ||
'?',
'@description': u.description,
'@author': u.username,
'@license': u.license,
});
const maxKeyLen = meta.reduce((res, [k]) => Math.max(res, k.length), 0);
return [
'/* ==UserStyle==',
...meta.map(([k, v]) => v && `${k}${' '.repeat(maxKeyLen - k.length + 2)}${v}`).filter(Boolean),
'==/UserStyle== */',
].join('\n') + '\n\n';
}
async function linkStyle(style, sourceCode) {
const {id} = style;
const metadata = await API.worker.parseUsercssMeta(sourceCode).catch(console.warn) || {};
const uswData = Object.assign({}, style, {metadata, sourceCode});
API.data.set('usw' + id, uswData);
const token = await tokenMan.getToken('userstylesworld', true, new TokenHooks(id));
const info = await uswFetch('style', token);
const data = style._usw = Object.assign({token}, info);
style.url = style.url || data.homepage || `${URLS.usw}style/${data.id}`;
await uswSave(style);
return data;
}
async function uswFetch(path, token, opts) {
opts = Object.assign({credentials: 'omit'}, opts);
opts.headers = Object.assign({Authorization: `Bearer ${token}`}, opts.headers);
return (await (await fetch(`${URLS.usw}api/${path}`, opts)).json()).data;
}
/** Uses a custom method when broadcasting and avoids needlessly sending the entire style */
async function uswSave(style) {
const {id, _usw} = style;
await API.styles.save(style, {broadcast: false});
msg.broadcastExtension({method: 'uswData', style: {id, _usw}});
}
//#endregion
//#region Exports
return {
/**
* @param {number} id
* @param {string} sourceCode
* @return {Promise<string>}
*/
async publish(id, sourceCode) {
const style = await API.styles.get(id);
const code = style.usercssData ? sourceCode
: fakeUsercssHeader(style) + sourceCode;
const data = (style._usw || {}).token
? style._usw
: await linkStyle(style, code);
return uswFetch(`style/${data.id}`, data.token, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code}),
});
},
/**
* @param {number} id
* @return {Promise<void>}
*/
async revoke(id) {
await tokenMan.revokeToken('userstylesworld', new TokenHooks(id));
const style = await API.styles.get(id);
if (style) {
style._usw = {};
await uswSave(style);
}
},
};
//#endregion
})();
/* Doing this outside so we don't break IDE's recognition of the exported methods in IIFE */
for (const [k, fn] of Object.entries(uswApi)) {
uswApi[k] = async (id, ...args) => {
API.data.set('usw' + id, true);
try {
/* Awaiting inside `try` so that `finally` runs when done */
return await fn(id, ...args);
} finally {
API.data.del('usw' + id);
}
};
}

View File

@ -5,7 +5,6 @@
(() => { (() => {
if (window.INJECTED === 1) return; if (window.INJECTED === 1) return;
window.INJECTED = 1;
/** true -> when the page styles are received, /** true -> when the page styles are received,
* false -> when disableAll mode is on at start, the styles won't be sent * false -> when disableAll mode is on at start, the styles won't be sent
@ -14,21 +13,15 @@
let hasStyles = false; let hasStyles = false;
let isDisabled = false; let isDisabled = false;
let isTab = !chrome.tabs || location.pathname !== '/popup.html'; let isTab = !chrome.tabs || location.pathname !== '/popup.html';
const order = {main: [], prio: []};
const calcOrder = ({id}) =>
(order.prio[id] || 0) * 1e6 ||
order.main[id] ||
id + .5e6; // no order = at the end of `main`
const isFrame = window !== parent; const isFrame = window !== parent;
const isFrameAboutBlank = isFrame && location.href === 'about:blank'; const isFrameAboutBlank = isFrame && location.href === 'about:blank';
const isUnstylable = !chrome.app && document instanceof XMLDocument; const isUnstylable = !chrome.app && document instanceof XMLDocument;
const styleInjector = StyleInjector({ const styleInjector = StyleInjector({
compare: (a, b) => calcOrder(a) - calcOrder(b), compare: (a, b) => a.id - b.id,
onUpdate: onInjectorUpdate, onUpdate: onInjectorUpdate,
}); });
// dynamic iframes don't have a URL yet so we'll use their parent's URL (hash isn't inherited) // dynamic about: and javascript: iframes don't have a URL yet so we'll use their parent
let matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href.split('#')[0]) || const matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href) || location.href;
location.href;
// save it now because chrome.runtime will be unavailable in the orphaned script // save it now because chrome.runtime will be unavailable in the orphaned script
const orphanEventId = chrome.runtime.id; const orphanEventId = chrome.runtime.id;
@ -41,26 +34,8 @@
let lazyBadge = isFrame; let lazyBadge = isFrame;
let parentDomain; let parentDomain;
/* about:blank iframes are often used by sites for file upload or background tasks
* and they may break if unexpected DOM stuff is present at `load` event
* so we'll add the styles only if the iframe becomes visible */
const xoEventId = `${Math.random()}`;
/** @type IntersectionObserver */
let xo;
window[Symbol.for('xo')] = (el, cb) => {
if (!xo) xo = new IntersectionObserver(onIntersect, {rootMargin: '100%'});
el.addEventListener(xoEventId, cb, {once: true});
xo.observe(el);
};
// FIXME: move this to background page when following bugs are fixed:
// https://bugzil.la/1587723, https://crbug.com/968651
const mqDark = matchMedia('(prefers-color-scheme: dark)');
mqDark.onchange = e => API.colorScheme.updateSystemPreferDark(e.matches);
mqDark.onchange(mqDark);
// Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let // Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let
init(); const ready = init();
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
if (!isTab) { if (!isTab) {
@ -71,11 +46,6 @@
} }
msg.onTab(applyOnMessage); msg.onTab(applyOnMessage);
window.addEventListener('pageshow', e => {
if (e.isTrusted && e.persisted) { // bfcache
updateCount();
}
});
if (!chrome.tabs) { if (!chrome.tabs) {
window.dispatchEvent(new CustomEvent(orphanEventId)); window.dispatchEvent(new CustomEvent(orphanEventId));
@ -104,13 +74,13 @@
tryCatch(() => parent[parent.Symbol.for(SYM_ID)]); tryCatch(() => parent[parent.Symbol.for(SYM_ID)]);
const styles = const styles =
window[SYM] || window[SYM] ||
parentStyles && await new Promise(onFrameElementInView) && parentStyles || /* about:blank iframes are often used by sites for file upload or background tasks
* and they may break if unexpected DOM stuff is present at `load` event
* so we'll add the styles in the next tick */
parentStyles && await new Promise(requestAnimationFrame) && parentStyles ||
!isFrameAboutBlank && chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr) || !isFrameAboutBlank && chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr) ||
await API.styles.getSectionsByUrl(matchUrl, null, true); await API.styles.getSectionsByUrl(matchUrl, null, true);
if (styles.cfg) { isDisabled = styles.disableAll;
isDisabled = styles.cfg.disableAll;
Object.assign(order, styles.cfg.order);
}
hasStyles = !isDisabled; hasStyles = !isDisabled;
if (hasStyles) { if (hasStyles) {
window[SYM] = styles; window[SYM] = styles;
@ -176,20 +146,21 @@
} }
break; break;
case 'styleSort':
Object.assign(order, request.order);
styleInjector.sort();
break;
case 'urlChanged': case 'urlChanged':
if (!hasStyles && isDisabled || matchUrl === request.url) break; if (!hasStyles && isDisabled) break;
matchUrl = request.url;
API.styles.getSectionsByUrl(matchUrl).then(sections => { API.styles.getSectionsByUrl(matchUrl).then(sections => {
hasStyles = true; hasStyles = true;
styleInjector.replace(sections); styleInjector.replace(sections);
}); });
break; break;
case 'backgroundReady':
ready.catch(err =>
msg.isIgnorableError(err)
? init()
: console.error(err));
break;
case 'updateCount': case 'updateCount':
updateCount(); updateCount();
break; break;
@ -238,20 +209,6 @@
).catch(msg.ignoreError); ).catch(msg.ignoreError);
} }
function onFrameElementInView(cb) {
parent[parent.Symbol.for('xo')](frameElement, cb);
}
/** @param {IntersectionObserverEntry[]} entries */
function onIntersect(entries) {
for (const e of entries) {
if (e.isIntersecting) {
xo.unobserve(e.target);
e.target.dispatchEvent(new Event(xoEventId));
}
}
}
function tryCatch(func, ...args) { function tryCatch(func, ...args) {
try { try {
return func(...args); return func(...args);
@ -259,13 +216,12 @@
} }
function orphanCheck() { function orphanCheck() {
if (chrome.runtime.id) return; if (tryCatch(() => chrome.i18n.getUILanguage())) return;
// In Chrome content script is orphaned on an extension update/reload // In Chrome content script is orphaned on an extension update/reload
// so we need to detach event listeners // so we need to detach event listeners
window.removeEventListener(orphanEventId, orphanCheck, true); window.removeEventListener(orphanEventId, orphanCheck, true);
mqDark.onchange = null;
isOrphaned = true; isOrphaned = true;
setTimeout(styleInjector.clear, 1000); // avoiding FOUC styleInjector.clear();
tryCatch(msg.off, applyOnMessage); tryCatch(msg.off, applyOnMessage);
} }
})(); })();

View File

@ -0,0 +1,174 @@
/* global API */// msg.js
'use strict';
(() => {
const manifest = chrome.runtime.getManifest();
const allowedOrigins = [
'https://openusercss.org',
'https://openusercss.com',
];
const sendPostMessage = message => {
if (allowedOrigins.includes(location.origin)) {
window.postMessage(message, location.origin);
}
};
const askHandshake = () => {
// Tell the page that we exist and that it should send the handshake
sendPostMessage({
type: 'ouc-begin-handshake',
});
};
// Listen for queries by the site and respond with a callback object
const sendInstalledCallback = styleData => {
sendPostMessage({
type: 'ouc-is-installed-response',
style: styleData,
});
};
const installedHandler = event => {
if (event.data
&& event.data.type === 'ouc-is-installed'
&& allowedOrigins.includes(event.origin)
) {
API.usercss.find({
name: event.data.name,
namespace: event.data.namespace,
}).then(style => {
const data = {event};
const callbackObject = {
installed: Boolean(style),
enabled: style.enabled,
name: data.name,
namespace: data.namespace,
};
sendInstalledCallback(callbackObject);
});
}
};
const attachInstalledListeners = () => {
window.addEventListener('message', installedHandler);
};
const doHandshake = event => {
// This is a representation of features that Stylus is capable of
const implementedFeatures = [
'install-usercss',
'event:install-usercss',
'event:is-installed',
'configure-after-install',
'builtin-editor',
'create-usercss',
'edit-usercss',
'import-moz-export',
'export-moz-export',
'update-manual',
'update-auto',
'export-json-backups',
'import-json-backups',
'manage-local',
];
const reportedFeatures = [];
// The handshake question includes a list of required and optional features
// we match them with features we have implemented, and build a union array.
event.data.featuresList.required.forEach(feature => {
if (implementedFeatures.includes(feature)) {
reportedFeatures.push(feature);
}
});
event.data.featuresList.optional.forEach(feature => {
if (implementedFeatures.includes(feature)) {
reportedFeatures.push(feature);
}
});
// We send the handshake response, which includes the key we got, plus some
// additional metadata
sendPostMessage({
type: 'ouc-handshake-response',
key: event.data.key,
extension: {
name: manifest.name,
capabilities: reportedFeatures,
},
});
};
const handshakeHandler = event => {
if (event.data
&& event.data.type === 'ouc-handshake-question'
&& allowedOrigins.includes(event.origin)
) {
doHandshake(event);
}
};
const attachHandshakeListeners = () => {
// Wait for the handshake request, then start it
window.addEventListener('message', handshakeHandler);
};
const sendInstallCallback = data => {
// Send an install callback to the site in order to let it know
// we were able to install the theme and it may display a success message
sendPostMessage({
type: 'ouc-install-callback',
key: data.key,
});
};
const installHandler = event => {
if (event.data
&& event.data.type === 'ouc-install-usercss'
&& allowedOrigins.includes(event.origin)
) {
API.usercss.install({
name: event.data.title,
sourceCode: event.data.code,
}).then(style => {
sendInstallCallback({
enabled: style.enabled,
key: event.data.key,
});
});
}
};
const attachInstallListeners = () => {
// Wait for an install event, then save the theme
window.addEventListener('message', installHandler);
};
const orphanCheck = () => {
const eventName = chrome.runtime.id + '-install-hook-openusercss';
const orphanCheckRequest = () => {
// If we can't get the UI language, it means we are orphaned, and should
// remove our event handlers
if (chrome.i18n && chrome.i18n.getUILanguage()) return true;
window.removeEventListener('message', installHandler);
window.removeEventListener('message', handshakeHandler);
window.removeEventListener('message', installedHandler);
window.removeEventListener(eventName, orphanCheckRequest, true);
};
// Send the event before we listen for it, for other possible
// running instances of the content script.
dispatchEvent(new Event(eventName));
addEventListener(eventName, orphanCheckRequest, true);
};
orphanCheck();
attachHandshakeListeners();
attachInstallListeners();
attachInstalledListeners();
askHandshake();
})();

View File

@ -1,324 +1,378 @@
/* global API */// msg.js /* global API msg */// msg.js
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions // eslint-disable-next-line no-unused-expressions
/^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (async () => { /^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (() => {
if (window.INJECTED_USO === 1) return; const styleId = RegExp.$1;
window.INJECTED_USO = 1;
const usoId = RegExp.$1;
const USO = 'https://userstyles.org';
const apiUrl = `${USO}/api/v1/styles/${usoId}`;
const md5Url = `https://update.userstyles.org/${usoId}.md5`;
const CLICK = [
['#install_stylish_style_button', onInstall],
['#update_stylish_style_button', onInstall],
['.customize_style_button', onCustomize],
['.uninstall_stylish_style_button', onUninstall],
];
const pageEventId = `${performance.now()}${Math.random()}`; const pageEventId = `${performance.now()}${Math.random()}`;
const contentEventId = pageEventId + ':';
const orphanEventId = chrome.runtime.id; // id won't be available in the orphaned script
const $ = (sel, base = document) => base.querySelector(sel);
const toggleListener = (isOn, ...args) => (isOn ? addEventListener : removeEventListener)(...args);
const togglePageListener = isOn => toggleListener(isOn, contentEventId, onPageEvent, true);
const mo = new MutationObserver(onMutation); window.dispatchEvent(new CustomEvent(chrome.runtime.id + '-install'));
const observeColors = isOn => window.addEventListener(chrome.runtime.id + '-install', orphanCheck, true);
isOn ? mo.observe(document.body, {subtree: true, attributes: true, attributeFilter: ['value']})
: mo.disconnect();
let style, dup, md5, pageData, badKeys; document.addEventListener('stylishInstallChrome', onClick);
document.addEventListener('stylishUpdateChrome', onClick);
runInPage(inPageContext, pageEventId, contentEventId, usoId, apiUrl); msg.on(onMessage);
addEventListener(orphanEventId, orphanCheck, true);
addEventListener('click', onClick, true);
togglePageListener(true);
[md5, dup] = await Promise.all([ let currentMd5;
fetch(md5Url).then(r => r.text()), const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
API.styles.find({md5Url}, {installationUrl: `https://uso.kkx.one/style/${usoId}`}) Promise.all([
.then(sendVarsToPage), API.styles.find({md5Url}),
document.body || new Promise(resolve => addEventListener('load', resolve, {once: true})), getResource(md5Url),
]); onDOMready(),
]).then(checkUpdatability);
if (!dup) { document.documentElement.appendChild(
sendStylishEvent('styleCanBeInstalledChrome'); Object.assign(document.createElement('script'), {
} else if (dup.originalMd5 && dup.originalMd5 !== md5 || !dup.usercssData || !dup.md5Url) { textContent: `(${inPageContext})('${pageEventId}')`,
// allow update if 1) changed, 2) is a classic USO style, 3) is from USO-archive }));
sendStylishEvent('styleCanBeUpdatedChrome');
} else {
sendStylishEvent('styleAlreadyInstalledChrome');
}
async function onClick(e) { function onMessage(msg) {
for (const [sel, fn] of CLICK) { switch (msg.method) {
const el = e.target.closest(sel); case 'ping':
if (!el) continue; // orphaned content script check
try { return true;
el.disabled = true; case 'openSettings':
await fn(e); openSettings();
} catch (e) { return true;
alert(chrome.i18n.getMessage('styleInstallFailed', e.message || e));
} finally {
el.disabled = false;
}
} }
} }
function onCustomize() { /* since we are using "stylish-code-chrome" meta key on all browsers and
const ss = $('#style-settings'); US.o does not provide "advanced settings" on this url if browser is not Chrome,
const willShow = !ss || !ss.offsetHeight; we need to fix this URL using "stylish-update-url" meta key
observeColors(willShow); */
toggleListener(willShow, 'change', onChange); function getStyleURL() {
const textUrl = getMeta('stylish-update-url') || '';
const jsonUrl = getMeta('stylish-code-chrome') ||
textUrl.replace(/styles\/(\d+)\/[^?]*/, 'styles/chrome/$1.json');
const paramsMissing = !jsonUrl.includes('?') && textUrl.includes('?');
return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : '');
} }
async function onInstall(e) { function checkUpdatability([installedStyle, md5]) {
const {id} = dup; // TODO: remove the following statement when USO is fixed
e.stopPropagation(); document.dispatchEvent(new CustomEvent(pageEventId, {
if (!style) await buildStyle(); detail: installedStyle && installedStyle.updateUrl,
style = dup = await API.usercss.install(style, { }));
dup: {id}, currentMd5 = md5;
vars: getPageVars(), if (!installedStyle) {
}); sendEvent({type: 'styleCanBeInstalledChrome'});
sendStylishEvent('styleInstalledChrome'); return;
API.uso.pingback(id); }
} const isCustomizable = /\?/.test(installedStyle.updateUrl);
const md5Url = getMeta('stylish-md5-url');
if (md5Url && installedStyle.md5Url && installedStyle.originalMd5) {
reportUpdatable(isCustomizable || md5 !== installedStyle.originalMd5);
} else {
getStyleJson().then(json => {
reportUpdatable(
isCustomizable ||
!json ||
!styleSectionsEqual(json, installedStyle));
});
}
function onUninstall() { function prepareInstallButton() {
const {id} = dup; return new Promise(resolve => {
dup = style = false; const observer = new MutationObserver(check);
observeColors(false); observer.observe(document.documentElement, {
removeEventListener('change', onChange); childList: true,
return API.styles.delete(id); subtree: true,
} });
check();
function onChange({target: el}) { function check() {
if (dup && el.matches('[name^="ik-"], [type=file]')) { if (document.querySelector('#install_style_button')) {
API.usercss.configVars(dup.id, getPageVars()); resolve();
observer.disconnect();
}
}
});
}
function reportUpdatable(isUpdatable) {
prepareInstallButton().then(() => {
sendEvent({
type: isUpdatable
? 'styleCanBeUpdatedChrome'
: 'styleAlreadyInstalledChrome',
detail: {
updateUrl: installedStyle.updateUrl,
},
});
});
} }
} }
function onMutation(mutations) {
for (const {target: el} of mutations) { function sendEvent(event) {
if (el.style.display === 'none' && sendEvent.lastEvent = event;
/^ik-/.test(el.name) && let {type, detail = null} = event;
/^#[\da-f]{6}$/.test(el.value)) { if (typeof cloneInto !== 'undefined') {
onChange({target: el}); // Firefox requires explicit cloning, however USO can't process our messages anyway
} // because USO tries to use a global "event" variable deprecated in Firefox
detail = cloneInto({detail}, document); /* global cloneInto */
} else {
detail = {detail};
}
document.dispatchEvent(new CustomEvent(type, detail));
}
function onClick(event) {
if (onClick.processing || !orphanCheck()) {
return;
}
onClick.processing = true;
doInstall()
.then(() => {
if (!event.type.includes('Update')) {
// FIXME: sometimes the button is broken i.e. the button sends
// 'install' instead of 'update' event while the style is already
// install.
// This triggers an incorrect install count but we don't really care.
return getResource(getMeta('stylish-install-ping-url-chrome'));
}
})
.catch(console.error)
.then(done);
function done() {
setTimeout(() => {
onClick.processing = false;
});
} }
} }
function onPageEvent(e) { function doInstall() {
pageData = e.detail; let oldStyle;
togglePageListener(false); return API.styles.find({
md5Url: getMeta('stylish-md5-url') || location.href,
})
.then(_oldStyle => {
oldStyle = _oldStyle;
return oldStyle ?
oldStyle.name :
getResource(getMeta('stylish-description'));
})
.then(name => {
const props = {};
if (oldStyle) {
props.id = oldStyle.id;
}
return saveStyleCode(oldStyle ? 'styleUpdate' : 'styleInstall', name, props);
});
} }
async function buildStyle() { async function saveStyleCode(message, name, addProps = {}) {
if (!pageData) pageData = await (await fetch(apiUrl)).json(); const isNew = message === 'styleInstall';
({style, badKeys} = await API.uso.toUsercss(pageData, {varsUrl: dup.updateUrl})); const needsConfirmation = isNew || !saveStyleCode.confirmed;
Object.assign(style, { if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
md5Url, return Promise.reject();
id: dup.id, }
originalMd5: md5, saveStyleCode.confirmed = true;
updateUrl: apiUrl, enableUpdateButton(false);
}); const json = await getStyleJson();
} if (!json) {
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
'https://github.com/openstyles/stylus/issues/195');
return;
}
// Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5}));
if (!isNew && style.updateUrl.includes('?')) {
enableUpdateButton(true);
} else {
sendEvent({type: 'styleInstalledChrome'});
}
function getPageVars() { function enableUpdateButton(state) {
const {vars} = (style || dup).usercssData; const important = s => s.replace(/;/g, '!important;');
for (const el of document.querySelectorAll('[name^="ik-"]')) { const button = document.getElementById('update_style_button');
const name = el.name.slice(3); // dropping "ik-" if (button) {
const ik = (badKeys || {})[name] || name; button.style.cssText = state ? '' : important('pointer-events: none; opacity: .35;');
const v = vars[ik] || false; const icon = button.querySelector('img[src*=".svg"]');
const isImage = el.type === 'radio'; if (icon) {
if (v && (!isImage || el.checked)) { icon.style.cssText = state ? '' : important('transition: transform 5s; transform: rotate(0);');
const val = el.value; if (state) {
const isFile = val === 'user-upload'; setTimeout(() => (icon.style.cssText += important('transform: rotate(10turn);')));
if (isImage && (isFile || val === 'user-url')) { }
const el2 = $(`[type=${isFile ? 'file' : 'url'}]`, el.parentElement);
const ikCust = `${ik}-custom`;
v.value = `${ikCust}-dropdown`;
vars[ikCust].value = isFile ? getFileUriFromPage(el2) : el2.value;
} else {
v.value = v.type === 'select' ? val.replace(/^ik-/, '') : val;
} }
} }
} }
return vars;
} }
function getFileUriFromPage(el) { function getMeta(name) {
togglePageListener(true); const e = document.querySelector(`link[rel="${name}"]`);
sendPageEvent(el); return e ? e.getAttribute('href') : null;
return pageData;
} }
function runInPage(fn, ...args) { async function getResource(url, opts) {
const div = document.createElement('div'); try {
div.attachShadow({mode: 'closed'}) return url.startsWith('#')
.appendChild(document.createElement('script')) ? document.getElementById(url.slice(1)).textContent
.textContent = `(${fn})(${JSON.stringify(args).slice(1, -1)})`; : await API.download(url, opts);
document.documentElement.appendChild(div).remove(); } catch (error) {
} alert('Error\n' + error.message);
return Promise.reject(error);
function sendPageEvent(data) { }
dispatchEvent(data instanceof Node }
? new MouseEvent(pageEventId, {relatedTarget: data})
: new CustomEvent(pageEventId, {detail: data})); // USO providing md5Url as "https://update.update.userstyles.org/#####.md5"
//* global cloneInto */// WARNING! Firefox requires cloning of an object `detail` // instead of "https://update.userstyles.org/#####.md5"
} async function getStyleJson() {
try {
function sendStylishEvent(type) { const style = await getResource(getStyleURL(), {responseType: 'json'});
document.dispatchEvent(new Event(type)); const codeElement = document.getElementById('stylish-code');
} if (!style || !Array.isArray(style.sections) || style.sections.length ||
codeElement && !codeElement.textContent.trim()) {
function sendVarsToPage(style) { return style;
if (style) { }
const vars = (style.usercssData || {}).vars || `${style.updateUrl}`.split('?')[1]; const code = await getResource(getMeta('stylish-update-url'));
if (vars) sendPageEvent('vars:' + JSON.stringify(vars)); style.sections = (await API.worker.parseMozFormat({code})).sections;
if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update');
return style;
} catch (e) {}
}
/**
* The sections are checked in successive order because it matters when many sections
* match the same URL and they have rules with the same CSS specificity
* @param {Object} a - first style object
* @param {Object} b - second style object
* @returns {?boolean}
*/
function styleSectionsEqual({sections: a}, {sections: b}) {
const targets = ['urls', 'urlPrefixes', 'domains', 'regexps'];
return a && b && a.length === b.length && a.every(sameSection);
function sameSection(secA, i) {
return equalOrEmpty(secA.code, b[i].code, 'string', (a, b) => a === b) &&
targets.every(target => equalOrEmpty(secA[target], b[i][target], 'array', arrayMirrors));
}
function equalOrEmpty(a, b, type, comparator) {
const typeA = type === 'array' ? Array.isArray(a) : typeof a === type;
const typeB = type === 'array' ? Array.isArray(b) : typeof b === type;
return typeA && typeB && comparator(a, b) ||
(a == null || typeA && !a.length) &&
(b == null || typeB && !b.length);
}
function arrayMirrors(a, b) {
return a.length === b.length &&
a.every(el => b.includes(el)) &&
b.every(el => a.includes(el));
}
}
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true}));
}
function openSettings(countdown = 10e3) {
const button = document.querySelector('.customize_button');
if (button) {
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
setTimeout(function pollArea(countdown = 2000) {
const area = document.getElementById('advancedsettings_area');
if (area || countdown < 0) {
(area || button).scrollIntoView({behavior: 'smooth', block: area ? 'end' : 'center'});
} else {
setTimeout(pollArea, 100, countdown - 100);
}
}, 500);
} else if (countdown > 0) {
setTimeout(openSettings, 100, countdown - 100);
} }
return style || false;
} }
function orphanCheck() { function orphanCheck() {
if (chrome.runtime.id) return true; try {
removeEventListener(orphanEventId, orphanCheck, true); if (chrome.i18n.getUILanguage()) {
removeEventListener('click', onClick, true); return true;
removeEventListener('change', onChange); }
sendPageEvent('quit'); } catch (e) {}
observeColors(false); // In Chrome content script is orphaned on an extension update/reload
togglePageListener(false); // so we need to detach event listeners
window.removeEventListener(chrome.runtime.id + '-install', orphanCheck, true);
document.removeEventListener('stylishInstallChrome', onClick);
document.removeEventListener('stylishUpdateChrome', onClick);
try {
msg.off(onMessage);
} catch (e) {}
} }
})(); })();
function inPageContext(eventId, eventIdHost, styleId, apiUrl) { function inPageContext(eventId) {
let done, orphaned, vars; document.currentScript.remove();
// `chrome` may be empty if no extensions use externally_connectable but USO needs it
if (!window.chrome) window.chrome = {runtime: {sendMessage: () => {}}};
const EXT_ID = 'fjnbnpbmkenffdnngjfgmeleoegfcffe';
const {defineProperty} = Object;
const {dispatchEvent, CustomEvent, removeEventListener} = window;
const apply = Map.call.bind(Map.apply);
const OVR = [
[chrome.runtime, 'sendMessage', (fn, me, args) => {
const [id, /*msg*/, opts, cb = opts] = args;
if (id !== EXT_ID) return apply(fn, me, args);
if (typeof cb !== 'function') return Promise.resolve(true);
cb(true);
}],
[Response.prototype, 'json', async (fn, me, args) => {
const res = await apply(fn, me, args);
try {
if (!done && me.url === apiUrl) {
done = true;
send(res);
setVars(res);
}
} catch (e) {}
return res;
}],
[window, 'fetch', (fn, me, args) =>
args[0] === `chrome-extension://${EXT_ID}/index.html`
? Promise.resolve(new Response('<!doctype html><html lang="en"></html>'))
: apply(fn, me, args),
],
];
OVR.forEach(([obj, name, caller], i) => {
const orig = obj[name];
const ovr = new Proxy(orig, {
apply(fn, me, args) {
if (orphaned) restore(obj, name, ovr, fn);
return (orphaned ? apply : caller)(fn, me, args);
},
});
defineProperty(obj, name, {value: ovr});
OVR[i] = [obj, name, ovr, orig]; // same args as restore()
});
/* We set `isInstalled` at page start intentionally not trying to replicate Stylish login events.
* This difference allows USO site to detect presence of Stylus (or another similar extension). */
window.isInstalled = true; window.isInstalled = true;
addEventListener(eventId, onCommand, true); const origMethods = {
function onCommand(e) { json: Response.prototype.json,
if (e.detail === 'quit') { byId: document.getElementById,
removeEventListener(eventId, onCommand, true); };
OVR.forEach(restore); let vars;
done = orphaned = true; // USO bug workaround: prevent errors in console after install and busy cursor
} else if (/^vars:/.test(e.detail)) { document.getElementById = id =>
vars = JSON.parse(e.detail.slice(5)); origMethods.byId.call(document, id) ||
} else if (e.relatedTarget) { (/^(stylish-code|stylish-installed-style-installed-\w+|post-install-ad|style-install-unknown)$/.test(id)
send(e.relatedTarget.uploadedData); ? Object.assign(document.createElement('p'), {className: 'afterdownload-ad'})
} : null);
} // USO bug workaround: use the actual image data in customized settings
function restore(obj, name, ovr, orig) { // same order as OVR after patching document.addEventListener(eventId, ({detail}) => {
if (obj[name] === ovr) { vars = /\?/.test(detail) && new URL(detail).searchParams;
defineProperty(obj, name, {value: orig}); if (!vars) Response.prototype.json = origMethods.json;
} }, {once: true});
} Response.prototype.json = async function () {
function send(data) { const json = await origMethods.json.apply(this, arguments);
dispatchEvent(new CustomEvent(eventIdHost, {__proto: null, detail: data})); if (vars && json && Array.isArray(json.style_settings)) {
} Response.prototype.json = origMethods.json;
function setVars(json) { const images = new Map();
const images = new Map(); for (const ss of json.style_settings) {
const isNew = typeof vars === 'object'; let value = vars.get('ik-' + ss.install_key);
const badKeys = {}; if (!value || !(ss.style_setting_options || [])[0]) {
const newKeys = []; continue;
const makeKey = ({install_key: key}) => {
let res = isNew ? badKeys[key] : key;
if (!res) {
res = key.replace(/[^-\w]/g, '-');
res += newKeys.includes(res) ? '-' : '';
if (key !== res) {
badKeys[key] = res;
newKeys.push(res);
} }
} if (value.startsWith('ik-')) {
return res; value = value.replace(/^ik-/, '');
}; const def = ss.style_setting_options.find(item => item.default);
if (!isNew) vars = new URLSearchParams(vars); if (!def || def.install_key !== value) {
for (const ss of json.style_settings || []) { if (def) def.default = false;
const ik = makeKey(ss); for (const item of ss.style_setting_options) {
let value = isNew ? (vars[ik] || {}).value : vars.get('ik-' + ik); if (item.install_key === value) {
if (value == null || !(ss.style_setting_options || [])[0]) { item.default = true;
continue; break;
} }
if (ss.setting_type === 'image') {
let isListed;
for (const opt of ss.style_setting_options) {
isListed |= opt.default = (opt.install_key === value);
}
images.set(ik, {url: isNew && !isListed ? vars[`${ik}-custom`].value : value, isListed});
} else if (value.startsWith('ik-') || isNew && vars[ik].type === 'select') {
value = value.replace(/^ik-/, '');
const def = ss.style_setting_options.find(item => item.default);
if (!def || makeKey(def) !== value) {
if (def) def.default = false;
for (const item of ss.style_setting_options) {
if (makeKey(item) === value) {
item.default = true;
break;
} }
} }
} else if (ss.setting_type === 'image') {
let isListed;
for (const opt of ss.style_setting_options) {
isListed |= opt.default = (opt.value === value);
}
images.set(ss.install_key, {url: value, isListed});
} else {
const item = ss.style_setting_options[0];
if (item.value !== value && item.install_key === 'placeholder') {
item.value = value;
}
} }
} else { }
const item = ss.style_setting_options[0]; if (images.size) {
if (item.value !== value && item.install_key === 'placeholder') { new MutationObserver((_, observer) => {
item.value = value; if (document.getElementById('style-settings')) {
} observer.disconnect();
for (const [name, {url, isListed}] of images) {
const elRadio = document.querySelector(`input[name="ik-${name}"][value="user-url"]`);
const elUrl = elRadio &&
document.getElementById(elRadio.id.replace('url-choice', 'user-url'));
if (elUrl) {
elRadio.checked = !isListed;
elUrl.value = url;
}
}
}
}).observe(document, {childList: true, subtree: true});
} }
} }
if (!images.size) return; return json;
new MutationObserver((_, observer) => { };
if (!document.getElementById('style-settings')) return;
observer.disconnect();
for (const [name, {url, isListed}] of images) {
const elRadio = document.querySelector(`input[name="ik-${name}"][value="user-url"]`);
const elUrl = elRadio && document.getElementById(elRadio.id.replace('url-choice', 'user-url'));
if (elUrl) {
elRadio.checked = !isListed;
elUrl.value = url;
}
}
}).observe(document, {childList: true, subtree: true});
}
} }

View File

@ -1,43 +0,0 @@
/* global API */// msg.js
'use strict';
(() => {
const ORIGIN = 'https://userstyles.world';
const HANDLERS = Object.assign(Object.create(null), {
async 'usw-ready'() {
send({type: 'usw-remove-stylus-button'});
if (location.pathname === '/api/oauth/style/new') {
const styleId = Number(new URLSearchParams(location.search).get('vendor_data'));
const data = await API.data.pop('usw' + styleId);
send({type: 'usw-fill-new-style', data});
}
},
async 'usw-style-info-request'(data) {
switch (data.requestType) {
case 'installed': {
const updateUrl = `${ORIGIN}/api/style/${data.styleID}.user.css`;
const style = await API.styles.find({updateUrl});
send({
type: 'usw-style-info-response',
data: {installed: Boolean(style), requestType: 'installed'},
});
break;
}
}
},
});
window.addEventListener('message', ({data, source, origin}) => {
// Some browsers don't reveal `source` to extensions e.g. Firefox
if (data && (source ? source === window : origin === ORIGIN)) {
const fn = HANDLERS[data.type];
if (fn) fn(data);
}
});
function send(msg) {
window.postMessage(msg, ORIGIN);
}
})();

View File

@ -9,14 +9,12 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
const PATCH_ID = 'transition-patch'; const PATCH_ID = 'transition-patch';
// styles are out of order if any of these elements is injected between them // styles are out of order if any of these elements is injected between them
const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']); const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']);
const docRewriteObserver = RewriteObserver(sort); const docRewriteObserver = RewriteObserver(_sort);
const docRootObserver = RootObserver(sortIfNeeded); const docRootObserver = RootObserver(_sortIfNeeded);
const toSafeChar = c => String.fromCharCode(0xFF00 + c.charCodeAt(0) - 0x20);
const list = []; const list = [];
const table = new Map(); const table = new Map();
let isEnabled = true; let isEnabled = true;
let isTransitionPatched = chrome.app && CSS.supports('accent-color', 'red'); // Chrome 93 let isTransitionPatched;
let exposeStyleName;
// will store the original method refs because the page can override them // will store the original method refs because the page can override them
let creationDoc, createElement, createElementNS; let creationDoc, createElement, createElementNS;
@ -25,24 +23,24 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
list, list,
async apply(styleMap) { async apply(styleMap) {
const styles = styleMapToArray(styleMap); const styles = _styleMapToArray(styleMap);
const value = !styles.length const value = !styles.length
? [] ? []
: await docRootObserver.evade(() => { : await docRootObserver.evade(() => {
if (!isTransitionPatched && isEnabled) { if (!isTransitionPatched && isEnabled) {
applyTransitionPatch(styles); _applyTransitionPatch(styles);
} }
return styles.map(addUpdate); return styles.map(_addUpdate);
}); });
emitUpdate(); _emitUpdate();
return value; return value;
}, },
clear() { clear() {
addRemoveElements(false); _addRemoveElements(false);
list.length = 0; list.length = 0;
table.clear(); table.clear();
emitUpdate(); _emitUpdate();
}, },
clearOrphans() { clearOrphans() {
@ -55,12 +53,12 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
}, },
remove(id) { remove(id) {
remove(id); _remove(id);
emitUpdate(); _emitUpdate();
}, },
replace(styleMap) { replace(styleMap) {
const styles = styleMapToArray(styleMap); const styles = _styleMapToArray(styleMap);
const added = new Set(styles.map(s => s.id)); const added = new Set(styles.map(s => s.id));
const removed = []; const removed = [];
for (const style of list) { for (const style of list) {
@ -68,24 +66,22 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
removed.push(style.id); removed.push(style.id);
} }
} }
styles.forEach(addUpdate); styles.forEach(_addUpdate);
removed.forEach(remove); removed.forEach(_remove);
emitUpdate(); _emitUpdate();
}, },
toggle(enable) { toggle(enable) {
if (isEnabled === enable) return; if (isEnabled === enable) return;
isEnabled = enable; isEnabled = enable;
if (!enable) toggleObservers(false); if (!enable) _toggleObservers(false);
addRemoveElements(enable); _addRemoveElements(enable);
if (enable) toggleObservers(true); if (enable) _toggleObservers(true);
}, },
sort: sort,
}; };
function add(style) { function _add(style) {
const el = style.el = createStyle(style); const el = style.el = _createStyle(style.id, style.code);
const i = list.findIndex(item => compare(item, style) > 0); const i = list.findIndex(item => compare(item, style) > 0);
table.set(style.id, style); table.set(style.id, style);
if (isEnabled) { if (isEnabled) {
@ -95,7 +91,7 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
return el; return el;
} }
function addRemoveElements(add) { function _addRemoveElements(add) {
for (const {el} of list) { for (const {el} of list) {
if (add) { if (add) {
document.documentElement.appendChild(el); document.documentElement.appendChild(el);
@ -105,11 +101,11 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
} }
} }
function addUpdate(style) { function _addUpdate(style) {
return table.has(style.id) ? update(style) : add(style); return table.has(style.id) ? _update(style) : _add(style);
} }
function applyTransitionPatch(styles) { function _applyTransitionPatch(styles) {
isTransitionPatched = true; isTransitionPatched = true;
// CSS transition bug workaround: since we insert styles asynchronously, // CSS transition bug workaround: since we insert styles asynchronously,
// the browsers, especially Firefox, may apply all transitions on page load // the browsers, especially Firefox, may apply all transitions on page load
@ -118,20 +114,19 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
!styles.some(s => s.code.includes('transition'))) { !styles.some(s => s.code.includes('transition'))) {
return; return;
} }
const el = createStyle({id: PATCH_ID, code: ` const el = _createStyle(PATCH_ID, `
:root:not(#\\0):not(#\\0) * { :root:not(#\\0):not(#\\0) * {
transition: none !important; transition: none !important;
} }
`}); `);
document.documentElement.appendChild(el); document.documentElement.appendChild(el);
// wait for the next paint to complete // wait for the next paint to complete
// note: requestAnimationFrame won't fire in inactive tabs // note: requestAnimationFrame won't fire in inactive tabs
requestAnimationFrame(() => setTimeout(() => el.remove())); requestAnimationFrame(() => setTimeout(() => el.remove()));
} }
function createStyle(style = {}) { function _createStyle(id, code = '') {
const {id} = style; if (!creationDoc) _initCreationDoc();
if (!creationDoc) initCreationDoc();
let el; let el;
if (document.documentElement instanceof SVGSVGElement) { if (document.documentElement instanceof SVGSVGElement) {
// SVG document style // SVG document style
@ -151,27 +146,18 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
el.type = 'text/css'; el.type = 'text/css';
// SVG className is not a string, but an instance of SVGAnimatedString // SVG className is not a string, but an instance of SVGAnimatedString
el.classList.add('stylus'); el.classList.add('stylus');
setTextAndName(el, style); el.textContent = code;
return el; return el;
} }
function setTextAndName(el, {id, code = '', name}) { function _toggleObservers(shouldStart) {
if (exposeStyleName && name) {
el.dataset.name = name;
name = encodeURIComponent(name.replace(/[?#/']/g, toSafeChar));
code += `\n/*# sourceURL=${chrome.runtime.getURL(name)}.user.css#${id} */`;
}
el.textContent = code;
}
function toggleObservers(shouldStart) {
const onOff = shouldStart && isEnabled ? 'start' : 'stop'; const onOff = shouldStart && isEnabled ? 'start' : 'stop';
docRewriteObserver[onOff](); docRewriteObserver[onOff]();
docRootObserver[onOff](); docRootObserver[onOff]();
} }
function emitUpdate() { function _emitUpdate() {
toggleObservers(list.length); _toggleObservers(list.length);
onUpdate(); onUpdate();
} }
@ -182,11 +168,11 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
and since userAgent.navigator can be spoofed via about:config or devtools, and since userAgent.navigator can be spoofed via about:config or devtools,
we're checking for getPreventDefault that was removed in FF59 we're checking for getPreventDefault that was removed in FF59
*/ */
function initCreationDoc() { function _initCreationDoc() {
creationDoc = !Event.prototype.getPreventDefault && document.wrappedJSObject; creationDoc = !Event.prototype.getPreventDefault && document.wrappedJSObject;
if (creationDoc) { if (creationDoc) {
({createElement, createElementNS} = creationDoc); ({createElement, createElementNS} = creationDoc);
const el = document.documentElement.appendChild(createStyle()); const el = document.documentElement.appendChild(_createStyle());
const isApplied = el.sheet; const isApplied = el.sheet;
el.remove(); el.remove();
if (isApplied) return; if (isApplied) return;
@ -195,7 +181,7 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
({createElement, createElementNS} = document); ({createElement, createElementNS} = document);
} }
function remove(id) { function _remove(id) {
const style = table.get(id); const style = table.get(id);
if (!style) return; if (!style) return;
table.delete(id); table.delete(id);
@ -203,14 +189,14 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
style.el.remove(); style.el.remove();
} }
function sort() { function _sort() {
docRootObserver.evade(() => { docRootObserver.evade(() => {
list.sort(compare); list.sort(compare);
addRemoveElements(true); _addRemoveElements(true);
}); });
} }
function sortIfNeeded() { function _sortIfNeeded() {
let needsSort; let needsSort;
let el = list.length && list[0].el; let el = list.length && list[0].el;
if (!el) { if (!el) {
@ -231,29 +217,22 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
// some styles are not injected to the document // some styles are not injected to the document
if (i < list.length) needsSort = true; if (i < list.length) needsSort = true;
} }
if (needsSort) sort(); if (needsSort) _sort();
return needsSort; return needsSort;
} }
function styleMapToArray(styleMap) { function _styleMapToArray(styleMap) {
if (styleMap.cfg) { return Object.values(styleMap).map(s => ({
({exposeStyleName} = styleMap.cfg); id: s.id,
delete styleMap.cfg; code: s.code.join(''),
}
return Object.values(styleMap).map(({id, code, name}) => ({
id,
name,
code: code.join(''),
})); }));
} }
function update(newStyle) { function _update({id, code}) {
const {id, code} = newStyle;
const style = table.get(id); const style = table.get(id);
if (style.code !== code || if (style.code !== code) {
style.name !== newStyle.name && exposeStyleName) {
style.code = code; style.code = code;
setTextAndName(style.el, newStyle); style.el.textContent = code;
} }
} }
@ -262,14 +241,14 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
let root; let root;
let observing = false; let observing = false;
let timer; let timer;
const observer = new MutationObserver(check); const observer = new MutationObserver(_check);
return {start, stop}; return {start, stop};
function start() { function start() {
if (observing) return; if (observing) return;
// detect dynamic iframes rewritten after creation by the embedder i.e. externally // detect dynamic iframes rewritten after creation by the embedder i.e. externally
root = document.documentElement; root = document.documentElement;
timer = setTimeout(check); timer = setTimeout(_check);
observer.observe(document, {childList: true}); observer.observe(document, {childList: true});
observing = true; observing = true;
} }
@ -281,7 +260,7 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
observing = false; observing = false;
} }
function check() { function _check() {
if (root !== document.documentElement) { if (root !== document.documentElement) {
root = document.documentElement; root = document.documentElement;
onChange(); onChange();
@ -311,7 +290,7 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
function evade(fn) { function evade(fn) {
const restore = observing && start; const restore = observing && start;
stop(); stop();
return new Promise(resolve => run(fn, resolve, waitForRoot)) return new Promise(resolve => _run(fn, resolve, _waitForRoot))
.then(restore); .then(restore);
} }
@ -329,7 +308,7 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
observing = false; observing = false;
} }
function run(fn, resolve, wait) { function _run(fn, resolve, wait) {
if (document.documentElement) { if (document.documentElement) {
resolve(fn()); resolve(fn());
return true; return true;
@ -337,8 +316,8 @@ window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
if (wait) wait(fn, resolve); if (wait) wait(fn, resolve);
} }
function waitForRoot(...args) { function _waitForRoot(...args) {
new MutationObserver((_, observer) => run(...args) && observer.disconnect()) new MutationObserver((_, observer) => _run(...args) && observer.disconnect())
.observe(document, {childList: true}); .observe(document, {childList: true});
} }
} }

242
edit.html
View File

@ -1,12 +1,22 @@
<!DOCTYPE html>
<html id="stylus"> <html id="stylus">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="global.css" rel="stylesheet"> <link href="global.css" rel="stylesheet">
<link href="global-dark.css" rel="stylesheet">
<style id="cm-theme"></style> <style id="firefox-transitions-bug-suppressor">
/* restrict to FF */
@supports (-moz-appearance:none) {
/* increased specificity to override sane selectors in user styles */
html#stylus.firefox #stylus-edit #header *,
html#stylus.firefox #stylus-edit #sections * {
transition: none !important;
}
}
</style>
<link id="cm-theme" rel="stylesheet">
<script src="js/polyfill.js"></script> <script src="js/polyfill.js"></script>
<script src="js/toolbox.js"></script> <script src="js/toolbox.js"></script>
@ -18,8 +28,6 @@
<script src="content/apply.js"></script> <script src="content/apply.js"></script>
<script src="js/sections-util.js"></script> <script src="js/sections-util.js"></script>
<script src="js/storage-util.js"></script>
<script src="edit/codemirror-themes.js"></script> <!-- must precede base.js -->
<script src="edit/base.js"></script> <script src="edit/base.js"></script>
<script src="vendor/codemirror/lib/codemirror.js"></script> <script src="vendor/codemirror/lib/codemirror.js"></script>
@ -41,18 +49,19 @@
<script src="vendor/codemirror/addon/lint/lint.js"></script> <script src="vendor/codemirror/addon/lint/lint.js"></script>
<script src="vendor/codemirror/addon/hint/show-hint.js"></script> <script src="vendor/codemirror/addon/hint/show-hint.js"></script>
<script src="vendor/codemirror/addon/hint/css-hint.js"></script> <script src="vendor/codemirror/addon/hint/css-hint.js"></script>
<script src="vendor/codemirror/keymap/emacs.js"></script>
<script src="vendor/codemirror/keymap/sublime.js"></script> <script src="vendor/codemirror/keymap/sublime.js"></script>
<script src="vendor/codemirror/keymap/vim.js"></script>
<script src="vendor-overwrites/codemirror-addon/match-highlighter.js"></script> <script src="vendor-overwrites/codemirror-addon/match-highlighter.js"></script>
<script src="vendor/lz-string-unsafe/lz-string-unsafe.min.js"></script>
<script src="js/color/color-converter.js"></script> <script src="js/color/color-converter.js"></script>
<script src="js/color/color-mimicry.js"></script> <script src="js/color/color-mimicry.js"></script>
<script src="js/color/color-picker.js"></script> <script src="js/color/color-picker.js"></script>
<script src="js/color/color-view.js"></script> <script src="js/color/color-view.js"></script>
<script src="js/storage-util.js"></script>
<script src="js/worker-util.js"></script> <script src="js/worker-util.js"></script>
<script src="edit/util.js"></script> <script src="edit/util.js"></script>
<script src="edit/codemirror-themes.js"></script>
<script src="edit/codemirror-default.js"></script> <script src="edit/codemirror-default.js"></script>
<script src="edit/codemirror-factory.js"></script> <script src="edit/codemirror-factory.js"></script>
<script src="edit/moz-section-finder.js"></script> <script src="edit/moz-section-finder.js"></script>
@ -62,25 +71,25 @@
<script src="edit/source-editor.js"></script> <script src="edit/source-editor.js"></script>
<script src="edit/sections-editor-section.js"></script> <script src="edit/sections-editor-section.js"></script>
<script src="edit/sections-editor.js"></script> <script src="edit/sections-editor.js"></script>
<script src="edit/usw-integration.js"></script> <script src="edit/edit.js"></script>
<template data-id="appliesTo"> <template data-id="appliesTo">
<li class="applies-to-item"> <li class="applies-to-item">
<div class="select-resizer"> <div class="select-resizer">
<select name="applies-type" class="applies-type style-contributor"> <select name="applies-type" class="applies-type style-contributor">
<option value="url" i18n="appliesUrlOption"></option> <option value="url" i18n-text="appliesUrlOption"></option>
<option value="url-prefix" i18n="appliesUrlPrefixOption"></option> <option value="url-prefix" i18n-text="appliesUrlPrefixOption"></option>
<option value="domain" i18n="appliesDomainOption"></option> <option value="domain" i18n-text="appliesDomainOption"></option>
<option value="regexp" i18n="appliesRegexpOption"></option> <option value="regexp" i18n-text="appliesRegexpOption"></option>
</select> </select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg> <svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div> </div>
<div class="applies-value-wrapper"> <div class="applies-value-wrapper">
<input name="applies-value" class="applies-value style-contributor" spellcheck="false"> <input name="applies-value" class="applies-value style-contributor" spellcheck="false">
<a class="remove-applies-to" i18n="appliesRemove, title:appliesRemove" tabindex="0"> <a class="remove-applies-to" href="#" i18n-text="appliesRemove" i18n-title="appliesRemove">
<svg class="svg-icon remove"><use xlink:href="#svg-icon-minus"/></svg> <svg class="svg-icon remove"><use xlink:href="#svg-icon-minus"/></svg>
</a> </a>
<a class="add-applies-to" i18n="appliesAdd, title:appliesAdd" tabindex="0"> <a class="add-applies-to" href="#" i18n-text="appliesAdd" i18n-title="appliesAdd">
<svg class="svg-icon add"><use xlink:href="#svg-icon-plus"/></svg> <svg class="svg-icon add"><use xlink:href="#svg-icon-plus"/></svg>
</a> </a>
</div> </div>
@ -88,8 +97,8 @@
</template> </template>
<template data-id="appliesToEverything"> <template data-id="appliesToEverything">
<li class="applies-to-everything" i18n="appliesToEverything"> <li class="applies-to-everything" i18n-text="appliesToEverything">
<a class="add-applies-to" i18n="appliesAdd, title:appliesAdd" tabindex="0"> <a class="add-applies-to" i18n-text="appliesAdd" i18n-title="appliesAdd" href="#">
<svg class="svg-icon add"><use xlink:href="#svg-icon-plus"/></svg> <svg class="svg-icon add"><use xlink:href="#svg-icon-plus"/></svg>
</a> </a>
</li> </li>
@ -99,25 +108,25 @@
<div class="section"> <div class="section">
<!-- not using DIV to make our CSS work for #sections > div:only-of-type .remove-section --> <!-- not using DIV to make our CSS work for #sections > div:only-of-type .remove-section -->
<p class="deleted-section"> <p class="deleted-section">
<button class="restore-section" i18n="sectionRestore"></button> <button class="restore-section" i18n-text="sectionRestore"></button>
</p> </p>
<label i18n="sectionCode" class="code-label"></label> <label i18n-text="sectionCode" class="code-label"></label>
<div class="applies-to"> <div class="applies-to">
<label i18n="appliesLabel, title:appliesHelp" data-cmd="note"> <label i18n-text="appliesLabel">
<a class="svg-inline-wrapper applies-to-help" tabindex="0"> <a href="#" class="svg-inline-wrapper applies-to-help" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg> <svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a> </a>
</label> </label>
<ul class="applies-to-list"></ul> <ul class="applies-to-list"></ul>
</div> </div>
<div class="edit-actions"> <div class="edit-actions">
<button class="remove-section" i18n="sectionRemove"></button> <button class="remove-section" i18n-text="sectionRemove"></button>
<button class="add-section" i18n="long-text:sectionAdd, short-text:genericAdd"></button> <button class="add-section" i18n-long-text="sectionAdd" i18n-short-text="genericAdd"></button>
<button class="clone-section" i18n="genericClone"></button> <button class="clone-section" i18n-text="genericClone"></button>
<button class="move-section-up"></button> <button class="move-section-up"></button>
<button class="move-section-down"></button> <button class="move-section-down"></button>
<button class="beautify-section" i18n="styleBeautify"></button> <button class="beautify-section" i18n-text="styleBeautify"></button>
<button class="test-regexp" i18n="genericTest"></button> <button class="test-regexp" i18n-text="styleRegexpTestButton"></button>
</div> </div>
</div> </div>
</template> </template>
@ -127,27 +136,27 @@
<div data-type="main"> <div data-type="main">
<div data-type="content"></div> <div data-type="content"></div>
<div data-type="actions"> <div data-type="actions">
<a data-action="case" i18n="title:searchCaseSensitive" tabindex="0">Aa</a> <a data-action="case" i18n-title="searchCaseSensitive" href="#" tabindex="0">Aa</a>
<a data-action="prev" i18n="title:genericPrevious" data-hotkey-tooltip="findPrev" tabindex="0"> <a data-action="prev" i18n-title="genericPrevious" href="#" data-hotkey-tooltip="findPrev" tabindex="0">
<svg class="svg-icon" style="transform: rotate(180deg)"><use xlink:href="#svg-icon-v"/></svg> <svg class="svg-icon" style="transform: rotate(180deg)"><use xlink:href="#svg-icon-v"/></svg>
</a> </a>
<a data-action="next" i18n="title:genericNext" data-hotkey-tooltip="findNext" tabindex="0"> <a data-action="next" i18n-title="genericNext" href="#" data-hotkey-tooltip="findNext" tabindex="0">
<svg class="svg-icon"><use xlink:href="#svg-icon-v"/></svg> <svg class="svg-icon"><use xlink:href="#svg-icon-v"/></svg>
</a> </a>
<a data-action="close" i18n="title:confirmClose" data-hotkey-tooltip="=Esc" tabindex="0"> <a data-action="close" i18n-title="confirmClose" href="#" data-hotkey-tooltip="=Esc" tabindex="0">
<svg class="svg-icon dismiss"><use xlink:href="#svg-icon-close"/></svg> <svg class="svg-icon dismiss"><use xlink:href="#svg-icon-close"/></svg>
</a> </a>
</div> </div>
</div> </div>
<div data-type="status"> <div data-type="status">
<div class="CodeMirror-search-hint" i18n-text="searchRegexp"></div> <div class="CodeMirror-search-hint" i18n-text="searchRegexp"></div>
<div data-type="tally" i18n="title:searchNumberOfResults"></div> <div data-type="tally" i18n-title="searchNumberOfResults"></div>
</div> </div>
</div> </div>
</template> </template>
<template data-id="clearSearch"> <template data-id="clearSearch">
<div data-type="hover" i18n="title:confirmDelete"> <div data-type="hover" i18n-title="confirmDelete">
<svg data-action="clear" class="svg-icon"><use xlink:href="#svg-icon-close"></use></svg> <svg data-action="clear" class="svg-icon"><use xlink:href="#svg-icon-close"></use></svg>
</div> </div>
</template> </template>
@ -156,7 +165,7 @@
<div data-type="content"> <div data-type="content">
<div data-type="input-wrapper"> <div data-type="input-wrapper">
<textarea class="CodeMirror-search-field" rows="1" spellcheck="false" required <textarea class="CodeMirror-search-field" rows="1" spellcheck="false" required
i18n="placeholder:search"></textarea> i18n-placeholder="search"></textarea>
</div> </div>
</div> </div>
</template> </template>
@ -165,36 +174,36 @@
<div data-type="content"> <div data-type="content">
<div data-type="input-wrapper"> <div data-type="input-wrapper">
<textarea data-type="replace-from" <textarea data-type="replace-from"
i18n="placeholder:replace" i18n-placeholder="replace"
class="CodeMirror-search-field" rows="1" required class="CodeMirror-search-field" rows="1" required
spellcheck="false"></textarea> spellcheck="false"></textarea>
</div> </div>
<div data-type="input-wrapper"> <div data-type="input-wrapper">
<textarea data-type="replace-to" <textarea data-type="replace-to"
i18n="placeholder:replaceWith" i18n-placeholder="replaceWith"
class="CodeMirror-search-field" rows="1" required class="CodeMirror-search-field" rows="1" required
spellcheck="false"></textarea> spellcheck="false"></textarea>
</div> </div>
<button data-action="replace" i18n="replace" disabled></button> <button data-action="replace" i18n-text="replace" disabled></button>
<button data-action="replaceAll" i18n="replaceAll" disabled></button> <button data-action="replaceAll" i18n-text="replaceAll" disabled></button>
<button data-action="undo" i18n="undo" disabled></button> <button data-action="undo" i18n-text="undo" disabled></button>
<!-- <!--
Using a separate set of buttons because Using a separate set of buttons because
1. FF can display tooltips only when specified on the <button>, ignores the nested <title> in <svg> 1. FF can display tooltips only when specified on the <button>, ignores the nested <title> in <svg>
2. the icon doesn't fill the entire button area so tooltips aren't shown when the edges are hovered 2. the icon doesn't fill the entire button area so tooltips aren't shown when the edges are hovered
--> -->
<button class="hidden" data-action="replace" i18n="title:replace" disabled> <button class="hidden" data-action="replace" i18n-title="replace" disabled>
<svg class="svg-icon" viewBox="0 0 20 20"> <svg class="svg-icon" viewBox="0 0 20 20">
<polygon points="15.83 4.75 8.76 11.82 5.2 8.26 3.51 9.95 8.76 15.19 17.52 6.43 15.83 4.75"/> <polygon points="15.83 4.75 8.76 11.82 5.2 8.26 3.51 9.95 8.76 15.19 17.52 6.43 15.83 4.75"/>
</svg> </svg>
</button> </button>
<button class="hidden" data-action="replaceAll" i18n="title:replaceAll" disabled> <button class="hidden" data-action="replaceAll" i18n-title="replaceAll" disabled>
<svg class="svg-icon" viewBox="0 0 20 20"> <svg class="svg-icon" viewBox="0 0 20 20">
<polygon points="15.8,1.8 8.8,8.8 5.2,5.3 3.5,6.9 8.8,12.2 17.5,3.4 "/> <polygon points="15.8,1.8 8.8,8.8 5.2,5.3 3.5,6.9 8.8,12.2 17.5,3.4 "/>
<polygon points="15.8,7.8 8.8,14.8 5.2,11.3 3.5,12.9 8.8,18.2 17.5,9.4 "/> <polygon points="15.8,7.8 8.8,14.8 5.2,11.3 3.5,12.9 8.8,18.2 17.5,9.4 "/>
</svg> </svg>
</button> </button>
<button class="hidden" data-action="undo" i18n="title:undo" disabled> <button class="hidden" data-action="undo" i18n-title="undo" disabled>
<svg class="svg-icon" viewBox="0 0 20 20"> <svg class="svg-icon" viewBox="0 0 20 20">
<path d="M11.3,5.5H8.7V1.4L1.9,6.5l6.8,5.1V7.5h2.6c1.8,0,3.2,1.4,3.2,3.2s-1.4,3.2-3.2,3.2H7.8v2h3.5c2.9,0,5.2-2.3,5.2-5.2S14.2,5.5,11.3,5.5z"/> <path d="M11.3,5.5H8.7V1.4L1.9,6.5l6.8,5.1V7.5h2.6c1.8,0,3.2,1.4,3.2,3.2s-1.4,3.2-3.2,3.2H7.8v2h3.5c2.9,0,5.2-2.3,5.2-5.2S14.2,5.5,11.3,5.5z"/>
</svg> </svg>
@ -203,7 +212,7 @@
</template> </template>
<template data-id="jumpToLine"> <template data-id="jumpToLine">
<span i18n="editGotoLine">: <input class="CodeMirror-jump-field" type="text"></span> <span i18n-text="editGotoLine">: <input class="CodeMirror-jump-field" type="text"></span>
</template> </template>
<template data-id="regexpTestPartial"> <template data-id="regexpTestPartial">
@ -211,15 +220,15 @@
</template> </template>
<template data-id="resizeGrip"> <template data-id="resizeGrip">
<div class="resize-grip" i18n="title:cm_resizeGripHint"></div> <div class="resize-grip" i18n-title="cm_resizeGripHint"></div>
</template> </template>
<template data-id="keymapHelp"> <template data-id="keymapHelp">
<table class="keymap-list"> <table class="keymap-list">
<thead> <thead>
<tr> <tr>
<th><input i18n="placeholder:helpKeyMapHotkey" type="search"></th> <th><input i18n-placeholder="helpKeyMapHotkey" type="search" class="can-close-on-esc"></th>
<th><input i18n="placeholder:helpKeyMapCommand" type="search"></th> <th><input i18n-placeholder="helpKeyMapCommand" type="search" class="can-close-on-esc" spellcheck="false"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -239,20 +248,16 @@
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet"> <link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<link href="js/color/color-picker.css" rel="stylesheet"> <link href="js/color/color-picker.css" rel="stylesheet">
<link href="edit/codemirror-default.css" rel="stylesheet"> <link href="edit/codemirror-default.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
</head>
<template data-id="body"> <!-- https://crbug.com/1288447 --> <body id="stylus-edit">
<div id="header"> <div id="header">
<h1 id="heading" i18n="data-edit:editStyleHeading, data-add:addStyleTitle"> <h1 id="heading">&nbsp;</h1> <!-- nbsp allocates the actual height which prevents page shift -->
<a class="usercss-only"
href="https://github.com/openstyles/stylus/wiki/Usercss"
i18n="title:externalUsercssDocument" target="_blank">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</h1>
<section id="basic-info"> <section id="basic-info">
<div id="basic-info-name"> <div id="basic-info-name">
<input id="name" class="style-contributor" spellcheck="false"> <input id="name" class="style-contributor" spellcheck="false">
<a id="reset-name" i18n="title:customNameResetHint" tabindex="0" hidden> <a id="reset-name" href="#" i18n-title="customNameResetHint" tabindex="0" hidden>
<svg class="svg-icon" viewBox="0 0 20 20"> <svg class="svg-icon" viewBox="0 0 20 20">
<polygon points="16.2,5.5 14.5,3.8 10,8.3 5.5,3.8 3.8,5.5 8.3,10 3.8,14.5 <polygon points="16.2,5.5 14.5,3.8 10,8.3 5.5,3.8 3.8,5.5 8.3,10 3.8,14.5
5.5,16.2 10,11.7 14.5,16.2 16.2,14.5 11.7,10 "/> 5.5,16.2 10,11.7 14.5,16.2 16.2,14.5 11.7,10 "/>
@ -262,99 +267,87 @@
</div> </div>
<div id="basic-info-enabled"> <div id="basic-info-enabled">
<label id="enabled-label" <label id="enabled-label"
i18n="styleEnabledLabel, title:toggleStyle" i18n-text="styleEnabledLabel"
i18n-title="toggleStyle"
data-hotkey-tooltip="toggleStyle"> data-hotkey-tooltip="toggleStyle">
<input type="checkbox" id="enabled" class="style-contributor"> <input type="checkbox" id="enabled" class="style-contributor">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
<label id="preview-label" i18n="previewLabel, title:previewTooltip"> <label id="preview-label" i18n-text="previewLabel" i18n-title="previewTooltip" class="hidden">
<input type="checkbox" id="editor.livePreview"> <input type="checkbox" id="editor.livePreview">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
<label id="disableAll-label" i18n="data-on:disableAllStyles, data-off:disableAllStylesOff"> <span id="preview-errors" class="hidden">!</span>
<input id="disableAll" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
<span id="preview-errors" hidden>!</span>
</div> </div>
</section> </section>
<section id="actions"> <section id="actions">
<div class="buttons"> <div>
<div class="split-btn"> <button id="save-button" i18n-text="styleSaveLabel" data-hotkey-tooltip="save" disabled></button>
<button id="save-button" i18n="styleSaveLabel" data-hotkey-tooltip="save" disabled></button <button id="beautify" i18n-text="styleBeautify"></button>
><button class="split-btn-pedal usercss-only" i18n="menu-tpl:saveAsTemplate"></button> <a href="manage.html" tabindex="-1"><button id="cancel-button" i18n-text="styleCancelEditLabel"></button></a>
</div>
<button id="beautify" i18n="styleBeautify"></button>
<button id="style-settings-btn" i18n="settings"></button>
<button id="cancel-button" i18n="title:styleCancelEditLabel"></button>
</div> </div>
<div id="mozilla-format-buttons" class="buttons sectioned-only"> <div id="mozilla-format-buttons" class="sectioned-only">
<button id="from-mozilla" i18n="importLabel"></button> <button id="from-mozilla" i18n-text="importLabel"></button>
<button id="to-mozilla" i18n="exportLabel"></button> <button id="to-mozilla" i18n-text="exportLabel"></button>
<a id="to-mozilla-help" class="svg-inline-wrapper" tabindex="0" <a id="to-mozilla-help" class="svg-inline-wrapper" href="#" tabindex="0"
i18n="title:styleMozillaFormatHeading"> i18n-title="styleMozillaFormatHeading">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg> <svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a> </a>
</div> </div>
</section> </section>
<div id="details-wrapper"> <div id="details-wrapper">
<details id="options" data-pref="editor.options.expanded" class="ignore-pref-if-compact"> <details id="options" data-pref="editor.options.expanded" class="ignore-pref-if-compact">
<summary><h2 id="options-heading" i18n="editorSettings"></h2></summary> <summary><h2 id="options-heading" i18n-text="optionsHeading"></h2></summary>
<div id="options-wrapper"> <div id="options-wrapper">
<div class="options-column"> <div class="options-column">
<div class="option"> <div class="option">
<label id="lineWrapping-label" i18n="cm_lineWrapping"> <label id="lineWrapping-label" i18n-text="cm_lineWrapping">
<input id="editor.lineWrapping" type="checkbox"> <input id="editor.lineWrapping" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
</div> </div>
<div class="option"> <div class="option">
<label id="smartIndent-label" i18n="cm_smartIndent"> <label id="smartIndent-label" i18n-text="cm_smartIndent">
<input id="editor.smartIndent" type="checkbox"> <input id="editor.smartIndent" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
</div> </div>
<div class="option"> <div class="option">
<label id="indentWithTabs-label" i18n="cm_indentWithTabs"> <label id="indentWithTabs-label" i18n-text="cm_indentWithTabs">
<input id="editor.indentWithTabs" type="checkbox"> <input id="editor.indentWithTabs" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
</div> </div>
<div class="option"> <div class="option">
<label i18n="cm_autoCloseBrackets, title:cm_autoCloseBracketsTooltip"> <label i18n-text="cm_autoCloseBrackets" i18n-title="cm_autoCloseBracketsTooltip">
<input id="editor.autoCloseBrackets" type="checkbox"> <input id="editor.autoCloseBrackets" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
</div> </div>
<div class="option"> <div class="option">
<label i18n="cm_autocompleteOnTyping"> <label i18n-text="cm_autocompleteOnTyping">
<input id="editor.autocompleteOnTyping" type="checkbox"> <input id="editor.autocompleteOnTyping" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
</div> </div>
<div class="option"> <div class="option">
<label i18n="cm_selectByTokens, title:cm_selectByTokensTooltip"> <label i18n-text="cm_selectByTokens"
i18n-title="cm_selectByTokensTooltip">
<input id="editor.selectByTokens" type="checkbox"> <input id="editor.selectByTokens" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
</div> </div>
<div class="option sectioned-only">
<label i18n="cm_arrowKeysTraverse">
<input id="editor.arrowKeysTraverse" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option"> <div class="option">
<label i18n="cm_colorpicker"> <label i18n-text="cm_colorpicker">
<input id="editor.colorpicker" type="checkbox"> <input id="editor.colorpicker" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
<a id="colorpicker-settings" class="svg-inline-wrapper" i18n="title:shortcutsNote" tabindex="0"> <a id="colorpicker-settings" href="#" class="svg-inline-wrapper" i18n-title="shortcutsNote" tabindex="0">
<svg class="svg-icon settings"><use xlink:href="#svg-icon-config"/></svg> <svg class="svg-icon settings"><use xlink:href="#svg-icon-config"/></svg>
</a> </a>
</div> </div>
<div class="option usercss-only"> <div class="option usercss-only">
<label i18n="appliesLineWidgetLabel, title:appliesLineWidgetWarning"> <label i18n-text="appliesLineWidgetLabel" i18n-title="appliesLineWidgetWarning">
<input id="editor.appliesToLineWidget" type="checkbox"> <input id="editor.appliesToLineWidget" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label> </label>
@ -362,89 +355,74 @@
</div> </div>
<div class="options-column"> <div class="options-column">
<div class="option aligned"> <div class="option aligned">
<label id="tabSize-label" for="editor.tabSize" i18n="cm_tabSize"></label> <label id="tabSize-label" for="editor.tabSize" i18n-text="cm_tabSize"></label>
<input id="editor.tabSize" type="number" min="0"> <input id="editor.tabSize" type="number" min="0">
</div> </div>
<div class="option aligned"> <div class="option aligned">
<label id="keyMap-label" for="editor.keyMap" i18n="cm_keyMap"></label> <label id="keyMap-label" for="editor.keyMap" i18n-text="cm_keyMap"></label>
<div class="select-resizer"> <div class="select-resizer">
<select id="editor.keyMap"></select> <select id="editor.keyMap"></select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg> <svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div> </div>
<a id="keyMap-help" class="svg-inline-wrapper" tabindex="0"> <a id="keyMap-help" href="#" class="svg-inline-wrapper" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg> <svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a> </a>
</div> </div>
<div class="option aligned"> <div class="option aligned">
<label id="theme-label" for="editor.theme" i18n="cm_theme"></label> <label id="theme-label" for="editor.theme" i18n-text="cm_theme"></label>
<div class="select-resizer"> <div class="select-resizer">
<select id="editor.theme"></select> <select id="editor.theme"></select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg> <svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div> </div>
</div> </div>
<div class="option aligned"> <div class="option aligned">
<label id="highlight-label" for="editor.matchHighlight" i18n="cm_matchHighlight"></label> <label id="highlight-label" for="editor.matchHighlight" i18n-text="cm_matchHighlight"></label>
<div class="select-resizer"> <div class="select-resizer">
<select id="editor.matchHighlight"> <select id="editor.matchHighlight">
<option i18n="cm_matchHighlightToken" value="token"> <option i18n-text="cm_matchHighlightToken" value="token">
<option i18n="cm_matchHighlightSelection" value="selection"> <option i18n-text="cm_matchHighlightSelection" value="selection">
<option i18n="genericDisabledLabel" value=""> <option i18n-text="genericDisabledLabel" value="">
</select> </select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg> <svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div> </div>
</div> </div>
<div class="option aligned"> <div class="option aligned">
<label id="linter-label" for="editor.linter" i18n="cm_linter"></label> <label id="linter-label" for="editor.linter" i18n-text="cm_linter"></label>
<div class="select-resizer"> <div class="select-resizer">
<select id="editor.linter"> <select id="editor.linter">
<option value="csslint" selected>CSSLint</option> <option value="csslint" selected>CSSLint</option>
<option value="stylelint">Stylelint</option> <option value="stylelint">Stylelint</option>
<option value="" i18n="genericDisabledLabel"></option> <option value="" i18n-text="genericDisabledLabel"></option>
</select> </select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg> <svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div> </div>
<a id="linter-settings" class="svg-inline-wrapper" i18n="title:linterConfigTooltip" tabindex="0"> <a id="linter-settings" href="#" class="svg-inline-wrapper" i18n-title="linterConfigTooltip" tabindex="0">
<svg class="svg-icon settings"><use xlink:href="#svg-icon-config"/></svg> <svg class="svg-icon settings"><use xlink:href="#svg-icon-config"/></svg>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</details> </details>
<details id="publish" data-pref="editor.publish.expanded" class="ignore-pref-if-compact">
<summary><h2 i18n="publish"></h2></summary>
<div>
<a id="usw-url" href="https://userstyles.world" target="_blank">&nbsp;</a>
<div id="usw-link-info">
<dl><dt i18n="styleName"></dt><dd data-usw="name"></dd></dl>
<dl><dt i18n="genericDescription"></dt><dd data-usw="description"></dd></dl>
</div>
<div>
<button id="usw-publish-style"
i18n="data-publish:publishStyle, data-push:publishPush"></button>
<button id="usw-disconnect" i18n="optionsSyncDisconnect"></button>
<span id="usw-progress"></span>
</div>
</div>
</details>
<details id="sections-list" data-pref="editor.toc.expanded" class="ignore-pref-if-compact"> <details id="sections-list" data-pref="editor.toc.expanded" class="ignore-pref-if-compact">
<summary><h2 i18n="sections"></h2></summary> <summary><h2 i18n-text="sections"></h2></summary>
<ol id="toc"></ol> <ol id="toc"></ol>
</details> </details>
<details id="lint" data-pref="editor.lint.expanded" class="ignore-pref-if-compact" hidden> <details id="lint" data-pref="editor.lint.expanded" class="hidden-unless-compact ignore-pref-if-compact">
<summary> <summary>
<h2><span i18n="linterIssues"></span><span id="issue-count"></span> <h2 i18n-text="linterIssues">: <span id="issue-count"></span>
<a id="lint-help" class="svg-inline-wrapper intercepts-click" tabindex="0"> <a id="lint-help" href="#" class="svg-inline-wrapper intercepts-click" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg> <svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a> </a>
</h2> </h2>
</summary> </summary>
<div class="lint-report-container"></div> <div class="lint-scroll-container">
<div class="lint-report-container"></div>
</div>
</details> </details>
</div> </div>
<div id="header-resizer" i18n="title:headerResizerHint"></div>
<div id="footer" class="hidden"> <div id="footer" class="hidden">
<a href="https://github.com/openstyles/stylus/wiki/Usercss" <a href="https://github.com/openstyles/stylus/wiki/Usercss"
i18n="externalUsercssDocument" i18n-text="externalUsercssDocument"
target="_blank"></a> target="_blank"></a>
</div> </div>
</div> </div>
@ -460,10 +438,8 @@
<path d="M0 0v8h8v-2h-1v1h-6v-6h1v-1h-2zm4 0l1.5 1.5-2.5 2.5 1 1 2.5-2.5 1.5 1.5v-4h-4z"></path> <path d="M0 0v8h8v-2h-1v1h-6v-6h1v-1h-2zm4 0l1.5 1.5-2.5 2.5 1 1 2.5-2.5 1.5 1.5v-4h-4z"></path>
</symbol> </symbol>
<symbol id="svg-icon-help" viewBox="0 0 14 16" i18n="alt:helpAlt"> <symbol id="svg-icon-help" viewBox="0 0 14 16" i18n-alt="helpAlt">
<circle cx="7" cy="5" r="1"/> <path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path>
<path d="M8,8c0-0.5-0.5-1-1-1H6C5.5,7,5,7.4,5,8h1v3c0,0.5,0.5,1,1,1h1c0.5,0,1-0.4,1-1H8V8z"/>
<path d="M7,1c3.9,0,7,3.1,7,7s-3.1,7-7,7s-7-3.1-7-7S3.1,1,7,1z M7,2.3C3.9,2.3,1.3,4.9,1.3,8s2.6,5.7,5.7,5.7s5.7-2.6,5.7-5.7S10.1,2.3,7,2.3C7,2.3,7,2.3,7,2.3z"/>
</symbol> </symbol>
<symbol id="svg-icon-close" viewBox="0 0 12 16"> <symbol id="svg-icon-close" viewBox="0 0 12 16">
@ -487,21 +463,13 @@
</symbol> </symbol>
<symbol id="svg-icon-plus" viewBox="0 0 8 8"> <symbol id="svg-icon-plus" viewBox="0 0 8 8">
<path d="M3 0v3h-3v2h3v3h2v-3h3v-2h-3v-3h-2z"/> <path fill-rule="evenodd" d="M3 0v3h-3v2h3v3h2v-3h3v-2h-3v-3h-2z"/>
</symbol> </symbol>
<symbol id="svg-icon-minus" viewBox="0 0 8 8"> <symbol id="svg-icon-minus" viewBox="0 0 8 8">
<path d="M0 3v2h8v-2h-8z"/> <path fill-rule="evenodd" d="M0 3v2h8v-2h-8z"/>
</symbol> </symbol>
</svg> </svg>
</template>
<link href="edit/edit.css" rel="stylesheet">
<script src="js/dark-themer.js"></script> <!-- must be last in HEAD to avoid FOUC -->
</head>
<body id="stylus-edit">
<script src="edit/edit.js"></script>
</body> </body>
</html> </html>

View File

@ -2,7 +2,6 @@
/* global cmFactory */ /* global cmFactory */
/* global debounce */// toolbox.js /* global debounce */// toolbox.js
/* global editor */ /* global editor */
/* global linterMan */
/* global prefs */ /* global prefs */
'use strict'; 'use strict';
@ -12,7 +11,6 @@
const USO_VAR = 'uso-variable'; const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR; const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR; const USO_INVALID_VAR = 'error ' + USO_VAR;
const rxPROP = /^(prop(erty)?|variable-2|string-2)\b/;
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu; const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
const rxCONSUME = /([-\w]*\s*:\s?)?/yu; const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
const cssMime = CodeMirror.mimeModes['text/css']; const cssMime = CodeMirror.mimeModes['text/css'];
@ -20,28 +18,27 @@
const {tokenHooks} = cssMime; const {tokenHooks} = cssMime;
const originalCommentHook = tokenHooks['/']; const originalCommentHook = tokenHooks['/'];
const originalHelper = CodeMirror.hint.css || (() => {}); const originalHelper = CodeMirror.hint.css || (() => {});
let cssMedia, cssProps, cssValues; let cssProps, cssMedia;
const AOT_ID = 'autocompleteOnTyping'; const aot = prefs.get('editor.autocompleteOnTyping');
const AOT_PREF_ID = 'editor.' + AOT_ID; CodeMirror.defineOption('autocompleteOnTyping', aot, aotToggled);
const aot = prefs.get(AOT_PREF_ID); if (aot) cmFactory.globalSetOption('autocompleteOnTyping', true);
CodeMirror.defineOption(AOT_ID, aot, (cm, value) => {
cm[value ? 'on' : 'off']('changes', autocompleteOnTyping);
cm[value ? 'on' : 'off']('pick', autocompletePicked);
});
prefs.subscribe(AOT_PREF_ID, (key, val) => cmFactory.globalSetOption(AOT_ID, val), {runNow: aot});
CodeMirror.registerHelper('hint', 'css', helper); CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper); CodeMirror.registerHelper('hint', 'stylus', helper);
tokenHooks['/'] = tokenizeUsoVariables; tokenHooks['/'] = tokenizeUsoVariables;
async function helper(cm) { function aotToggled(cm, value) {
cm[value ? 'on' : 'off']('changes', autocompleteOnTyping);
cm[value ? 'on' : 'off']('pick', autocompletePicked);
}
function helper(cm) {
const pos = cm.getCursor(); const pos = cm.getCursor();
const {line, ch} = pos; const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line); const {styles, text} = cm.getLineHandle(line);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {}; const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
const isLessLang = cm.doc.mode.helperType === 'less';
const isStylusLang = cm.doc.mode.name === 'stylus'; const isStylusLang = cm.doc.mode.name === 'stylus';
const type = style && style.split(' ', 1)[0] || 'prop?'; const type = style && style.split(' ', 1)[0] || 'prop?';
if (!type || type === 'comment' || type === 'string') { if (!type || type === 'comment' || type === 'string') {
@ -67,7 +64,7 @@
const str = text.slice(prev, end); const str = text.slice(prev, end);
const left = text.slice(prev, ch).trim(); const left = text.slice(prev, ch).trim();
let leftLC = left.toLowerCase(); let leftLC = left.toLowerCase();
let list; let list = [];
switch (leftLC[0]) { switch (leftLC[0]) {
case '!': case '!':
@ -87,7 +84,6 @@
'@supports', '@supports',
'@viewport', '@viewport',
]; ];
if (isLessLang) list = findAllCssVars(cm, left, '\\s*:').concat(list);
break; break;
case '#': // prevents autocomplete for #hex colors case '#': // prevents autocomplete for #hex colors
@ -129,31 +125,10 @@
// fallthrough to `default` // fallthrough to `default`
default: default:
// property values
if (isStylusLang || getTokenState() === 'prop') {
while (i > 0 && !rxPROP.test(styles[i + 1])) i -= 2;
const propEnd = styles[i];
let prop;
if (propEnd > text.lastIndexOf(';', ch - 1)) {
while (i > 0 && rxPROP.test(styles[i + 1])) i -= 2;
prop = text.slice(styles[i] || 0, propEnd).match(/([-\w]+)?$/u)[1];
}
if (prop) {
if (/[^-\w]/.test(leftLC)) {
prev += execAt(/[\s:()]*/y, prev, text)[0].length;
leftLC = leftLC.replace(/^[^\w\s]\s*/, '');
}
if (prop.startsWith('--')) prop = 'color'; // assuming 90% of variables are colors
if (!cssProps) await initCssProps();
list = [...new Set([...cssValues.all[prop] || [], ...cssValues.global])];
end = prev + execAt(/(\s*[-a-z(]+)?/y, prev, text)[0].length;
}
}
// properties and media features // properties and media features
if (!list && if (/^(prop(erty|\?)|atom|error)/.test(type) &&
/^(prop(erty|\?)|atom|error|tag)/.test(type) &&
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) { /^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
if (!cssProps) await initCssProps(); if (!cssProps) initCssProps();
if (type === 'prop?') { if (type === 'prop?') {
prev += leftLC.length; prev += leftLC.length;
leftLC = ''; leftLC = '';
@ -161,9 +136,7 @@
list = state === 'atBlock_parens' ? cssMedia : cssProps; list = state === 'atBlock_parens' ? cssMedia : cssProps;
end -= /\W$/u.test(str); // e.g. don't consume ) when inside () end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
end += execAt(rxCONSUME, end, text)[0].length; end += execAt(rxCONSUME, end, text)[0].length;
} else {
}
if (!list) {
return isStylusLang return isStylusLang
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus}) ? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
: originalHelper(cm); : originalHelper(cm);
@ -176,9 +149,8 @@
}; };
} }
async function initCssProps() { function initCssProps() {
cssValues = await linterMan.worker.getCssPropsValues(); cssProps = addSuffix(cssMime.propertyKeywords);
cssProps = addSuffix(cssValues.all);
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort(); cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
} }
@ -198,15 +170,13 @@
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR); !style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
} }
function findAllCssVars(cm, leftPart, rightPart = '') { function findAllCssVars(cm, leftPart) {
// simplified regex without CSS escapes // simplified regex without CSS escapes
const [, prefixed, named] = leftPart.match(/^(--|@)?(\S)?/);
const rx = new RegExp( const rx = new RegExp(
'(?:^|[\\s/;{])(' + '(?:^|[\\s/;{])(' +
(prefixed ? leftPart : '--') + (leftPart.startsWith('--') ? leftPart : '--') +
(named ? '' : '[a-zA-Z_\u0080-\uFFFF]') + (leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)' + '[-0-9a-zA-Z_\u0080-\uFFFF]*)',
rightPart,
'g'); 'g');
const list = new Set(); const list = new Set();
cm.eachLine(({text}) => { cm.eachLine(({text}) => {

View File

@ -1,13 +1,19 @@
/* global $$ $ $create messageBoxProxy setInputValue setupLivePrefs */// dom.js /* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
/* global API */// msg.js /* global API */// msg.js
/* global CODEMIRROR_THEMES */ /* global CODEMIRROR_THEMES */
/* global CodeMirror */ /* global CodeMirror */
/* global MozDocMapper */// sections-util.js /* global MozDocMapper */// sections-util.js
/* global chromeSync */// storage-util.js
/* global initBeautifyButton */// beautify.js /* global initBeautifyButton */// beautify.js
/* global prefs */ /* global prefs */
/* global t */// localization.js /* global t */// localization.js
/* global FIREFOX getOwnTab sessionStore tryJSONparse tryURL */// toolbox.js /* global
FIREFOX
debounce
getOwnTab
sessionStore
tryCatch
tryJSONparse
*/// toolbox.js
'use strict'; 'use strict';
/** /**
@ -15,32 +21,15 @@
* @namespace Editor * @namespace Editor
*/ */
const editor = { const editor = {
style: null,
dirty: DirtyReporter(), dirty: DirtyReporter(),
isUsercss: false, isUsercss: false,
isWindowed: false, isWindowed: false,
livePreview: LivePreview(), livePreview: null,
/** @type {'customName'|'name'} */ /** @type {'customName'|'name'} */
nameTarget: 'name', nameTarget: 'name',
previewDelay: 200, // Chrome devtools uses 200 previewDelay: 200, // Chrome devtools uses 200
saving: false,
scrollInfo: null, scrollInfo: null,
cancel: () => location.assign('/manage.html'),
updateClass() {
$.rootCL.toggle('is-new-style', !editor.style.id);
},
updateTheme(name) {
if (!CODEMIRROR_THEMES[name]) {
name = 'default';
prefs.set('editor.theme', name);
}
$('#cm-theme').dataset.theme = name;
$('#cm-theme').textContent = CODEMIRROR_THEMES[name] || '';
},
updateTitle(isDirty = editor.dirty.isDirty()) { updateTitle(isDirty = editor.dirty.isDirty()) {
const {customName, name} = editor.style; const {customName, name} = editor.style;
document.title = `${ document.title = `${
@ -53,22 +42,39 @@ const editor = {
//#region pre-init //#region pre-init
(() => { const baseInit = (() => {
const mqCompact = matchMedia('(max-width: 850px)'); const lazyKeymaps = {
const toggleCompact = mq => $.rootCL.toggle('compact-layout', mq.matches); emacs: '/vendor/codemirror/keymap/emacs',
mqCompact.on('change', toggleCompact); vim: '/vendor/codemirror/keymap/vim',
toggleCompact(mqCompact); };
Object.assign(editor, /** @namespace Editor */ { const domReady = waitForSelector('#sections');
mqCompact,
styleReady: prefs.ready.then(loadStyle), return {
}); domReady,
ready: Promise.all([
domReady,
loadStyle(),
prefs.ready.then(() =>
Promise.all([
loadTheme(),
loadKeymaps(),
])),
]),
};
/** Preloads vim/emacs keymap only if it's the active one, otherwise will load later */
function loadKeymaps() {
const km = prefs.get('editor.keyMap');
return /emacs/i.test(km) && require([lazyKeymaps.emacs]) ||
/vim/i.test(km) && require([lazyKeymaps.vim]);
}
async function loadStyle() { async function loadStyle() {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
let id = Number(params.get('id')); const id = Number(params.get('id'));
const style = id && await API.styles.get(id) || { const style = id && await API.styles.get(id) || {
id: id = null, // resetting the non-existent id
name: params.get('domain') || name: params.get('domain') ||
tryURL(params.get('url-prefix')).hostname || tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'', '',
enabled: true, enabled: true,
sections: [ sections: [
@ -76,37 +82,89 @@ const editor = {
], ],
}; };
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded // switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
const isUC = Boolean(style.usercssData || !id && prefs.get('newStyleAsUsercss')); editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
Object.assign(editor, /** @namespace Editor */ { editor.lazyKeymaps = lazyKeymaps;
style, editor.style = style;
isUsercss: isUC,
template: isUC && !id && chromeSync.getLZValue(chromeSync.LZ_KEY.usercssTemplate), // promise
});
editor.updateClass();
editor.updateTheme(prefs.get('editor.theme'));
editor.updateTitle(false); editor.updateTitle(false);
$.rootCL.add(isUC ? 'usercss' : 'sectioned'); document.documentElement.classList.toggle('usercss', editor.isUsercss);
sessionStore.justEditedStyleId = id || ''; sessionStore.justEditedStyleId = style.id || '';
// no such style so let's clear the invalid URL parameters // no such style so let's clear the invalid URL parameters
if (!id) history.replaceState({}, '', location.pathname); if (!style.id) history.replaceState({}, '', location.pathname);
}
/** Preloads the theme so CodeMirror can use the correct metrics in its first render */
async function loadTheme() {
const theme = prefs.get('editor.theme');
if (theme !== 'default') {
const el = $('#cm-theme');
const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
el2.id = el.id;
el.remove();
if (!el2.sheet) {
prefs.set('editor.theme', 'default');
}
}
} }
})(); })();
//#endregion
//#region init layout/resize
baseInit.domReady.then(() => {
let headerHeight;
detectLayout(true);
window.on('resize', () => detectLayout());
function detectLayout(now) {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
if (now) fixedHeader();
else debounce(fixedHeader, 250);
window.on('scroll', fixedHeader, {passive: true});
}
} else {
document.body.classList.remove('compact-layout', 'fixed-header');
window.off('scroll', fixedHeader);
}
for (const type of ['options', 'toc', 'lint']) {
const el = $(`details[data-pref="editor.${type}.expanded"]`);
el.open = compact ? false : prefs.get(el.dataset.pref);
}
}
function fixedHeader() {
const headerFixed = $('.fixed-header');
if (!headerFixed) headerHeight = $('#header').clientHeight;
const scrollPoint = headerHeight - 43;
if (window.scrollY >= scrollPoint && !headerFixed) {
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
$('body').classList.add('fixed-header');
} else if (window.scrollY < scrollPoint && headerFixed) {
$('body').classList.remove('fixed-header');
}
}
});
//#endregion //#endregion
//#region init header //#region init header
/* exported EditorHeader */ baseInit.ready.then(() => {
function EditorHeader() {
initBeautifyButton($('#beautify')); initBeautifyButton($('#beautify'));
initKeymapElement(); initKeymapElement();
initNameArea(); initNameArea();
initThemeElement(); initThemeElement();
setupLivePrefs(); setupLivePrefs();
window.on('load', () => { $('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !editor.style.id);
require(Object.values(editor.lazyKeymaps), () => {
initKeymapElement();
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true}); prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
window.on('showHotkeyInTooltip', showHotkeyInTooltip); window.on('showHotkeyInTooltip', showHotkeyInTooltip);
}, {once: true}); });
function findKeyForCommand(command, map) { function findKeyForCommand(command, map) {
if (typeof map === 'string') map = CodeMirror.keyMap[map]; if (typeof map === 'string') map = CodeMirror.keyMap[map];
@ -132,12 +190,19 @@ function EditorHeader() {
nameEl.title = isCustomName ? t('customNameHint') : ''; nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.on('input', () => { nameEl.on('input', () => {
editor.updateName(true); editor.updateName(true);
resetEl.hidden = !editor.style.customName; resetEl.hidden = false;
}); });
resetEl.hidden = !editor.style.customName; resetEl.hidden = !editor.style.customName;
resetEl.onclick = () => { resetEl.onclick = () => {
editor.style.customName = null; // to delete it from db const {style} = editor;
setInputValue(nameEl, editor.style.name); nameEl.focus();
nameEl.select();
// trying to make it undoable via Ctrl-Z
if (!document.execCommand('insertText', false, style.name)) {
nameEl.value = style.name;
editor.updateName(true);
}
style.customName = null; // to delete it from db
resetEl.hidden = true; resetEl.hidden = true;
}; };
const enabledEl = $('#enabled'); const enabledEl = $('#enabled');
@ -147,7 +212,7 @@ function EditorHeader() {
function initThemeElement() { function initThemeElement() {
$('#editor.theme').append(...[ $('#editor.theme').append(...[
$create('option', {value: 'default'}, t('defaultTheme')), $create('option', {value: 'default'}, t('defaultTheme')),
...Object.keys(CODEMIRROR_THEMES).map(s => $create('option', s)), ...CODEMIRROR_THEMES.map(s => $create('option', s)),
]); ]);
// move the theme after built-in CSS so that its same-specificity selectors win // move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme')); document.head.appendChild($('#cm-theme'));
@ -203,7 +268,7 @@ function EditorHeader() {
} }
} }
} }
} });
//#endregion //#endregion
//#region init windowed mode //#region init windowed mode
@ -220,17 +285,22 @@ function EditorHeader() {
} }
} }
getOwnTab().then(tab => { getOwnTab().then(async tab => {
ownTabId = tab.id; ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) { if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
editor.cancel = () => history.back(); await baseInit.domReady;
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
} }
}); });
async function initWindowedMode() { async function initWindowedMode() {
chrome.tabs.onAttached.addListener(onTabAttached); chrome.tabs.onAttached.addListener(onTabAttached);
// Chrome 96+ bug: the type is 'app' for a window that was restored via Ctrl-Shift-T const isSimple = (await browser.windows.getCurrent()).type === 'popup';
const isSimple = ['app', 'popup'].includes((await browser.windows.getCurrent()).type);
if (isSimple) require(['/edit/embedded-popup']); if (isSimple) require(['/edit/embedded-popup']);
editor.isWindowed = isSimple || ( editor.isWindowed = isSimple || (
history.length === 1 && history.length === 1 &&
@ -266,15 +336,9 @@ function EditorHeader() {
function DirtyReporter() { function DirtyReporter() {
const data = new Map(); const data = new Map();
const listeners = new Set(); const listeners = new Set();
const dataListeners = new Set();
const notifyChange = wasDirty => { const notifyChange = wasDirty => {
const isDirty = data.size > 0; if (wasDirty !== (data.size > 0)) {
const flipped = isDirty !== wasDirty; listeners.forEach(cb => cb());
if (flipped) {
listeners.forEach(cb => cb(isDirty));
}
if (flipped || isDirty) {
dataListeners.forEach(cb => cb(isDirty));
} }
}; };
/** @namespace DirtyReporter */ /** @namespace DirtyReporter */
@ -291,19 +355,17 @@ function DirtyReporter() {
saved.newValue = value; saved.newValue = value;
saved.type = 'modify'; saved.type = 'modify';
} }
} else {
return;
} }
notifyChange(wasDirty); notifyChange(wasDirty);
}, },
clear(...objs) { clear(obj) {
if (data.size && ( const wasDirty = data.size > 0;
objs.length if (obj === undefined) {
? objs.map(data.delete, data).includes(true) data.clear();
: (data.clear(), true) } else {
)) { data.delete(obj);
notifyChange(true);
} }
notifyChange(wasDirty);
}, },
has(key) { has(key) {
return data.has(key); return data.has(key);
@ -317,8 +379,6 @@ function DirtyReporter() {
if (!saved) { if (!saved) {
if (oldValue !== newValue) { if (oldValue !== newValue) {
data.set(obj, {type: 'modify', savedValue: oldValue, newValue}); data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
} else {
return;
} }
} else if (saved.type === 'modify') { } else if (saved.type === 'modify') {
if (saved.savedValue === newValue) { if (saved.savedValue === newValue) {
@ -328,17 +388,12 @@ function DirtyReporter() {
} }
} else if (saved.type === 'add') { } else if (saved.type === 'add') {
saved.newValue = newValue; saved.newValue = newValue;
} else {
return;
} }
notifyChange(wasDirty); notifyChange(wasDirty);
}, },
onChange(cb, add = true) { onChange(cb, add = true) {
listeners[add ? 'add' : 'delete'](cb); listeners[add ? 'add' : 'delete'](cb);
}, },
onDataChange(cb, add = true) {
dataListeners[add ? 'add' : 'delete'](cb);
},
remove(obj, value) { remove(obj, value) {
const wasDirty = data.size > 0; const wasDirty = data.size > 0;
const saved = data.get(obj); const saved = data.get(obj);
@ -348,80 +403,10 @@ function DirtyReporter() {
data.delete(obj); data.delete(obj);
} else if (saved.type === 'modify') { } else if (saved.type === 'modify') {
saved.type = 'remove'; saved.type = 'remove';
} else {
return;
} }
notifyChange(wasDirty); notifyChange(wasDirty);
}, },
}; };
} }
function LivePreview() {
let el;
let data;
let port;
let preprocess;
let enabled = prefs.get('editor.livePreview');
prefs.subscribe('editor.livePreview', (key, value) => {
if (!value) {
if (port) {
port.disconnect();
port = null;
}
} else if (data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
createPreviewer();
updatePreviewer(data);
}
enabled = value;
});
return {
/**
* @param {Function} [fn] - preprocessor
*/
init(fn) {
preprocess = fn;
},
update(newData) {
data = newData;
if (!port) {
if (!data.id || !data.enabled || !enabled) {
return;
}
createPreviewer();
}
updatePreviewer(data);
},
};
function createPreviewer() {
port = chrome.runtime.connect({name: 'livePreview'});
port.onDisconnect.addListener(err => {
throw err;
});
el = $('#preview-errors');
el.onclick = () => messageBoxProxy.alert(el.title, 'pre');
}
async function updatePreviewer(data) {
try {
port.postMessage(preprocess ? await preprocess(data) : data);
el.hidden = true;
} catch (err) {
if (Array.isArray(err)) {
err = err.map(e => e.message || e).join('\n');
} else if (err && err.index != null) {
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
const pos = editor.getEditors()[0].posFromIndex(err.index);
err.message = `${pos.line}:${pos.ch} ${err.message || err}`;
}
el.title = err.message || `${err}`;
el.hidden = false;
}
}
}
//#endregion //#endregion

View File

@ -65,7 +65,7 @@ function beautifyEditor(cm, options, ui) {
window.scrollTo(scrollX, scrollY); window.scrollTo(scrollX, scrollY);
cm.beautifyChange[cm.changeGeneration()] = true; cm.beautifyChange[cm.changeGeneration()] = true;
if (ui) { if (ui) {
$('button[role="close"]', helpPopup.div).disabled = false; $('#help-popup button[role="close"]').disabled = false;
} }
} }
} }
@ -82,14 +82,10 @@ function createBeautifyUI(scope, options) {
$createOption('}', 'newline_between_rules'), $createOption('}', 'newline_between_rules'),
$createLabeledCheckbox('preserve_newlines', 'styleBeautifyPreserveNewlines'), $createLabeledCheckbox('preserve_newlines', 'styleBeautifyPreserveNewlines'),
$createLabeledCheckbox('indent_conditional', 'styleBeautifyIndentConditional'), $createLabeledCheckbox('indent_conditional', 'styleBeautifyIndentConditional'),
editor.isUsercss && $createLabeledCheckbox('indent_mozdoc', '', '... @-moz-document'),
]), ]),
$create('p.beautify-hint', [ $create('p.beautify-hint', [
$create('span', t('styleBeautifyHint') + '\u00A0'), $create('span', t('styleBeautifyHint') + '\u00A0'),
createHotkeyInput('editor.beautify.hotkey', { createHotkeyInput('editor.beautify.hotkey', () => moveFocus($('#help-popup'), 0)),
buttons: false,
onDone: () => moveFocus(helpPopup.div, 0),
}),
]), ]),
$create('.buttons', [ $create('.buttons', [
$create('button', { $create('button', {
@ -114,16 +110,16 @@ function createBeautifyUI(scope, options) {
}, },
}, t(scope.length === 1 ? 'undo' : 'undoGlobal')), }, t(scope.length === 1 ? 'undo' : 'undoGlobal')),
]), ]),
]), ]));
{
className: 'wide', $('#help-popup').className = 'wide';
});
$('.beautify-options').onchange = ({target}) => { $('.beautify-options').onchange = ({target}) => {
const value = target.type === 'checkbox' ? target.checked : target.selectedIndex > 0; const value = target.type === 'checkbox' ? target.checked : target.selectedIndex > 0;
const elLine = target.closest('[newline]');
if (elLine) elLine.setAttribute('newline', value);
prefs.set('editor.beautify', Object.assign(options, {[target.dataset.option]: value})); prefs.set('editor.beautify', Object.assign(options, {[target.dataset.option]: value}));
if (target.parentNode.hasAttribute('newline')) {
target.parentNode.setAttribute('newline', value.toString());
}
beautify(scope, false); beautify(scope, false);
}; };
@ -149,7 +145,7 @@ function createBeautifyUI(scope, options) {
); );
} }
function $createLabeledCheckbox(optionName, i18nKey, text) { function $createLabeledCheckbox(optionName, i18nKey) {
return ( return (
$create('label', {style: 'display: block; clear: both;'}, [ $create('label', {style: 'display: block; clear: both;'}, [
$create('input', { $create('input', {
@ -159,7 +155,7 @@ function createBeautifyUI(scope, options) {
}), }),
$create('SVG:svg.svg-icon.checked', $create('SVG:svg.svg-icon.checked',
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})), $create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
i18nKey ? t(i18nKey) : text, t(i18nKey),
]) ])
); );
} }

View File

@ -4,23 +4,13 @@
z-index: 999; z-index: 999;
} }
.CodeMirror-hint:hover { .CodeMirror-hint:hover {
color: var(--bg); color: white;
background: #08f; background: #08f;
} }
.CodeMirror { .CodeMirror {
border: solid var(--c80) 1px; border: solid #CCC 1px;
transition: box-shadow .1s; transition: box-shadow .1s;
} }
.CodeMirror {
color: inherit;
background-color: inherit;
border: solid var(--c80) 1px;
transition: box-shadow .1s;
}
.CodeMirror-gutters {
background-color: var(--c95);
border-color: var(--c85);
}
#stylus#stylus .CodeMirror { #stylus#stylus .CodeMirror {
/* Using a specificity hack to override userstyles */ /* Using a specificity hack to override userstyles */
/* Not using the ring-color hack as it became ugly in new Chrome */ /* Not using the ring-color hack as it became ugly in new Chrome */
@ -36,7 +26,7 @@
width: 5em; width: 5em;
} }
.CodeMirror-search-hint { .CodeMirror-search-hint {
color: var(--c50); color: #888;
} }
.CodeMirror-activeline .applies-to:before { .CodeMirror-activeline .applies-to:before {
background-color: hsla(214, 100%, 90%, 0.15); background-color: hsla(214, 100%, 90%, 0.15);
@ -75,10 +65,6 @@
.CodeMirror-linenumber { .CodeMirror-linenumber {
cursor: pointer; /* for bookmarking */ cursor: pointer; /* for bookmarking */
} }
.cm-matchhighlight,
.CodeMirror-selection-highlight-scrollbar {
background: hsla(200, 100%, 50%, var(--match-hl-opacity, .1));
}
/* Custom stuff we add to CodeMirror */ /* Custom stuff we add to CodeMirror */
@ -88,61 +74,3 @@
.gutter-bookmark { .gutter-bookmark {
background: linear-gradient(0deg, hsla(180, 100%, 30%, .75) 2px, hsla(180, 100%, 30%, .2) 2px); background: linear-gradient(0deg, hsla(180, 100%, 30%, .75) 2px, hsla(180, 100%, 30%, .2) 2px);
} }
@media screen and (prefers-color-scheme: dark), dark {
.CodeMirror {
--match-hl-opacity: .18;
}
.CodeMirror-dialog {
background-color: #333;
}
.CodeMirror-dialog-top {
border-color: #555;
}
.CodeMirror-activeline-background {
background: hsl(180, 21%, 18%);
}
.CodeMirror-selected,
.CodeMirror-focused .CodeMirror-selected,
.CodeMirror-line::selection,
.CodeMirror-line > span::selection,
.CodeMirror-line > span > span::selection {
background: #444;
}
.CodeMirror-line::-moz-selection,
.CodeMirror-line > span::-moz-selection,
.CodeMirror-line > span > span::-moz-selection {
/* TODO: remove this when strict_min_version >= 62 */
background: #444;
}
.cm-s-default div.CodeMirror-cursor {
border-left: 1px solid #fff;
}
/* Using Chromium's dark devtools colors */
.cm-s-default .cm-atom,
.cm-s-default .cm-number { color: #a1f7b5 }
.cm-s-default .cm-attribute { color: #6194c6 }
.cm-s-default .cm-bracket { color: #997 }
.cm-s-default .cm-builtin,
.cm-s-default .cm-link { color: #9fb4d6 }
.cm-s-default .cm-comment { color: #747474 }
.cm-s-default .cm-qualifier { color: #ffa34f }
.cm-s-default .cm-def,
.cm-s-default .cm-header,
.cm-s-default .cm-tag,
.cm-s-default .cm-type { color: #5db0d7 }
.cm-s-default .cm-hr { color: #999 }
.cm-s-default .cm-keyword { color: #9a7fd5 }
.cm-s-default .cm-meta { color: #ddfb55 }
.cm-s-default .cm-operator { color: #d2c057 }
.cm-s-default .cm-string { color: #f28b54 }
.cm-s-default .cm-variable { color: #d9d9d9 }
.cm-s-default .cm-variable-2 { color: #72b9ff }
.cm-s-default .cm-variable-3 { color: #9bbbdc }
@keyframes highlight {
from {
background-color: #888;
}
}
}

View File

@ -1,6 +1,5 @@
/* global $ */// dom.js /* global $ */// dom.js
/* global CodeMirror */ /* global CodeMirror */
/* global UA */// toolbox.js
/* global editor */ /* global editor */
/* global prefs */ /* global prefs */
/* global t */// localization.js /* global t */// localization.js
@ -26,7 +25,7 @@
matchBrackets: true, matchBrackets: true,
hintOptions: {}, hintOptions: {},
lintReportDelay: prefs.get('editor.lintReportDelay'), lintReportDelay: prefs.get('editor.lintReportDelay'),
styleActiveLine: {nonEmpty: true}, styleActiveLine: true,
theme: prefs.get('editor.theme'), theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'), keyMap: prefs.get('editor.keyMap'),
extraKeys: Object.assign(CodeMirror.defaults.extraKeys || {}, { extraKeys: Object.assign(CodeMirror.defaults.extraKeys || {}, {
@ -42,7 +41,7 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options')); Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// Adding hotkeys to some keymaps except 'basic' which is primitive by design // Adding hotkeys to some keymaps except 'basic' which is primitive by design
{ require(Object.values(typeof editor === 'object' && editor.lazyKeymaps || {}), () => {
const KM = CodeMirror.keyMap; const KM = CodeMirror.keyMap;
const extras = Object.values(CodeMirror.defaults.extraKeys); const extras = Object.values(CodeMirror.defaults.extraKeys);
if (!extras.includes('jumpToLine')) { if (!extras.includes('jumpToLine')) {
@ -63,7 +62,7 @@
if (!extras.includes('blockComment')) { if (!extras.includes('blockComment')) {
KM.sublime['Shift-Ctrl-/'] = 'commentSelection'; KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
} }
if (UA.windows) { if (navigator.appVersion.includes('Windows')) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R // 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext'; if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev'; if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
@ -90,8 +89,33 @@
} }
} }
} }
} });
const cssMime = CodeMirror.mimeModes['text/css'];
Object.assign(cssMime.propertyKeywords, {
'content-visibility': true,
'overflow-anchor': true,
'overscroll-behavior': true,
});
Object.assign(cssMime.colorKeywords, {
'darkgrey': true,
'darkslategrey': true,
'dimgrey': true,
'lightgrey': true,
'lightslategrey': true,
'slategrey': true,
});
Object.assign(cssMime.valueKeywords, {
'blur': true,
'brightness': true,
'contrast': true,
'cubic-bezier': true,
'drop-shadow': true,
'fit-content': true,
'hue-rotate': true,
'saturate': true,
'sepia': true,
});
Object.assign(CodeMirror.prototype, { Object.assign(CodeMirror.prototype, {
/** /**
* @param {'less' | 'stylus' | ?} [pp] - any value besides `less` or `stylus` sets `css` mode * @param {'less' | 'stylus' | ?} [pp] - any value besides `less` or `stylus` sets `css` mode
@ -102,7 +126,6 @@
const m = this.doc.mode; const m = this.doc.mode;
if (force || (m.helperType ? m.helperType !== pp : m.name !== name)) { if (force || (m.helperType ? m.helperType !== pp : m.name !== name)) {
this.setOption('mode', name); this.setOption('mode', name);
this.doc.mode.lineComment = ''; // stylelint chokes on line comments a lot
} }
}, },
/** Superfast GC-friendly check that runs until the first non-space line */ /** Superfast GC-friendly check that runs until the first non-space line */

View File

@ -1,3 +1,4 @@
/* global $ */// dom.js
/* global CodeMirror */ /* global CodeMirror */
/* global editor */ /* global editor */
/* global prefs */ /* global prefs */
@ -31,7 +32,7 @@
globalSetOption(key, value) { globalSetOption(key, value) {
CodeMirror.defaults[key] = value; CodeMirror.defaults[key] = value;
if (cms.size > 4 && lazyOpt.names.includes(key)) { if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
lazyOpt.set(key, value); lazyOpt.set(key, value);
} else { } else {
cms.forEach(cm => cm.setOption(key, value)); cms.forEach(cm => cm.setOption(key, value));
@ -39,18 +40,14 @@
}, },
}; };
// focus and blur
const onCmFocus = cm => { const onCmFocus = cm => {
rerouteHotkeys.toggle(false); rerouteHotkeys.toggle(false);
cm.display.wrapper.classList.add('CodeMirror-active'); cm.display.wrapper.classList.add('CodeMirror-active');
cm.lastActive = Date.now(); cm.lastActive = Date.now();
}; };
const onCmBlur = cm => { const onCmBlur = cm => {
rerouteHotkeys.toggle(true);
setTimeout(() => { setTimeout(() => {
/* Delaying to next tick to avoid double-processing of the currently processed keyboard event
* when it bubbles up from CodeMirror to `document` where the rerouter listens */
rerouteHotkeys.toggle(true);
const {wrapper} = cm.display; const {wrapper} = cm.display;
wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement)); wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement));
}); });
@ -60,50 +57,36 @@
cm.on('blur', onCmBlur); cm.on('blur', onCmBlur);
}); });
// propagated preferences const handledPrefs = {
'editor.colorpicker'() {}, // handled in colorpicker-helper.js
const prefToCmOpt = k => async 'editor.theme'(key, value) {
let el2;
const el = $('#cm-theme');
if (value === 'default') {
el.href = '';
} else {
const path = `/vendor/codemirror/theme/${value}.css`;
if (el.href !== location.origin + path) {
// avoid flicker: wait for the second stylesheet to load, then apply the theme
el2 = await require([path]);
}
}
cmFactory.globalSetOption('theme', value);
if (el2) {
el.remove();
el2.id = el.id;
}
},
};
const pref2opt = k => k.slice('editor.'.length);
const mirroredPrefs = prefs.knownKeys.filter(k =>
!handledPrefs[k] &&
k.startsWith('editor.') && k.startsWith('editor.') &&
k.slice('editor.'.length); Object.hasOwnProperty.call(CodeMirror.defaults, pref2opt(k)));
const prefKeys = prefs.knownKeys.filter(k => prefs.subscribe(mirroredPrefs, (k, val) => cmFactory.globalSetOption(pref2opt(k), val));
k !== 'editor.colorpicker' && // handled in colorpicker-helper.js prefs.subscribeMany(handledPrefs);
k !== 'editor.arrowKeysTraverse' && // handled in sections-editor.js
prefToCmOpt(k) in CodeMirror.defaults);
const {insertTab, insertSoftTab} = CodeMirror.commands;
for (const [key, fn] of Object.entries({ lazyOpt = window.IntersectionObserver && {
'editor.tabSize'(cm, value) {
cm.setOption('indentUnit', Number(value));
},
'editor.indentWithTabs'(cm, value) {
CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
},
'editor.matchHighlight'(cm, value) {
const showToken = value === 'token' && /[#.\-\w]/;
const opt = (showToken || value === 'selection') && {
showToken,
annotateScrollbar: true,
delay: 0,
onUpdate: updateMatchHighlightCount,
};
cm.setOption('highlightSelectionMatches', opt || null);
},
'editor.selectByTokens'(cm, value) {
cm.setOption('configureMouse', value ? configureMouseFn : null);
},
})) {
CodeMirror.defineOption(prefToCmOpt(key), prefs.get(key), fn);
prefKeys.push(key);
}
prefs.subscribe(prefKeys, (key, val) => {
if (key === 'editor.theme') editor.updateTheme(val);
cmFactory.globalSetOption(prefToCmOpt(key), val);
});
// lazy propagation
lazyOpt = {
names: ['theme', 'lineWrapping'], names: ['theme', 'lineWrapping'],
set(key, value) { set(key, value) {
const {observer, queue} = lazyOpt; const {observer, queue} = lazyOpt;
@ -177,6 +160,30 @@
//#endregion //#endregion
//#region CM option handlers //#region CM option handlers
const {insertTab, insertSoftTab} = CodeMirror.commands;
Object.entries({
tabSize(cm, value) {
cm.setOption('indentUnit', Number(value));
},
indentWithTabs(cm, value) {
CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
},
matchHighlight(cm, value) {
const showToken = value === 'token' && /[#.\-\w]/;
const opt = (showToken || value === 'selection') && {
showToken,
annotateScrollbar: true,
onUpdate: updateMatchHighlightCount,
};
cm.setOption('highlightSelectionMatches', opt || null);
},
selectByTokens(cm, value) {
cm.setOption('configureMouse', value ? configureMouseFn : null);
},
}).forEach(([name, fn]) => {
CodeMirror.defineOption(name, prefs.get('editor.' + name), fn);
});
function updateMatchHighlightCount(cm, state) { function updateMatchHighlightCount(cm, state) {
cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length; cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length;
} }
@ -244,20 +251,7 @@
const BM_CLS = 'gutter-bookmark'; const BM_CLS = 'gutter-bookmark';
const BM_BRAND = 'sublimeBookmark'; const BM_BRAND = 'sublimeBookmark';
const BM_CLICKER = 'CodeMirror-linenumbers'; const BM_CLICKER = 'CodeMirror-linenumbers';
const BM_DATA = Symbol('data'); const {markText} = CodeMirror.prototype;
// TODO: revisit when https://github.com/codemirror/CodeMirror/issues/6716 is fixed
const tmProto = CodeMirror.TextMarker.prototype;
const tmProtoOvr = {};
for (const k of ['clear', 'attachLine', 'detachLine']) {
tmProtoOvr[k] = function (line) {
const {cm} = this.doc;
const withOp = !cm.curOp;
if (withOp) cm.startOperation();
tmProto[k].apply(this, arguments);
cm.curOp.ownsGroup.delayedCallbacks.push(toggleMark.bind(this, this.lines[0], line));
if (withOp) cm.endOperation();
};
}
for (const name of ['prevBookmark', 'nextBookmark']) { for (const name of ['prevBookmark', 'nextBookmark']) {
const cmdFn = CodeMirror.commands[name]; const cmdFn = CodeMirror.commands[name];
CodeMirror.commands[name] = cm => { CodeMirror.commands[name] = cm => {
@ -269,9 +263,29 @@
CodeMirror.defineInitHook(cm => { CodeMirror.defineInitHook(cm => {
cm.on('gutterClick', onGutterClick); cm.on('gutterClick', onGutterClick);
cm.on('gutterContextMenu', onGutterContextMenu); cm.on('gutterContextMenu', onGutterContextMenu);
cm.on('markerAdded', onMarkAdded);
}); });
// TODO: reimplement bookmarking so next/prev order is decided solely by the line numbers // TODO: reimplement bookmarking so next/prev order is decided solely by the line numbers
Object.assign(CodeMirror.prototype, {
markText() {
const marker = markText.apply(this, arguments);
if (marker[BM_BRAND]) {
this.doc.addLineClass(marker.lines[0], 'gutter', BM_CLS);
marker.clear = clearMarker;
}
return marker;
},
});
function clearMarker() {
const line = this.lines[0];
const spans = line.markedSpans;
delete this.clear; // removing our patch from the instance...
this.clear(); // ...and using the original prototype
if (!spans || spans.some(span => span.marker[BM_BRAND])) {
this.doc.removeLineClass(line, 'gutter', BM_CLS);
}
}
function onGutterClick(cm, line, name, e) { function onGutterClick(cm, line, name, e) {
switch (name === BM_CLICKER && e.button) { switch (name === BM_CLICKER && e.button) {
case 0: { case 0: {
@ -287,27 +301,13 @@
break; break;
} }
} }
function onGutterContextMenu(cm, line, name, e) { function onGutterContextMenu(cm, line, name, e) {
if (name === BM_CLICKER) { if (name === BM_CLICKER) {
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark'); cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
e.preventDefault(); e.preventDefault();
} }
} }
function onMarkAdded(cm, mark) {
if (mark[BM_BRAND]) {
// CM bug workaround to keep the mark at line start when the above line is removed
mark.inclusiveRight = true;
Object.assign(mark, tmProtoOvr);
toggleMark.call(mark, true, mark[BM_DATA] = mark.lines[0]);
}
}
function toggleMark(state, line = this[BM_DATA]) {
this.doc[state ? 'addLineClass' : 'removeLineClass'](line, 'gutter', BM_CLS);
if (state) {
const bms = this.doc.cm.state.sublimeBookmarks;
if (!bms.includes(this)) bms.push(this);
}
}
//#endregion //#endregion
})(); })();

File diff suppressed because one or more lines are too long

View File

@ -1,68 +0,0 @@
/* global messageBoxProxy */// dom.js
/* global API */// msg.js
/* global clamp debounce */// toolbox.js
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
(async function AutosaveDrafts() {
const makeId = () => editor.style.id || 'new';
let delay;
let port;
connectPort();
const draft = await API.drafts.get(makeId());
if (draft && draft.isUsercss === editor.isUsercss) {
const date = makeRelativeDate(draft.date);
if (await messageBoxProxy.confirm(t('draftAction'), 'danger', t('draftTitle', date))) {
await editor.replaceStyle(draft.style, draft);
} else {
API.drafts.delete(makeId());
}
}
editor.dirty.onChange(isDirty => isDirty ? connectPort() : port.disconnect());
editor.dirty.onDataChange(isDirty => debounce(updateDraft, isDirty ? delay : 0));
prefs.subscribe('editor.autosaveDraft', (key, val) => {
delay = clamp(val * 1000 | 0, 1000, 2 ** 32 - 1);
const t = debounce.timers.get(updateDraft);
if (t != null) debounce(updateDraft, t ? delay : 0);
}, {runNow: true});
function connectPort() {
port = chrome.runtime.connect({name: 'draft:' + makeId()});
}
function makeRelativeDate(date) {
let delta = (Date.now() - date) / 1000;
if (delta >= 0 && Intl.RelativeTimeFormat) {
for (const [span, unit, frac = 1] of [
[60, 'second', 0],
[60, 'minute', 0],
[24, 'hour'],
[7, 'day'],
[4, 'week'],
[12, 'month'],
[1e99, 'year'],
]) {
if (delta < span) {
return new Intl.RelativeTimeFormat({style: 'short'}).format(-delta.toFixed(frac), unit);
}
delta /= span;
}
}
return date.toLocaleString();
}
function updateDraft(isDirty = editor.dirty.isDirty()) {
if (!isDirty) return;
API.drafts.put({
date: Date.now(),
isUsercss: editor.isUsercss,
style: editor.getValue(true),
si: editor.makeScrollInfo(),
}, makeId());
}
})();

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,13 @@
/* global $$ $ $create */// dom.js /* global $ $create messageBoxProxy waitForSheet */// dom.js
/* global API msg */// msg.js /* global API msg */// msg.js
/* global CodeMirror */ /* global CodeMirror */
/* global SectionsEditor */ /* global SectionsEditor */
/* global SourceEditor */ /* global SourceEditor */
/* global baseInit */
/* global clipString createHotkeyInput helpPopup */// util.js /* global clipString createHotkeyInput helpPopup */// util.js
/* global closeCurrentTab deepEqual mapObj sessionStore tryJSONparse */// toolbox.js /* global closeCurrentTab deepEqual sessionStore tryJSONparse */// toolbox.js
/* global cmFactory */ /* global cmFactory */
/* global editor EditorHeader */// base.js /* global editor */
/* global linterMan */ /* global linterMan */
/* global prefs */ /* global prefs */
/* global t */// localization.js /* global t */// localization.js
@ -14,29 +15,25 @@
//#region init //#region init
document.body.appendChild(t.template.body); baseInit.ready.then(async () => {
await waitForSheet();
EditorMethods(); (editor.isUsercss ? SourceEditor : SectionsEditor)();
editor.styleReady.then(async () => { await editor.ready;
EditorHeader(); editor.ready = true;
dispatchEvent(new Event('domReady'));
await (editor.isUsercss ? SourceEditor : SectionsEditor)();
editor.dirty.onChange(editor.updateDirty); editor.dirty.onChange(editor.updateDirty);
prefs.subscribe('editor.linter', () => linterMan.run());
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {runNow: true});
prefs.subscribe('editor.linter', (key, value) => {
document.body.classList.toggle('linter-disabled', value === '');
linterMan.run();
});
// enabling after init to prevent flash of validation failure on an empty name // enabling after init to prevent flash of validation failure on an empty name
$('#name').required = !editor.isUsercss; $('#name').required = !editor.isUsercss;
$('#save-button').onclick = editor.save; $('#save-button').onclick = editor.save;
$('#cancel-button').onclick = editor.cancel;
const elSec = $('#sections-list');
// editor.toc.expanded pref isn't saved in compact-layout so prefs.subscribe won't work // editor.toc.expanded pref isn't saved in compact-layout so prefs.subscribe won't work
if (elSec.open) editor.updateToc(); $('#sections-list').on('click', () => $('.compact-layout') && setTimeout(editor.updateToc),
// and we also toggle `open` directly in other places e.g. in detectLayout() {once: true});
new MutationObserver(() => elSec.open && editor.updateToc())
.observe(elSec, {attributes: true, attributeFilter: ['open']});
$('#toc').onclick = e => $('#toc').onclick = e =>
editor.jumpToEditor([...$('#toc').children].indexOf(e.target)); editor.jumpToEditor([...$('#toc').children].indexOf(e.target));
$('#keyMap-help').onclick = () => $('#keyMap-help').onclick = () =>
@ -45,65 +42,20 @@ editor.styleReady.then(async () => {
require(['/edit/linter-dialogs'], () => linterMan.showLintConfig()); require(['/edit/linter-dialogs'], () => linterMan.showLintConfig());
$('#lint-help').onclick = () => $('#lint-help').onclick = () =>
require(['/edit/linter-dialogs'], () => linterMan.showLintHelp()); require(['/edit/linter-dialogs'], () => linterMan.showLintHelp());
$('#style-settings-btn').onclick = () => require([
'/edit/settings.css',
'/edit/settings', /* global StyleSettings */
], () => StyleSettings());
require([ require([
'/edit/autocomplete', '/edit/autocomplete',
'/edit/drafts',
'/edit/global-search', '/edit/global-search',
]); ]);
}); });
editor.styleReady.then(async () => {
// Set up mini-header on scroll
const {isUsercss} = editor;
const el = $create({
style: `
top: 0;
height: 1px;
position: absolute;
visibility: hidden;
`.replace(/;/g, '!important;'),
});
const scroller = isUsercss ? $('.CodeMirror-scroll') : document.body;
const xoRoot = isUsercss ? scroller : undefined;
const xo = new IntersectionObserver(onScrolled, {root: xoRoot});
scroller.appendChild(el);
onCompactToggled(editor.mqCompact);
editor.mqCompact.on('change', onCompactToggled);
/** @param {MediaQueryList} mq */
function onCompactToggled(mq) {
for (const el of $$('details[data-pref]')) {
el.open = mq.matches ? false : prefs.get(el.dataset.pref);
}
if (mq.matches) {
xo.observe(el);
} else {
xo.disconnect();
}
}
/** @param {IntersectionObserverEntry[]} entries */
function onScrolled(entries) {
const h = $('#header');
const sticky = !entries.pop().isIntersecting;
if (!isUsercss) scroller.style.paddingTop = sticky ? h.offsetHeight + 'px' : '';
h.classList.toggle('sticky', sticky);
}
});
//#endregion
//#region events
msg.onExtension(request => { msg.onExtension(request => {
const {style} = request; const {style} = request;
switch (request.method) { switch (request.method) {
case 'styleUpdated': case 'styleUpdated':
if (editor.style.id === style.id) { if (editor.style.id === style.id &&
handleExternalUpdate(request); !['editPreview', 'editPreviewEnd', 'editSave', 'config'].includes(request.reason)) {
Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id))
.then(newStyle => editor.replaceStyle(newStyle, request.codeIsUpdated));
} }
break; break;
case 'styleDeleted': case 'styleDeleted':
@ -111,47 +63,17 @@ msg.onExtension(request => {
closeCurrentTab(); closeCurrentTab();
} }
break; break;
case 'editDeleteText':
document.execCommand('delete');
break;
} }
}); });
async function handleExternalUpdate({style, reason}) {
if (reason === 'editPreview' ||
reason === 'editPreviewEnd') {
return;
}
if (reason === 'editSave' && editor.saving) {
editor.saving = false;
return;
}
if (reason === 'toggle') {
if (editor.dirty.isDirty()) {
editor.toggleStyle(style.enabled);
} else {
Object.assign(editor.style, style);
}
editor.updateMeta();
editor.updateLivePreview();
return;
}
style = await API.styles.get(style.id);
if (reason === 'config') {
delete style.sourceCode;
delete style.sections;
delete style.name;
delete style.enabled;
Object.assign(editor.style, style);
} else {
await editor.replaceStyle(style);
}
window.dispatchEvent(new Event('styleSettings'));
}
window.on('beforeunload', e => { window.on('beforeunload', e => {
let pos; let pos;
if (editor.isWindowed && if (editor.isWindowed &&
document.visibilityState === 'visible' && document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') && prefs.get('openEditInWindow') &&
screenX !== -32000 && // Chrome uses this value for minimized windows
( // only if not maximized ( // only if not maximized
screenX > 0 || outerWidth < screen.availWidth || screenX > 0 || outerWidth < screen.availWidth ||
screenY > 0 || outerHeight < screen.availHeight || screenY > 0 || outerHeight < screen.availHeight ||
@ -168,7 +90,16 @@ window.on('beforeunload', e => {
prefs.set('windowPosition', pos); prefs.set('windowPosition', pos);
} }
sessionStore.windowPos = JSON.stringify(pos || {}); sessionStore.windowPos = JSON.stringify(pos || {});
sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify(editor.makeScrollInfo()); sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({
scrollY: window.scrollY,
cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({
bookmarks: (cm.state.sublimeBookmarks || []).map(b => b.find()),
focus: cm.hasFocus(),
height: cm.display.wrapper.style.height.replace('100vh', ''),
parentHeight: cm.display.wrapper.parentElement.offsetHeight,
sel: cm.isClean() && [cm.doc.sel.ranges, cm.doc.sel.primIndex],
})),
});
const activeElement = document.activeElement; const activeElement = document.activeElement;
if (activeElement) { if (activeElement) {
// blurring triggers 'change' or 'input' event if needed // blurring triggers 'change' or 'input' event if needed
@ -185,7 +116,7 @@ window.on('beforeunload', e => {
//#endregion //#endregion
//#region editor methods //#region editor methods
function EditorMethods() { (() => {
const toc = []; const toc = [];
const {dirty} = editor; const {dirty} = editor;
let {style} = editor; let {style} = editor;
@ -207,37 +138,17 @@ function EditorMethods() {
applyScrollInfo(cm, si = (editor.scrollInfo.cms || [])[0]) { applyScrollInfo(cm, si = (editor.scrollInfo.cms || [])[0]) {
if (si && si.sel) { if (si && si.sel) {
const bmOpts = {sublimeBookmark: true, clearWhenEmpty: false}; // copied from sublime.js const bmOpts = {sublimeBookmark: true, clearWhenEmpty: false}; // copied from sublime.js
cm.setSelections(...si.sel, {scroll: false}); cm.operation(() => {
cm.state.sublimeBookmarks = si.bookmarks.map(b => cm.markText(b.from, b.to, bmOpts)); cm.setSelections(...si.sel, {scroll: false});
Object.assign(cm.display.scroller, si.scroll); // for source editor cm.scrollIntoView(cm.getCursor(), si.parentHeight / 2);
Object.assign(cm.doc, si.scroll); // for sectioned editor cm.state.sublimeBookmarks = si.bookmarks.map(b => cm.markText(b.from, b.to, bmOpts));
});
} }
}, },
makeScrollInfo() { toggleStyle() {
return { $('#enabled').checked = !style.enabled;
scrollY: window.scrollY, editor.updateEnabledness(!style.enabled);
cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({
bookmarks: (cm.state.sublimeBookmarks || []).map(b => b.find()),
focus: cm.hasFocus(),
height: cm.display.wrapper.style.height.replace('100vh', ''),
parentHeight: cm.display.wrapper.parentElement.offsetHeight,
scroll: mapObj(cm.doc, null, ['scrollLeft', 'scrollTop']),
sel: [cm.doc.sel.ranges, cm.doc.sel.primIndex],
})),
};
},
async save() {
if (dirty.isDirty()) {
editor.saving = true;
await editor.saveImpl();
}
},
toggleStyle(enabled = !style.enabled) {
$('#enabled').checked = enabled;
editor.updateEnabledness(enabled);
}, },
updateDirty() { updateDirty() {
@ -303,18 +214,87 @@ function EditorMethods() {
el.classList.add(cls); el.classList.add(cls);
} }
}, },
useSavedStyle(newStyle) {
if (style.id !== newStyle.id) {
history.replaceState({}, '', `?id=${newStyle.id}`);
}
sessionStore.justEditedStyleId = newStyle.id;
Object.assign(style, newStyle);
editor.updateClass();
editor.updateMeta();
},
}); });
} })();
//#endregion
//#region editor livePreview
editor.livePreview = (() => {
let data;
let port;
let preprocess;
let enabled = prefs.get('editor.livePreview');
prefs.subscribe('editor.livePreview', (key, value) => {
if (!value) {
if (port) {
port.disconnect();
port = null;
}
} else if (data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
createPreviewer();
updatePreviewer(data);
}
enabled = value;
});
return {
/**
* @param {Function} [fn] - preprocessor
* @param {boolean} [show]
*/
init(fn, show) {
preprocess = fn;
if (show != null) toggle(show);
},
toggle,
update(newData) {
data = newData;
if (!port) {
if (!data.id || !data.enabled || !enabled) {
return;
}
createPreviewer();
}
updatePreviewer(data);
},
};
function createPreviewer() {
port = chrome.runtime.connect({name: 'livePreview'});
port.onDisconnect.addListener(err => {
throw err;
});
}
function toggle(state) {
$('#preview-label').classList.toggle('hidden', !state);
}
async function updatePreviewer(data) {
const errorContainer = $('#preview-errors');
try {
port.postMessage(preprocess ? await preprocess(data) : data);
errorContainer.classList.add('hidden');
} catch (err) {
if (Array.isArray(err)) {
err = err.join('\n');
} else if (err && err.index != null) {
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
const pos = editor.getEditors()[0].posFromIndex(err.index);
err.message = `${pos.line}:${pos.ch} ${err.message || err}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => {
messageBoxProxy.alert(err.message || `${err}`, 'pre');
};
}
}
})();
//#endregion //#endregion
//#region colorpickerHelper //#region colorpickerHelper
@ -372,15 +352,17 @@ function EditorMethods() {
cmFactory.globalSetOption('colorpicker', defaults.colorpicker); cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
}, {runNow: true}); }, {runNow: true});
await baseInit.domReady;
$('#colorpicker-settings').onclick = function (event) { $('#colorpicker-settings').onclick = function (event) {
event.preventDefault(); event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', {onDone: () => helpPopup.close()}); const input = createHotkeyInput('editor.colorpicker.hotkey', () => helpPopup.close());
const popup = helpPopup.show(t('helpKeyMapHotkey'), input); const popup = helpPopup.show(t('helpKeyMapHotkey'), input);
const bounds = this.getBoundingClientRect(); const bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px'; popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px'; popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto'; popup.style.right = 'auto';
$('input', popup).focus(); input.focus();
}; };
function invokeColorpicker(cm) { function invokeColorpicker(cm) {

View File

@ -2,7 +2,9 @@
'use strict'; 'use strict';
(() => { (() => {
let sugarss = false; const hasCurlyBraceError = warning =>
warning.text === 'Unnecessary curly bracket (CssSyntaxError)';
let sugarssFallback;
/** @namespace EditorWorker */ /** @namespace EditorWorker */
createWorkerApi({ createWorkerApi({
@ -14,38 +16,6 @@
.map(m => Object.assign(m, {rule: {id: m.rule.id}})); .map(m => Object.assign(m, {rule: {id: m.rule.id}}));
}, },
getCssPropsValues() {
require(['/js/csslint/parserlib']); /* global parserlib */
const {
css: {Colors, GlobalKeywords, Properties},
util: {describeProp},
} = parserlib;
const namedColors = Object.keys(Colors);
const rxNonWord = /(?:<.+?>|[^-\w<(]+\d*)+/g;
const res = {};
// moving vendor-prefixed props to the end
const cmp = (a, b) => a[0] === '-' && b[0] !== '-' ? 1 : a < b ? -1 : a > b;
for (const [k, v] of Object.entries(Properties)) {
res[k] = false;
if (typeof v === 'string') {
let last = '';
const uniq = [];
// strip definitions of function arguments
const desc = describeProp(v).replace(/([-\w]+)\(.*?\)/g, 'z-$1');
const descNoColors = desc.replace(/<named-color>/g, '');
// add a prefix to functions to group them at the end
const words = descNoColors.split(rxNonWord).sort(cmp);
for (let w of words) {
if (w.startsWith('z-')) w = w.slice(2) + '(';
if (w !== last) uniq.push(last = w);
}
if (desc !== descNoColors) uniq.push(...namedColors);
if (uniq.length) res[k] = uniq;
}
}
return {all: res, global: GlobalKeywords};
},
getRules(linter) { getRules(linter) {
return ruleRetriever[linter](); // eslint-disable-line no-use-before-define return ruleRetriever[linter](); // eslint-disable-line no-use-before-define
}, },
@ -65,32 +35,23 @@
async stylelint(opts) { async stylelint(opts) {
require(['/vendor/stylelint-bundle/stylelint-bundle.min']); /* global stylelint */ require(['/vendor/stylelint-bundle/stylelint-bundle.min']); /* global stylelint */
// Stylus-lang allows a trailing ";" but sugarss doesn't, so we monkeypatch it try {
stylelint.SugarSSParser.prototype.checkSemicolon = tt => { let res;
while (tt.length && tt[tt.length - 1][0] === ';') tt.pop(); let pass = 0;
}; /* sugarss is used for stylus-lang by default,
for (const pass of opts.mode === 'stylus' ? [sugarss, !sugarss] : [-1]) { but it fails on normal css syntax so we retry in css mode. */
/* We try sugarss (for indented stylus-lang), then css mode, switching them on failure, const isSugarSS = opts.syntax === 'sugarss';
* so that the succeeding syntax will be used next time first. */ if (sugarssFallback && isSugarSS) opts.syntax = sugarssFallback;
opts.config.customSyntax = !pass ? 'sugarss' : ''; while (
try { ++pass <= 2 &&
const res = await stylelint.createLinter(opts)._lintSource(opts); (res = (await stylelint.lint(opts)).results[0]) &&
if (pass !== -1) sugarss = pass; isSugarSS && res.warnings.some(hasCurlyBraceError)
return collectStylelintResults(res, opts); ) sugarssFallback = opts.syntax = 'css';
} catch (e) { delete res._postcssResult; // huge and unused
const fatal = pass === -1 || return res;
!pass && !/^CssSyntaxError:.+?Unnecessary curly bracket/.test(e) || } catch (e) {
pass && !/^CssSyntaxError:.+?Unknown word[\s\S]*?\.decl\s/.test(`${e}${e.stack}`); delete e.postcssNode; // huge, unused, non-transferable
if (fatal) { throw e;
return [{
from: {line: e.line - 1, ch: e.column - 1},
to: {line: e.line - 1, ch: e.column - 1},
message: e.reason,
severity: 'error',
rule: e.name,
}];
}
}
} }
}, },
}); });
@ -145,32 +106,4 @@
return options; return options;
}, },
}; };
function collectStylelintResults({messages}, {mode}) {
/* We hide nonfatal "//" warnings since we lint with sugarss without applying @preprocessor.
* We can't easily pre-remove "//" comments which may be inside strings, comments, url(), etc.
* And even if we did, it'd be wrong to hide potential bugs in stylus-lang like #1460 */
const isLess = mode === 'text/x-less';
const slashCommentAllowed = isLess || mode === 'stylus';
const res = [];
for (const m of messages) {
if (/deprecation|invalidOption/.test(m.stylelintType)) {
continue;
}
const {rule} = m;
const msg = m.text.replace(/^Unexpected\s+/, '').replace(` (${rule})`, '');
if (slashCommentAllowed && msg.includes('"//"') ||
isLess && /^unknown at-rule "@[-\w]+:"/.test(msg) /* LESS variables */) {
continue;
}
res.push({
from: {line: m.line - 1, ch: m.column - 1},
to: {line: m.endLine - 1, ch: m.endColumn - 1},
message: msg[0].toUpperCase() + msg.slice(1),
severity: m.severity,
rule,
});
}
return res;
}
})(); })();

View File

@ -1,5 +1,6 @@
/* global $ $create $remove getEventKeyName */// dom.js /* global $ $create $remove getEventKeyName */// dom.js
/* global CodeMirror */ /* global CodeMirror */
/* global baseInit */// base.js
/* global prefs */ /* global prefs */
/* global t */// localization.js /* global t */// localization.js
'use strict'; 'use strict';
@ -19,13 +20,12 @@
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY, title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup, onclick: embedPopup,
}); });
$.root.appendChild(btn); document.documentElement.appendChild(btn);
$.rootCL.add('popup-window'); baseInit.domReady.then(() => {
window.on('domReady', () => {
document.body.appendChild(btn); document.body.appendChild(btn);
// Adding a dummy command to show in keymap help popup // Adding a dummy command to show in keymap help popup
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup'; CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup';
}, {once: true}); });
prefs.subscribe('iconset', (_, val) => { prefs.subscribe('iconset', (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`; const prefix = `images/icon/${val ? 'light/' : ''}`;
@ -60,9 +60,14 @@
const body = pw.document.body; const body = pw.document.body;
pw.on('keydown', removePopupOnEsc); pw.on('keydown', removePopupOnEsc);
pw.close = removePopup; pw.close = removePopup;
new pw.IntersectionObserver(onIntersect).observe(body.appendChild( if (pw.IntersectionObserver) {
$create('div', {style: {height: '1px', marginTop: '-1px'}}) new pw.IntersectionObserver(onIntersect).observe(body.appendChild(
)); $create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(onMutation).observe(body, { new pw.MutationObserver(onMutation).observe(body, {
attributes: true, attributes: true,
attributeFilter: ['style'], attributeFilter: ['style'],

View File

@ -1,4 +1,4 @@
/* global $ $$ $create $remove focusAccessibility setInputValue toggleDataset */// dom.js /* global $ $$ $create $remove focusAccessibility */// dom.js
/* global CodeMirror */ /* global CodeMirror */
/* global chromeLocal */// storage-util.js /* global chromeLocal */// storage-util.js
/* global colorMimicry */ /* global colorMimicry */
@ -54,7 +54,7 @@
undoHistory: [], undoHistory: [],
searchInApplies: !editor.isUsercss, searchInApplies: !document.documentElement.classList.contains('usercss'),
}; };
//endregion //endregion
@ -70,9 +70,7 @@
if (found) { if (found) {
const target = $('.' + TARGET_CLASS); const target = $('.' + TARGET_CLASS);
const cm = target.CodeMirror; const cm = target.CodeMirror;
/* Since this runs in `keydown` event we have to delay focusing (cm || target).focus();
* to prevent CodeMirror from seeing and handling the key */
setTimeout(() => (cm || target).focus());
if (cm) { if (cm) {
const {from, to} = cm.state.search.searchPos; const {from, to} = cm.state.search.searchPos;
cm.jumpToPos(from, to); cm.jumpToPos(from, to);
@ -588,7 +586,7 @@
input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}), input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}), icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
}; };
$.root.appendChild( document.documentElement.appendChild(
$(DIALOG_STYLE_SELECTOR) || $(DIALOG_STYLE_SELECTOR) ||
$create('style' + DIALOG_STYLE_SELECTOR) $create('style' + DIALOG_STYLE_SELECTOR)
).textContent = ` ).textContent = `
@ -607,10 +605,10 @@
} }
#search-replace-dialog[data-type="replace"] button:hover svg, #search-replace-dialog[data-type="replace"] button:hover svg,
#search-replace-dialog svg:hover { #search-replace-dialog svg:hover {
fill: var(--cmin); fill: inherit;
} }
#search-replace-dialog [data-action="case"]:hover { #search-replace-dialog [data-action="case"]:hover {
color: var(--cmin); color: inherit;
} }
#search-replace-dialog [data-action="clear"] { #search-replace-dialog [data-action="clear"] {
background-color: ${colors.input.bg.replace(/[^,]+$/, '') + '.75)'}; background-color: ${colors.input.bg.replace(/[^,]+$/, '') + '.75)'};
@ -680,7 +678,7 @@
el.style.width = newWidth + 'px'; el.style.width = newWidth + 'px';
} }
const numLines = el.value.split('\n').length; const numLines = el.value.split('\n').length;
if (numLines !== Number(el.rows)) { if (numLines !== parseInt(el.rows)) {
el.rows = numLines; el.rows = numLines;
} }
el.style.overflowX = el.scrollWidth > el.clientWidth ? '' : 'hidden'; el.style.overflowX = el.scrollWidth > el.clientWidth ? '' : 'hidden';
@ -876,6 +874,15 @@
} }
function toggleDataset(el, prop, state) {
if (state) {
el.dataset[prop] = '';
} else {
delete el.dataset[prop];
}
}
function saveWindowScrollPos() { function saveWindowScrollPos() {
state.scrollX = window.scrollX; state.scrollX = window.scrollX;
state.scrollY = window.scrollY; state.scrollY = window.scrollY;
@ -930,5 +937,18 @@
}))); })));
} }
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 //endregion
})(); })();

View File

@ -90,7 +90,7 @@
return rule && return rule &&
$create('li', [ $create('li', [
$create('b', ruleID + ': '), $create('b', ruleID + ': '),
rule.url ? $createLink(rule.url, rule.name) : $create('span', `"${rule.name}"`), rule.url ? $createLink(`"${rule.url}"`, rule.name) : $create('span', `"${rule.name}"`),
$create('p', rule.desc), $create('p', rule.desc),
]); ]);
}; };

View File

@ -79,7 +79,7 @@ const linterMan = (() => {
function getCachedAnnotations(code, opt, cm) { function getCachedAnnotations(code, opt, cm) {
const results = cms.get(cm); const results = cms.get(cm);
cms.set(cm, null); cms.set(cm, null);
cm.state.lint.options.getAnnotations = getAnnotations; cm.options.lint.getAnnotations = getAnnotations;
return results; return results;
} }
@ -131,9 +131,7 @@ linterMan.DEFAULTS = {
'duplicate-properties': 1, 'duplicate-properties': 1,
'empty-rules': 1, 'empty-rules': 1,
'errors': 1, 'errors': 1,
'globals-in-document': 1,
'known-properties': 1, 'known-properties': 1,
'known-pseudos': 1,
'selector-newline': 1, 'selector-newline': 1,
'shorthand-overrides': 1, 'shorthand-overrides': 1,
'simple-not': 1, 'simple-not': 1,
@ -202,7 +200,35 @@ linterMan.DEFAULTS = {
getConfig: config => ({ getConfig: config => ({
rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules), rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules),
}), }),
lint: (code, config, mode) => worker.stylelint({code, config, mode}), async lint(code, config, mode) {
const isLess = mode === 'text/x-less';
const isStylus = mode === 'stylus';
const syntax = isLess ? 'less' : isStylus ? 'sugarss' : 'css';
const raw = await worker.stylelint({code, config, syntax});
if (!raw) {
return [];
}
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
// and we can't just pre-remove the comments since "//" may be inside a string token
const slashCommentAllowed = isLess || isStylus;
const res = [];
for (const w of raw.warnings) {
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
if (!slashCommentAllowed || !(
w.rule === 'no-invalid-double-slash-comments' ||
w.rule === 'property-no-unknown' && msg.includes('"//"')
)) {
res.push({
from: {line: w.line - 1, ch: w.column - 1},
to: {line: w.line - 1, ch: w.column},
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
severity: w.severity,
rule: w.rule,
});
}
}
return res;
},
}, },
}; };
@ -287,7 +313,7 @@ linterMan.DEFAULTS = {
function updateCount() { function updateCount() {
const issueCount = Array.from(tables.values()) const issueCount = Array.from(tables.values())
.reduce((sum, table) => sum + table.trs.length, 0); .reduce((sum, table) => sum + table.trs.length, 0);
$('#lint').hidden = !issueCount; $('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
$('#issue-count').textContent = issueCount; $('#issue-count').textContent = issueCount;
} }
@ -303,20 +329,19 @@ linterMan.DEFAULTS = {
} }
function createTable(cm) { function createTable(cm) {
const caption = $create('.caption'); const caption = $create('caption');
const table = $create('table'); const tbody = $create('tbody');
const report = $create('.report', [caption, table]); const table = $create('table', [caption, tbody]);
const trs = []; const trs = [];
return { return {
element: report, element: table,
trs, trs,
updateAnnotations, updateAnnotations,
updateCaption, updateCaption,
}; };
function updateCaption() { function updateCaption() {
const t = editor.getEditorTitle(cm); caption.textContent = editor.getEditorTitle(cm);
Object.assign(caption, typeof t == 'string' ? {textContent: t} : t);
} }
function updateAnnotations(lines) { function updateAnnotations(lines) {
@ -328,20 +353,20 @@ linterMan.DEFAULTS = {
} else { } else {
tr = createTr(); tr = createTr();
trs.push(tr); trs.push(tr);
table.appendChild(tr.element); tbody.append(tr.element);
} }
tr.update(anno); tr.update(anno);
i++; i++;
} }
if (i === 0) { if (i === 0) {
trs.length = 0; trs.length = 0;
table.textContent = ''; tbody.textContent = '';
} else { } else {
while (trs.length > i) { while (trs.length > i) {
trs.pop().element.remove(); trs.pop().element.remove();
} }
} }
report.classList.toggle('empty', !trs.length); table.classList.toggle('empty', trs.length === 0);
function *getAnnotations() { function *getAnnotations() {
for (const line of lines.filter(Boolean)) { for (const line of lines.filter(Boolean)) {

View File

@ -4,7 +4,9 @@
/* global colorMimicry */ /* global colorMimicry */
/* global editor */ /* global editor */
/* global msg */ /* global msg */
/* global prefs */
/* global t */// localization.js /* global t */// localization.js
/* global tryCatch */// toolbox.js
'use strict'; 'use strict';
/* exported MozSectionWidget */ /* exported MozSectionWidget */
@ -38,12 +40,11 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
}; };
function init() { function init() {
const hint = {title: t('appliesHelp')};
enabled = true; enabled = true;
TPL = { TPL = {
container: container:
$create('div' + C_CONTAINER, [ $create('div' + C_CONTAINER, [
$create(C_LABEL, hint, t('appliesLabel')), $create(C_LABEL, t('appliesLabel')),
$create('ul' + C_LIST), $create('ul' + C_LIST),
]), ]),
listItem: listItem:
@ -52,9 +53,8 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
$create('li.applies-to-everything', t('appliesToEverything')), $create('li.applies-to-everything', t('appliesToEverything')),
}; };
Object.assign($(C_TYPE, TPL.listItem), hint);
$(C_VALUE, TPL.listItem).after( $(C_VALUE, TPL.listItem).after(
$create('button.test-regexp', t('genericTest'))); $create('button.test-regexp', t('styleRegexpTestButton')));
CLICK_ROUTE = { CLICK_ROUTE = {
'.test-regexp': showRegExpTester, '.test-regexp': showRegExpTester,
@ -129,8 +129,7 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
update(finder.sections, []); update(finder.sections, []);
} }
finder.on(update); finder.on(update);
updateWidgetStyle(); // updating in this paint frame to avoid FOUC for dark themes requestAnimationFrame(updateWidgetStyle);
cm.display.wrapper.style.setProperty('--cm-bar-width', cm.display.barWidth + 'px');
} }
function destroy() { function destroy() {
@ -156,7 +155,6 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
} }
if (msg.style || msg.styles || if (msg.style || msg.styles ||
msg.prefs && 'disableAll' in msg.prefs || msg.prefs && 'disableAll' in msg.prefs ||
msg.method === 'colorScheme' ||
msg.method === 'styleDeleted') { msg.method === 'styleDeleted') {
requestAnimationFrame(updateWidgetStyle); requestAnimationFrame(updateWidgetStyle);
} }
@ -164,6 +162,11 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
function updateWidgetStyle() { function updateWidgetStyle() {
funcHeight = 0; funcHeight = 0;
if (prefs.get('editor.theme') !== 'default' &&
!tryCatch(() => $('#cm-theme').sheet.cssRules)) {
requestAnimationFrame(updateWidgetStyle);
return;
}
const MIN_LUMA = .05; const MIN_LUMA = .05;
const MIN_LUMA_DIFF = .4; const MIN_LUMA_DIFF = .4;
const color = { const color = {
@ -199,6 +202,7 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
color: ${fore}; color: ${fore};
} }
${C_CONTAINER} input, ${C_CONTAINER} input,
${C_CONTAINER} button,
${C_CONTAINER} select { ${C_CONTAINER} select {
background: rgba(255, 255, 255, ${ background: rgba(255, 255, 255, ${
Math.max(MIN_LUMA, Math.pow(Math.max(0, color.gutter.bgLuma - MIN_LUMA * 2), 2)).toFixed(2) Math.max(MIN_LUMA, Math.pow(Math.max(0, color.gutter.bgLuma - MIN_LUMA * 2), 2)).toFixed(2)
@ -212,7 +216,7 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
transition: none; transition: none;
} }
`; `;
$.root.appendChild(actualStyle); document.documentElement.appendChild(actualStyle);
} }
/** /**
@ -269,27 +273,19 @@ function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
let widget = old && old.widget; let widget = old && old.widget;
const height = Math.round(funcHeight * (sec.funcs.length || 1)) || undefined; const height = Math.round(funcHeight * (sec.funcs.length || 1)) || undefined;
const node = renderContainer(sec, widget); const node = renderContainer(sec, widget);
if (widget && widget.line.lineNo() === sec.start.line) { if (widget) {
widget.node = node; widget.node = node;
if (height && height !== widget.height) { if (height && height !== widget.height) {
widget.height = height; widget.height = height;
widget.changed(); widget.changed();
} }
} else { } else {
if (widget) widget.clear();
widget = cm.addLineWidget(sec.start.line, node, { widget = cm.addLineWidget(sec.start.line, node, {
coverGutter: true, coverGutter: true,
noHScroll: true, noHScroll: true,
above: true, above: true,
height, height,
}); });
widget.on('redraw', () => {
const value = cm.display.barWidth + 'px';
if (widget[KEY] !== value) {
widget[KEY] = value;
node.style.setProperty('--cm-bar-width', value);
}
});
} }
if (!funcHeight) { if (!funcHeight) {
funcHeight = node.offsetHeight / (sec.funcs.length || 1); funcHeight = node.offsetHeight / (sec.funcs.length || 1);

View File

@ -5,23 +5,24 @@
'use strict'; 'use strict';
const regexpTester = (() => { const regexpTester = (() => {
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
const OWN_ICON = chrome.runtime.getManifest().icons['16']; const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const cachedRegexps = new Map(); const cachedRegexps = new Map();
let currentRegexps = []; let currentRegexps = [];
let isWatching = false; let isWatching = false;
let isShown = false; let isShown = false;
window.on('closeHelp', () => regexpTester.toggle(false));
return { return {
toggle(state = !isShown) { toggle(state = !isShown) {
if (state && !isShown) { if (state && !isShown) {
if (!isWatching) { if (!isWatching) {
isWatching = true; isWatching = true;
chrome.tabs.onRemoved.addListener(onTabRemoved); chrome.tabs.onUpdated.addListener(onTabUpdate);
chrome.tabs.onUpdated.addListener(onTabUpdated);
} }
helpPopup.show('', $create('.regexp-report')); helpPopup.show('', $create('.regexp-report'));
window.on('closeHelp', () => regexpTester.toggle(false), {once: true});
isShown = true; isShown = true;
} else if (!state && isShown) { } else if (!state && isShown) {
unwatch(); unwatch();
@ -92,15 +93,15 @@ const regexpTester = (() => {
for (const [url, match] of urls.entries()) { for (const [url, match] of urls.entries()) {
const faviconUrl = url.startsWith(URLS.ownOrigin) const faviconUrl = url.startsWith(URLS.ownOrigin)
? OWN_ICON ? OWN_ICON
: URLS.favicon(new URL(url).hostname); : GET_FAVICON_URL + new URL(url).hostname;
const icon = $create('img', {src: faviconUrl}); const icon = $create('img', {src: faviconUrl});
if (match.text.length === url.length) { if (match.text.length === url.length) {
full.push($create('a', {tabIndex: 0}, [ full.push($create('a', {href: '#'}, [
icon, icon,
url, url,
])); ]));
} else { } else {
partial.push($create('a', {tabIndex: 0}, [ partial.push($create('a', {href: '#'}, [
icon, icon,
url.substr(0, match.pos), url.substr(0, match.pos),
$create('mark', match.text), $create('mark', match.text),
@ -135,7 +136,8 @@ const regexpTester = (() => {
block.appendChild( block.appendChild(
$create('details', {open: true}, [ $create('details', {open: true}, [
$create('summary', text), $create('summary', text),
$create('div', urls), // 3rd level: tab urls
...urls,
])); ]));
} else { } else {
// type is none or invalid // type is none or invalid
@ -152,26 +154,28 @@ const regexpTester = (() => {
.split(/(\\+)/) .split(/(\\+)/)
.map(s => (s.startsWith('\\') ? $create('code', s) : s))); .map(s => (s.startsWith('\\') ? $create('code', s) : s)));
report.appendChild(note); report.appendChild(note);
report.style.paddingBottom = note.offsetHeight + 'px'; adjustNote(report, note);
}, },
}; };
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
}
function onClick(event) { function onClick(event) {
const a = event.target.closest('a, button'); const a = event.target.closest('a');
if (a) { if (a) {
event.preventDefault(); event.preventDefault();
openURL({ openURL({
url: a.href || a.textContent, url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
currentWindow: null, currentWindow: null,
}); });
} else if (event.target.closest('details')) {
setTimeout(adjustNote);
} }
} }
function onTabRemoved() { function onTabUpdate(tabId, info) {
regexpTester.update();
}
function onTabUpdated(tabId, info) {
if (info.url) { if (info.url) {
regexpTester.update(); regexpTester.update();
} }
@ -179,8 +183,7 @@ const regexpTester = (() => {
function unwatch() { function unwatch() {
if (isWatching) { if (isWatching) {
chrome.tabs.onRemoved.removeListener(onTabRemoved); chrome.tabs.onUpdated.removeListener(onTabUpdate);
chrome.tabs.onUpdated.removeListener(onTabUpdated);
isWatching = false; isWatching = false;
} }
} }

View File

@ -1,4 +1,4 @@
/* global $ toggleDataset */// dom.js /* global $ */// dom.js
/* global MozDocMapper trimCommentLabel */// util.js /* global MozDocMapper trimCommentLabel */// util.js
/* global cmFactory */ /* global cmFactory */
/* global debounce tryRegExp */// toolbox.js /* global debounce tryRegExp */// toolbox.js
@ -23,7 +23,7 @@ function createSection(originalSection, genId, si) {
const elLabel = $('.code-label', el); const elLabel = $('.code-label', el);
const cm = cmFactory.create(wrapper => { const cm = cmFactory.create(wrapper => {
// making it tall during initial load so IntersectionObserver sees only one adjacent CM // making it tall during initial load so IntersectionObserver sees only one adjacent CM
if (editor.loading) { if (editor.ready !== true) {
wrapper.style.height = si ? si.height : '100vh'; wrapper.style.height = si ? si.height : '100vh';
} }
elLabel.after(wrapper); elLabel.after(wrapper);
@ -31,13 +31,11 @@ function createSection(originalSection, genId, si) {
value: originalSection.code, value: originalSection.code,
}); });
el.CodeMirror = cm; // used by getAssociatedEditor el.CodeMirror = cm; // used by getAssociatedEditor
cm.el = el;
editor.applyScrollInfo(cm, si); editor.applyScrollInfo(cm, si);
const changeListeners = new Set(); const changeListeners = new Set();
const appliesToContainer = $('.applies-to', el); const appliesToContainer = $('.applies-to-list', el);
const appliesToList = $('.applies-to-list', el);
const appliesTo = []; const appliesTo = [];
MozDocMapper.forEachProp(originalSection, (type, value) => MozDocMapper.forEachProp(originalSection, (type, value) =>
insertApplyAfter({type, value})); insertApplyAfter({type, value}));
@ -115,21 +113,66 @@ function createSection(originalSection, genId, si) {
changeGeneration = newGeneration; changeGeneration = newGeneration;
emitSectionChange('code'); emitSectionChange('code');
}); });
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
$('.test-regexp', el).onclick = () => updateRegexpTester(true); $('.test-regexp', el).onclick = () => updateRegexpTester(true);
initBeautifyButton($('.beautify-section', el), [cm]); initBeautifyButton($('.beautify-section', el), [cm]);
} }
function handleKeydown(cm, event) {
if (event.shiftKey || event.altKey || event.metaKey) {
return;
}
const {key} = event;
const {line, ch} = cm.getCursor();
switch (key) {
case 'ArrowLeft':
if (line || ch) {
return;
}
// fallthrough
case 'ArrowUp':
cm = line === 0 && editor.prevEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(cm.doc.size - 1, key === 'ArrowLeft' ? 1e20 : ch);
break;
case 'ArrowRight':
if (line < cm.doc.size - 1 || ch < cm.getLine(line).length - 1) {
return;
}
// fallthrough
case 'ArrowDown':
cm = line === cm.doc.size - 1 && editor.nextEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(0, 0);
break;
}
}
async function updateRegexpTester(toggle) { async function updateRegexpTester(toggle) {
const isLoaded = typeof regexpTester === 'object' || const isLoaded = typeof regexpTester === 'object';
toggle && await require(['/edit/regexp-tester']); /* global regexpTester */ if (toggle && !isLoaded) {
if (toggle != null) { await require(['/edit/regexp-tester']); /* global regexpTester */
}
if (toggle != null && isLoaded) {
regexpTester.toggle(toggle); regexpTester.toggle(toggle);
} }
const regexps = appliesTo.filter(a => a.type === 'regexp') const regexps = appliesTo.filter(a => a.type === 'regexp')
.map(a => a.value); .map(a => a.value);
const hasRe = regexps.length > 0; if (regexps.length) {
if (hasRe && isLoaded) regexpTester.update(regexps); el.classList.add('has-regexp');
el.classList.toggle('has-regexp', hasRe); if (isLoaded) regexpTester.update(regexps);
} else {
el.classList.remove('has-regexp');
if (isLoaded) regexpTester.toggle(false);
}
} }
function updateTocEntry(origin) { function updateTocEntry(origin) {
@ -197,13 +240,11 @@ function createSection(originalSection, genId, si) {
function insertApplyAfter(init, base) { function insertApplyAfter(init, base) {
const apply = createApply(init); const apply = createApply(init);
appliesTo.splice(base ? appliesTo.indexOf(base) + 1 : appliesTo.length, 0, apply); appliesTo.splice(base ? appliesTo.indexOf(base) + 1 : appliesTo.length, 0, apply);
appliesToList.insertBefore(apply.el, base ? base.el.nextSibling : null); appliesToContainer.insertBefore(apply.el, base ? base.el.nextSibling : null);
toggleDataset(appliesToContainer, 'all', init.all);
dirty.add(apply, apply); dirty.add(apply, apply);
if (appliesTo.length > 1 && appliesTo[0].all) { if (appliesTo.length > 1 && appliesTo[0].all) {
removeApply(appliesTo[0]); removeApply(appliesTo[0]);
} }
if (base) requestAnimationFrame(shrinkSectionBy1);
emitSectionChange('apply'); emitSectionChange('apply');
return apply; return apply;
} }
@ -312,15 +353,6 @@ function createSection(originalSection, genId, si) {
dirty.add(`${dirtyPrefix}.value`, value); dirty.add(`${dirtyPrefix}.value`, value);
} }
} }
function shrinkSectionBy1() {
const cmEl = cm.display.wrapper;
const cmH = cmEl.offsetHeight;
const viewH = el.parentElement.offsetHeight;
if (el.offsetHeight > viewH && cmH > Math.min(viewH / 2, cm.display.sizer.offsetHeight + 30)) {
cmEl.style.height = (cmH - appliesToContainer.offsetHeight / (appliesTo.length || 1) | 0) + 'px';
}
}
} }
function createResizeGrip(cm) { function createResizeGrip(cm) {
@ -349,7 +381,8 @@ function createResizeGrip(cm) {
cm.display.lineDiv.offsetParent.offsetTop + cm.display.lineDiv.offsetParent.offsetTop +
/* borders */ /* borders */
wrapper.offsetHeight - wrapper.clientHeight; wrapper.offsetHeight - wrapper.clientHeight;
document.body.classList.add('resizing-v'); wrapper.style.pointerEvents = 'none';
document.body.style.cursor = 's-resize';
document.on('mousemove', resize); document.on('mousemove', resize);
document.on('mouseup', resizeStop); document.on('mouseup', resizeStop);
@ -365,7 +398,8 @@ function createResizeGrip(cm) {
function resizeStop() { function resizeStop() {
document.off('mouseup', resizeStop); document.off('mouseup', resizeStop);
document.off('mousemove', resize); document.off('mousemove', resize);
document.body.classList.remove('resizing-v'); wrapper.style.pointerEvents = '';
document.body.style.cursor = '';
} }
}; };

View File

@ -1,13 +1,12 @@
/* global $ $create $remove messageBoxProxy */// dom.js /* global $ $$ $create $remove messageBoxProxy */// dom.js
/* global API */// msg.js /* global API */// msg.js
/* global CodeMirror */ /* global CodeMirror */
/* global RX_META debounce */// toolbox.js /* global FIREFOX RX_META debounce ignoreChromeError sessionStore */// toolbox.js
/* global MozDocMapper clipString helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js /* global MozDocMapper clipString helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
/* global createSection */// sections-editor-section.js /* global createSection */// sections-editor-section.js
/* global editor */ /* global editor */
/* global linterMan */ /* global linterMan */
/* global prefs */ /* global prefs */
/* global styleSectionsEqual */ // sections-util.js
/* global t */// localization.js /* global t */// localization.js
'use strict'; 'use strict';
@ -17,25 +16,25 @@ function SectionsEditor() {
const container = $('#sections'); const container = $('#sections');
/** @type {EditorSection[]} */ /** @type {EditorSection[]} */
const sections = []; const sections = [];
const xo = new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'}); const xo = window.IntersectionObserver &&
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
let INC_ID = 0; // an increment id that is used by various object to track the order let INC_ID = 0; // an increment id that is used by various object to track the order
let sectionOrder = ''; let sectionOrder = '';
let headerOffset; // in compact mode the header is at the top so it reduces the available height let headerOffset; // in compact mode the header is at the top so it reduces the available height
let cmExtrasHeight; // resize grip + borders
let upDownJumps;
updateMeta(); updateHeader();
rerouteHotkeys.toggle(true); // enabled initially because we don't always focus a CodeMirror rerouteHotkeys.toggle(true); // enabled initially because we don't always focus a CodeMirror
editor.livePreview.init(); editor.livePreview.init(null, style.id);
container.classList.add('section-editor');
$('#to-mozilla').on('click', showMozillaFormat); $('#to-mozilla').on('click', showMozillaFormat);
$('#to-mozilla-help').on('click', showToMozillaHelp); $('#to-mozilla-help').on('click', showToMozillaHelp);
$('#from-mozilla').on('click', () => showMozillaFormatImport()); $('#from-mozilla').on('click', () => showMozillaFormatImport());
document.on('wheel', scrollEntirePageOnCtrlShift, {passive: false}); document.on('wheel', scrollEntirePageOnCtrlShift, {passive: false});
CodeMirror.defaults.extraKeys['Shift-Ctrl-Wheel'] = 'scrollWindow'; CodeMirror.defaults.extraKeys['Shift-Ctrl-Wheel'] = 'scrollWindow';
prefs.subscribe('editor.arrowKeysTraverse', (_, val) => { if (!FIREFOX) {
for (const {cm} of sections) handleKeydownSetup(cm, val); $$('input:not([type]), input[type=text], input[type=search], input[type=number]')
upDownJumps = val; .forEach(e => e.on('mousedown', toggleContextMenuDelete));
}, {runNow: true}); }
/** @namespace Editor */ /** @namespace Editor */
Object.assign(editor, { Object.assign(editor, {
@ -44,23 +43,14 @@ function SectionsEditor() {
closestVisible, closestVisible,
updateLivePreview, updateLivePreview,
updateMeta,
getEditors() { getEditors() {
return sections.filter(s => !s.removed).map(s => s.cm); return sections.filter(s => !s.removed).map(s => s.cm);
}, },
getEditorTitle(cm) { getEditorTitle(cm) {
const index = editor.getEditors().indexOf(cm) + 1; const index = editor.getEditors().indexOf(cm);
return { return `${t('sectionCode')} ${index + 1}`;
textContent: `#${index}`,
title: `${t('sectionCode')} ${index}`,
};
},
getValue(asObject) {
const st = getModel();
return asObject ? st : MozDocMapper.styleToCss(st);
}, },
getSearchableInputs(cm) { getSearchableInputs(cm) {
@ -76,62 +66,62 @@ function SectionsEditor() {
} }
}, },
nextEditor(cm, upDown) { nextEditor(cm, cycle = true) {
return !upDown || cm !== findLast(sections, s => !s.removed).cm return cycle || cm !== findLast(sections, s => !s.removed).cm
? nextPrevEditor(cm, 1, upDown) ? nextPrevEditor(cm, 1)
: null; : null;
}, },
prevEditor(cm, upDown) { prevEditor(cm, cycle = true) {
return !upDown || cm !== sections.find(s => !s.removed).cm return cycle || cm !== sections.find(s => !s.removed).cm
? nextPrevEditor(cm, -1, upDown) ? nextPrevEditor(cm, -1)
: null; : null;
}, },
async replaceStyle(newStyle, draft) { async replaceStyle(newStyle, codeIsUpdated) {
const sameCode = styleSectionsEqual(newStyle, getModel()); dirty.clear('name');
if (!sameCode && !draft && !await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) {
return;
}
if (!draft) {
dirty.clear();
}
// FIXME: avoid recreating all editors? // FIXME: avoid recreating all editors?
if (!sameCode) { if (codeIsUpdated !== false) {
await initSections(newStyle.sections, { await initSections(newStyle.sections, {replace: true});
keepDirty: draft,
replace: true,
si: draft && draft.si,
});
} }
editor.useSavedStyle(newStyle); Object.assign(style, newStyle);
updateHeader();
dirty.clear();
// Go from new style URL to edit style URL
if (location.href.indexOf('id=') === -1 && style.id) {
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
$('#heading').textContent = t('editStyleHeading');
}
editor.livePreview.toggle(Boolean(style.id));
updateLivePreview(); updateLivePreview();
}, },
async saveImpl() { async save() {
if (!dirty.isDirty()) {
return;
}
let newStyle = getModel(); let newStyle = getModel();
if (!validate(newStyle)) { if (!validate(newStyle)) {
return; return;
} }
newStyle = await API.styles.editSave(newStyle); newStyle = await API.styles.editSave(newStyle);
dirty.clear(); destroyRemovedSections();
editor.useSavedStyle(newStyle); sessionStore.justEditedStyleId = newStyle.id;
editor.replaceStyle(newStyle, false);
}, },
scrollToEditor(cm, partial) { scrollToEditor(cm) {
const cc = partial && cm.cursorCoords(true, 'window'); const {el} = sections.find(s => s.cm === cm);
const {top: y1, bottom: y2} = cm.el.getBoundingClientRect(); const r = el.getBoundingClientRect();
const rc = container.getBoundingClientRect(); const h = window.innerHeight;
const rcY1 = Math.max(rc.top, 0); if (r.bottom > h && r.top > 0 ||
const rcY2 = Math.min(rc.bottom, innerHeight); r.bottom < h && r.top < 0) {
const bad = partial window.scrollBy(0, (r.top + r.bottom - h) / 2 | 0);
? cc.top < rcY1 || cc.top > rcY2 - 30 }
: y1 >= rcY1 ^ y2 <= rcY2;
if (bad) window.scrollBy(0, (y1 + y2 - rcY2 + rcY1) / 2 | 0);
}, },
}); });
return initSections(style.sections); editor.ready = initSections(style.sections);
/** @param {EditorSection} section */ /** @param {EditorSection} section */
function fitToContent(section) { function fitToContent(section) {
@ -148,17 +138,13 @@ function SectionsEditor() {
return; return;
} }
if (headerOffset == null) { if (headerOffset == null) {
headerOffset = Math.ceil(container.getBoundingClientRect().top + scrollY); headerOffset = container.getBoundingClientRect().top + scrollY | 0;
} }
if (cmExtrasHeight == null) { contentHeight += 9; // border & resize grip
cmExtrasHeight = $('.resize-grip', wrapper).offsetHeight + // grip
wrapper.offsetHeight - wrapper.clientHeight; // borders
}
contentHeight += cmExtrasHeight;
cm.off('update', resize); cm.off('update', resize);
const cmHeight = wrapper.offsetHeight; const cmHeight = wrapper.offsetHeight;
const appliesToHeight = Math.min(section.el.offsetHeight - cmHeight, window.innerHeight / 2); const appliesToHeight = Math.min(section.el.offsetHeight - cmHeight, window.innerHeight / 2);
const maxHeight = Math.floor(window.innerHeight - headerOffset - appliesToHeight); const maxHeight = (window.innerHeight - headerOffset) - appliesToHeight;
const fit = Math.min(contentHeight, maxHeight); const fit = Math.min(contentHeight, maxHeight);
if (Math.abs(fit - cmHeight) > 1) { if (Math.abs(fit - cmHeight) > 1) {
cm.setSize(null, fit); cm.setSize(null, fit);
@ -299,36 +285,10 @@ function SectionsEditor() {
} }
} }
function handleKeydown(event) { function nextPrevEditor(cm, direction) {
if (event.shiftKey || event.altKey || event.metaKey ||
event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return;
}
let pos;
let cm = this.CodeMirror;
const {line, ch} = cm.getCursor();
if (event.key === 'ArrowUp') {
cm = line === 0 && editor.prevEditor(cm, true);
pos = cm && [cm.doc.size - 1, ch];
} else {
cm = line === cm.doc.size - 1 && editor.nextEditor(cm, true);
pos = cm && [0, 0];
}
if (cm) {
cm.setCursor(...pos);
event.preventDefault();
event.stopPropagation();
}
}
function handleKeydownSetup(cm, state) {
cm.display.wrapper[state ? 'on' : 'off']('keydown', handleKeydown, true);
}
function nextPrevEditor(cm, direction, upDown) {
const editors = editor.getEditors(); const editors = editor.getEditors();
cm = editors[(editors.indexOf(cm) + direction + editors.length) % editors.length]; cm = editors[(editors.indexOf(cm) + direction + editors.length) % editors.length];
editor.scrollToEditor(cm, upDown); editor.scrollToEditor(cm);
cm.focus(); cm.focus();
return cm; return cm;
} }
@ -358,7 +318,7 @@ function SectionsEditor() {
function showMozillaFormat() { function showMozillaFormat() {
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true}); const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
popup.codebox.setValue(editor.getValue()); popup.codebox.setValue(MozDocMapper.styleToCss(getModel()));
popup.codebox.execCommand('selectAll'); popup.codebox.execCommand('selectAll');
} }
@ -430,7 +390,7 @@ function SectionsEditor() {
} }
function lockPageUI(locked) { function lockPageUI(locked) {
$.root.style.pointerEvents = locked ? 'none' : ''; document.documentElement.style.pointerEvents = locked ? 'none' : '';
if (popup.codebox) { if (popup.codebox) {
popup.classList.toggle('ready', locked ? false : !popup.codebox.isBlank()); popup.classList.toggle('ready', locked ? false : !popup.codebox.isBlank());
popup.codebox.options.readOnly = locked; popup.codebox.options.readOnly = locked;
@ -460,7 +420,7 @@ function SectionsEditor() {
editor.updateToc(); editor.updateToc();
} }
/** @returns {StyleObj} */ /** @returns {Style} */
function getModel() { function getModel() {
return Object.assign({}, style, { return Object.assign({}, style, {
sections: sections.filter(s => !s.removed).map(s => s.getModel()), sections: sections.filter(s => !s.removed).map(s => s.getModel()),
@ -486,7 +446,19 @@ function SectionsEditor() {
return true; return true;
} }
function updateMeta() { function destroyRemovedSections() {
for (let i = 0; i < sections.length;) {
if (!sections[i].removed) {
i++;
continue;
}
sections[i].destroy();
sections[i].el.remove();
sections.splice(i, 1);
}
}
function updateHeader() {
$('#name').value = style.customName || style.name || ''; $('#name').value = style.customName || style.name || '';
$('#enabled').checked = style.enabled !== false; $('#enabled').checked = style.enabled !== false;
$('#url').href = style.url || ''; $('#url').href = style.url || '';
@ -504,15 +476,14 @@ function SectionsEditor() {
async function initSections(src, { async function initSections(src, {
focusOn = 0, focusOn = 0,
replace = false, replace = false,
keepDirty = false, keepDirty = false, // used by import
si = editor.scrollInfo,
} = {}) { } = {}) {
Object.assign(editor, /** @namespace Editor */ {loading: true});
if (replace) { if (replace) {
sections.forEach(s => s.remove(true)); sections.forEach(s => s.remove(true));
sections.length = 0; sections.length = 0;
container.textContent = ''; container.textContent = '';
} }
let si = editor.scrollInfo;
if (si && si.cms && si.cms.length === src.length) { if (si && si.cms && si.cms.length === src.length) {
si.scrollY2 = si.scrollY + window.innerHeight; si.scrollY2 = si.scrollY + window.innerHeight;
container.style.height = si.scrollY2 + 'px'; container.style.height = si.scrollY2 + 'px';
@ -540,12 +511,9 @@ function SectionsEditor() {
if (!keepDirty) dirty.clear(); if (!keepDirty) dirty.clear();
if (i === focusOn) sections[i].cm.focus(); if (i === focusOn) sections[i].cm.focus();
} }
if (!si || si.cms.every(cm => !cm.height)) { if (!si) requestAnimationFrame(fitToAvailableSpace);
requestAnimationFrame(fitToAvailableSpace);
}
container.style.removeProperty('height'); container.style.removeProperty('height');
setGlobalProgress(); setGlobalProgress();
editor.loading = false;
} }
/** @param {EditorSection} section */ /** @param {EditorSection} section */
@ -611,9 +579,6 @@ function SectionsEditor() {
cm.focus(); cm.focus();
editor.scrollToEditor(cm); editor.scrollToEditor(cm);
} }
if (upDownJumps) {
handleKeydownSetup(cm, true);
}
updateSectionOrder(); updateSectionOrder();
updateLivePreview(); updateLivePreview();
section.onChange(updateLivePreview); section.onChange(updateLivePreview);
@ -646,6 +611,7 @@ function SectionsEditor() {
/** @param {EditorSection} section */ /** @param {EditorSection} section */
function registerEvents(section) { function registerEvents(section) {
const {el, cm} = section; const {el, cm} = section;
$('.applies-to-help', el).onclick = () => helpPopup.show(t('appliesLabel'), t('appliesHelp'));
$('.remove-section', el).onclick = () => removeSection(section); $('.remove-section', el).onclick = () => removeSection(section);
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section); $('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section); $('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
@ -653,13 +619,16 @@ function SectionsEditor() {
$('.move-section-down', el).onclick = () => moveSectionDown(section); $('.move-section-down', el).onclick = () => moveSectionDown(section);
$('.restore-section', el).onclick = () => restoreSection(section); $('.restore-section', el).onclick = () => restoreSection(section);
cm.on('paste', maybeImportOnPaste); cm.on('paste', maybeImportOnPaste);
if (!FIREFOX) {
cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event));
}
} }
function maybeImportOnPaste(cm, event) { function maybeImportOnPaste(cm, event) {
const text = event.clipboardData.getData('text') || ''; const text = event.clipboardData.getData('text') || '';
if (/@-moz-document/i.test(text) && if (/@-moz-document/i.test(text) &&
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i /@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
.test(text.replace(/\/\*([^*]+|\*(?!\/))*(\*\/|$)/g, '')) .test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
) { ) {
event.preventDefault(); event.preventDefault();
showMozillaFormatImport(text); showMozillaFormatImport(text);
@ -670,7 +639,7 @@ function SectionsEditor() {
if (code) { if (code) {
linterMan.enableForEditor(cm, code); linterMan.enableForEditor(cm, code);
} }
if (force) { if (force || !xo) {
refreshOnViewNow(cm); refreshOnViewNow(cm);
} else { } else {
xo.observe(cm.display.wrapper); xo.observe(cm.display.wrapper);
@ -697,4 +666,15 @@ function SectionsEditor() {
linterMan.enableForEditor(cm); linterMan.enableForEditor(cm);
cm.refresh(); cm.refresh();
} }
function toggleContextMenuDelete(event) {
if (chrome.contextMenus && event.button === 2 && prefs.get('editor.contextDelete')) {
chrome.contextMenus.update('editor.contextDelete', {
enabled: Boolean(
this.selectionStart !== this.selectionEnd ||
this.somethingSelected && this.somethingSelected()
),
}, ignoreChromeError);
}
}
} }

View File

@ -1,47 +0,0 @@
#help-popup.style-settings-popup.dirty .title::after {
content: ' *';
}
.compact-layout #help-popup.style-settings-popup {
width: 90%;
}
.style-settings {
padding: 0 1px; /* for focus outline */
border: 0;
margin: 0;
}
.style-settings > * {
display: block;
margin: 1rem 0;
padding: 0;
}
.style-settings > :first-child {
margin-top: 0;
}
.style-settings > :last-child {
margin-bottom: 0;
}
.style-settings input:disabled ~ label {
opacity: .5;
}
.style-settings .w100 {
display: block;
width: 100%;
margin-top: .25em;
box-sizing: border-box;
}
.style-settings textarea {
resize: vertical;
min-width: 33vw;
min-height: 2.5em;
max-height: 50vh;
}
.style-settings textarea:not(:placeholder-shown) {
min-width: 50vw;
}
.style-settings .radio-wrapper {
display: inline-flex;
padding: 0 .8em 0 0;
}
a[data-cmd=note] {
vertical-align: text-bottom;
}

View File

@ -1,40 +0,0 @@
<div>
<fieldset class="style-settings">
<div class="rel">
<input id="ss-updatable" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
<label i18n="installUpdateFromLabel" for="ss-updatable"></label>
<input id="ss-update-url" type="url" class="w100" i18n="placeholder:styleUpdateUrlLabel">
</div>
<div id="ss-scheme">
<div i18n="preferScheme">
<div><small id="ss-scheme-off" i18n="preferSchemeAlways" hidden></small></div>
</div>
<label i18n="+preferSchemeNone" class="radio-wrapper">
<input name="ss-scheme" type="radio" value="none">
</label>
<label i18n="+preferSchemeDark" class="radio-wrapper">
<input name="ss-scheme" type="radio" value="dark">
</label>
<label i18n="+preferSchemeLight" class="radio-wrapper">
<input name="ss-scheme" type="radio" value="light">
</label>
</div>
<label i18n="styleIncludeLabel">
<textarea id="ss-inclusions" spellcheck="false" class="w100"
placeholder="*://site1.com/*&#10;*://site2.com/*"></textarea>
</label>
<label i18n="styleExcludeLabel">
<textarea id="ss-exclusions" spellcheck="false" class="w100"
placeholder="*://site1.com/*&#10;*://site2.com/*"></textarea>
</label>
</fieldset>
<div class="buttons">
<button id="ss-save" i18n="confirmSave" disabled></button>
<label i18n="+configOnChange, title:configOnChangeTooltip">
<input id="config.autosave" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
<button id="ss-close" i18n="confirmClose"></button>
</div>
</div>

View File

@ -1,124 +0,0 @@
/* global $ moveFocus setupLivePrefs */// dom.js
/* global API */// msg.js
/* global editor */
/* global helpPopup */// util.js
/* global prefs */
/* global t */// localization.js
/* global debounce tryURL */// toolbox.js
'use strict';
/* exported StyleSettings */
async function StyleSettings() {
const AUTOSAVE_DELAY = 500; // same as config-dialog.js
const SS_ID = 'styleSettings';
const PASS = val => val;
await t.fetchTemplate('/edit/settings.html', SS_ID);
const {style} = editor;
const ui = t.template[SS_ID].cloneNode(true);
const elAuto = $('#config\\.autosave', ui);
const elSave = $('#ss-save', ui);
const elUpd = $('#ss-updatable', ui);
const pendingSetters = new Map();
const updaters = [
initCheckbox(elUpd, 'updatable', tryURL(style.updateUrl).href),
initInput('#ss-update-url', 'updateUrl', '', {
validate(el) {
elUpd.disabled = !el.value || !el.validity.valid;
return el.validity.valid;
},
}),
initRadio('ss-scheme', 'preferScheme', 'none'),
initArea('inclusions'),
initArea('exclusions'),
];
update();
prefs.subscribe('schemeSwitcher.enabled', (_, val) => {
$('#ss-scheme-off', ui).hidden = val !== 'never';
}, {runNow: true});
window.on(SS_ID, update);
window.on('closeHelp', () => window.off(SS_ID, update), {once: true});
helpPopup.show(t(SS_ID), ui, {
className: 'style-settings-popup',
});
elSave.onclick = save;
$('#ss-close', ui).onclick = helpPopup.close;
setupLivePrefs([elAuto.id]);
moveFocus(ui, 0);
function autosave(el, setter) {
pendingSetters.set(el, setter);
helpPopup.div.classList.add('dirty');
elSave.disabled = false;
if (elAuto.checked) debounce(save, AUTOSAVE_DELAY);
}
function initArea(type) {
return initInput(`#ss-${type}`, type, [], {
get: textToList,
set: list => list.join('\n'),
validate(el) {
const val = el.value;
el.rows = val.match(/^/gm).length + !val.endsWith('\n');
},
});
}
function initCheckbox(el, key, defVal) {
return initInput(el, key, Boolean(defVal), {dom: 'checked'});
}
function initInput(el, key, defVal, {
dom = 'value', // DOM property name
get = PASS, // transformer function(val) after getting DOM value
set = PASS, // transformer function(val) before setting DOM value
validate = PASS, // function(el) - return `false` to prevent saving
} = {}) {
if (typeof el === 'string') {
el = $(el, ui);
}
el.oninput = () => {
if (validate(el) !== false) {
autosave(el, {dom, get, key});
}
};
return () => {
let val = style[key];
val = set(val != null ? val : defVal);
// Skipping if unchanged to preserve the Undo history of the input
if (el[dom] !== val) el[dom] = val;
validate(el);
};
}
function initRadio(name, key, defVal) {
$(`#${name}`, ui).oninput = e => {
if (e.target.checked) {
autosave(e.target, {key});
}
};
return () => {
const val = style[key] || defVal;
const el = $(`[name="${name}"][value="${val}"]`, ui);
el.checked = true;
};
}
function save() {
pendingSetters.forEach(saveValue);
pendingSetters.clear();
helpPopup.div.classList.remove('dirty');
elSave.disabled = true;
}
function saveValue({dom = 'value', get = PASS, key}, el) {
return API.styles.config(style.id, key, get(el[dom]));
}
function textToList(text) {
return text.split(/\n/).map(s => s.trim()).filter(Boolean);
}
function update() {
updaters.forEach(fn => fn());
}
}

View File

@ -4,7 +4,7 @@
/* global MozDocMapper */// util.js /* global MozDocMapper */// util.js
/* global MozSectionFinder */ /* global MozSectionFinder */
/* global MozSectionWidget */ /* global MozSectionWidget */
/* global RX_META debounce */// toolbox.js /* global RX_META debounce sessionStore */// toolbox.js
/* global chromeSync */// storage-util.js /* global chromeSync */// storage-util.js
/* global cmFactory */ /* global cmFactory */
/* global editor */ /* global editor */
@ -14,31 +14,23 @@
'use strict'; 'use strict';
/* exported SourceEditor */ /* exported SourceEditor */
async function SourceEditor() { function SourceEditor() {
const {style, /** @type DirtyReporter */dirty} = editor; const {style, /** @type DirtyReporter */dirty} = editor;
const DEFAULT_TEMPLATE = `
/* ==UserStyle==
@name ${''/* a trick to preserve the trailing spaces */}
@namespace github.com/openstyles/stylus
@version 1.0.0
@description A new userstyle
@author Me
==/UserStyle== */
`.replace(/^\s+/gm, '');
let savedGeneration; let savedGeneration;
let placeholderName = '';
let prevMode = NaN; let prevMode = NaN;
$$remove('.sectioned-only'); $$remove('.sectioned-only');
$('#header').on('wheel', headerOnScroll); $('#header').on('wheel', headerOnScroll);
$('#sections').textContent = ''; $('#sections').textContent = '';
$('#sections').appendChild($create('.single-editor')); $('#sections').appendChild($create('.single-editor'));
$('#save-button').on('split-btn', saveTemplate);
if (!style.id) setupNewStyle(style);
const cm = cmFactory.create($('.single-editor')); const cm = cmFactory.create($('.single-editor'));
const sectionFinder = MozSectionFinder(cm); const sectionFinder = MozSectionFinder(cm);
const sectionWidget = MozSectionWidget(cm, sectionFinder); const sectionWidget = MozSectionWidget(cm, sectionFinder);
editor.livePreview.init(preprocess); editor.livePreview.init(preprocess, style.id);
if (!style.id) setupNewStyle(await editor.template);
createMetaCompiler(meta => { createMetaCompiler(meta => {
style.usercssData = meta; style.usercssData = meta;
style.name = meta.name; style.name = meta.name;
@ -53,17 +45,9 @@ async function SourceEditor() {
sections: sectionFinder.sections, sections: sectionFinder.sections,
replaceStyle, replaceStyle,
updateLivePreview, updateLivePreview,
updateMeta,
closestVisible: () => cm, closestVisible: () => cm,
getEditors: () => [cm], getEditors: () => [cm],
getEditorTitle: () => '', getEditorTitle: () => '',
getValue: asObject => asObject
? {
customName: style.customName,
enabled: style.enabled,
sourceCode: cm.getValue(),
}
: cm.getValue(),
getSearchableInputs: () => [], getSearchableInputs: () => [],
prevEditor: nextPrevSection.bind(null, -1), prevEditor: nextPrevSection.bind(null, -1),
nextEditor: nextPrevSection.bind(null, 1), nextEditor: nextPrevSection.bind(null, 1),
@ -72,24 +56,28 @@ async function SourceEditor() {
if (sec) { if (sec) {
sectionFinder.updatePositions(sec); sectionFinder.updatePositions(sec);
cm.jumpToPos(sec.start); cm.jumpToPos(sec.start);
cm.focus();
} }
}, },
async saveImpl() { async save() {
if (!dirty.isDirty()) return;
const sourceCode = cm.getValue(); const sourceCode = cm.getValue();
try { try {
const {customName, enabled, id} = style; const {customName, enabled, id} = style;
let res = !id && await API.usercss.build({sourceCode, checkDup: true, metaOnly: true}); if (!id &&
if (res && res.dup) { (await API.usercss.build({sourceCode, checkDup: true, metaOnly: true})).dup) {
messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError')); messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
} else { } else {
res = await API.usercss.editSave({customName, enabled, id, sourceCode}); await replaceStyle(
// Awaiting inside `try` so that exceptions go to our `catch` await API.usercss.editSave({customName, enabled, id, sourceCode}));
await replaceStyle(res.style);
} }
showLog(res);
} catch (err) { } catch (err) {
showSaveError(err); const i = err.index;
const isNameEmpty = i > 0 &&
err.code === 'missingValue' &&
sourceCode.slice(sourceCode.lastIndexOf('\n', i - 1), i).trim().endsWith('@name');
return isNameEmpty
? saveTemplate(sourceCode)
: showSaveError(err);
} }
}, },
scrollToEditor: () => {}, scrollToEditor: () => {},
@ -101,6 +89,7 @@ async function SourceEditor() {
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val), 'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
}, {runNow: true}); }, {runNow: true});
editor.applyScrollInfo(cm);
cm.clearHistory(); cm.clearHistory();
cm.markClean(); cm.markClean();
savedGeneration = cm.changeGeneration(); savedGeneration = cm.changeGeneration();
@ -120,23 +109,15 @@ async function SourceEditor() {
if (!$isTextInput(document.activeElement)) { if (!$isTextInput(document.activeElement)) {
cm.focus(); cm.focus();
} }
editor.applyScrollInfo(cm); // WARNING! Place it after all cm.XXX calls that change scroll pos
async function preprocess(style) { async function preprocess(style) {
const res = await API.usercss.build({ const {style: newStyle} = await API.usercss.build({
styleId: style.id, styleId: style.id,
sourceCode: style.sourceCode, sourceCode: style.sourceCode,
assignVars: true, assignVars: true,
}); });
showLog(res); delete newStyle.enabled;
delete res.style.enabled; return Object.assign(style, newStyle);
return Object.assign(style, res.style);
}
/** Shows the console.log output from the background worker stored in `log` property */
function showLog(data) {
if (data.log) data.log.forEach(args => console.log(...args));
return data;
} }
function updateLivePreview() { function updateLivePreview() {
@ -168,21 +149,34 @@ async function SourceEditor() {
return name; return name;
} }
function setupNewStyle(tpl) { async function setupNewStyle(style) {
const comment = `/* ${t('usercssReplaceTemplateSectionBody')} */`; style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) +
const sec0 = style.sections[0]; `/* ${t('usercssReplaceTemplateSectionBody')} */`;
sec0.code = ' '.repeat(prefs.get('editor.tabSize')) + comment; let section = MozDocMapper.styleToCss(style);
if (Object.keys(sec0).length === 1) { // the only key is 'code' if (!section.includes('@-moz-document')) {
sec0.domains = ['example.com']; style.sections[0].domains = ['example.com'];
section = MozDocMapper.styleToCss(style);
} }
style.name = [style.name, new Date().toLocaleString()].filter(Boolean).join(' - '); const DEFAULT_CODE = `
style.sourceCode = (tpl || DEFAULT_TEMPLATE) /* ==UserStyle==
.replace(/(@name)(?:([\t\x20]+).*|\n)/, (_, k, space) => `${k}${space || ' '}${style.name}`) @name ${''/* a trick to preserve the trailing spaces */}
.replace(/\s*@-moz-document[^{]*{([^}]*)}\s*$/g, // stripping dummy sections @namespace github.com/openstyles/stylus
(s, body) => body.trim() === comment ? '\n\n' : s) @version 1.0.0
.trim() + @description A new userstyle
'\n\n' + @author Me
MozDocMapper.styleToCss(style); ==/UserStyle== */
`.replace(/^\s+/gm, '');
dirty.clear('sourceGeneration');
style.sourceCode = '';
placeholderName = `${style.name || t('usercssReplaceTemplateName')} - ${new Date().toLocaleString()}`;
let code = await chromeSync.getLZValue(chromeSync.LZ_KEY.usercssTemplate);
code = code || DEFAULT_CODE;
code = code.replace(/@name(\s*)(?=[\r\n])/, (str, space) =>
`${str}${space ? '' : ' '}${placeholderName}`);
// strip the last dummy section if any, add an empty line followed by the section
style.sourceCode = code.replace(/\s*@-moz-document[^{]*{[^}]*}\s*$|\s+$/g, '') + '\n\n' + section;
cm.startOperation(); cm.startOperation();
cm.setValue(style.sourceCode); cm.setValue(style.sourceCode);
cm.clearHistory(); cm.clearHistory();
@ -194,60 +188,60 @@ async function SourceEditor() {
function updateMeta() { function updateMeta() {
const name = style.customName || style.name; const name = style.customName || style.name;
$('#name').value = name; if (name !== placeholderName) {
$('#name').value = name;
}
$('#enabled').checked = style.enabled; $('#enabled').checked = style.enabled;
$('#url').href = style.url; $('#url').href = style.url;
editor.updateName(); editor.updateName();
cm.setPreprocessor((style.usercssData || {}).preprocessor); cm.setPreprocessor((style.usercssData || {}).preprocessor);
} }
async function replaceStyle(newStyle, draft) { function replaceStyle(newStyle, codeIsUpdated) {
dirty.clear('name'); dirty.clear('name');
const sameCode = newStyle.sourceCode === cm.getValue(); const sameCode = newStyle.sourceCode === cm.getValue();
if (sameCode) { if (sameCode) {
savedGeneration = cm.changeGeneration(); savedGeneration = cm.changeGeneration();
dirty.clear('sourceGeneration'); dirty.clear('sourceGeneration');
editor.useSavedStyle(newStyle); }
if (codeIsUpdated === false || sameCode) {
updateEnvironment();
dirty.clear('enabled'); dirty.clear('enabled');
updateLivePreview(); updateLivePreview();
return; return;
} }
if (draft || await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) { Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
editor.useSavedStyle(newStyle); if (!ok) return;
updateEnvironment();
if (!sameCode) { if (!sameCode) {
const si0 = draft && draft.si.cms[0]; const cursor = cm.getCursor();
const cursor = !si0 && cm.getCursor();
cm.setValue(style.sourceCode); cm.setValue(style.sourceCode);
if (si0) { cm.setCursor(cursor);
editor.applyScrollInfo(cm, si0);
} else {
cm.setCursor(cursor);
}
savedGeneration = cm.changeGeneration(); savedGeneration = cm.changeGeneration();
} }
if (sameCode) { if (sameCode) {
// the code is same but the environment is changed // the code is same but the environment is changed
updateLivePreview(); updateLivePreview();
} }
if (!draft) { dirty.clear();
dirty.clear(); });
function updateEnvironment() {
if (style.id !== newStyle.id) {
history.replaceState({}, '', `?id=${newStyle.id}`);
} }
sessionStore.justEditedStyleId = newStyle.id;
Object.assign(style, newStyle);
$('#preview-label').classList.remove('hidden');
updateMeta();
editor.livePreview.toggle(Boolean(style.id));
} }
} }
async function saveTemplate() { async function saveTemplate(code) {
const res = await messageBoxProxy.show({ if (await messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation'))) {
contents: t('usercssReplaceTemplateConfirmation'),
className: 'center',
buttons: [t('confirmYes'), t('confirmNo'), {
textContent: t('genericResetLabel'),
title: t('restoreTemplate'),
}],
});
if (res.enter || res.button !== 1) {
const key = chromeSync.LZ_KEY.usercssTemplate; const key = chromeSync.LZ_KEY.usercssTemplate;
const code = res.button === 2 ? DEFAULT_TEMPLATE : cm.getValue();
await chromeSync.setLZValue(key, code); await chromeSync.setLZValue(key, code);
if (await chromeSync.getLZValue(key) !== code) { if (await chromeSync.getLZValue(key) !== code) {
messageBoxProxy.alert(t('syncStorageErrorSaving')); messageBoxProxy.alert(t('syncStorageErrorSaving'));
@ -324,17 +318,13 @@ async function SourceEditor() {
if (errors.every(err => err.code === 'unknownMeta')) { if (errors.every(err => err.code === 'unknownMeta')) {
onUpdated(metadata); onUpdated(metadata);
} }
cache = errors.map(({code, index, args, message}) => { cache = errors.map(err => ({
const isUnknownMeta = code === 'unknownMeta'; from: cm.posFromIndex((err.index || 0) + match.index),
const typo = isUnknownMeta && args[1] ? 'Typo' : ''; // args[1] may be present but undefined to: cm.posFromIndex((err.index || 0) + match.index),
return ({ message: err.code && t(`meta_${err.code}`, err.args, false) || err.message,
from: cm.posFromIndex((index || 0) + match.index), severity: err.code === 'unknownMeta' ? 'warning' : 'error',
to: cm.posFromIndex((index || 0) + match.index), rule: err.code,
message: code && t(`meta_${code}${typo}`, args, false) || message, }));
severity: isUnknownMeta ? 'warning' : 'error',
rule: code,
});
});
meta = match[0]; meta = match[0];
metaIndex = match.index; metaIndex = match.index;
return cache; return cache;

View File

@ -1,96 +0,0 @@
/* global $ $create $remove messageBoxProxy showSpinner toggleDataset */// dom.js
/* global API msg */// msg.js
/* global URLS */// toolbox.js
/* global editor */
/* global t */// localization.js
'use strict';
(() => {
//#region Main
const ERROR_TITLE = 'UserStyles.world ' + t('genericError');
const PROGRESS = '#usw-progress';
let spinnerTimer = 0;
let prevCode = '';
msg.onExtension(request => {
if (request.method === 'uswData' &&
request.style.id === editor.style.id) {
Object.assign(editor.style, request.style);
updateUI();
}
});
window.on('domReady', () => {
updateUI();
$('#usw-publish-style').onclick = disableWhileActive(publishStyle);
$('#usw-disconnect').onclick = disableWhileActive(disconnect);
}, {once: true});
async function publishStyle() {
const {id} = editor.style;
if (await API.data.has('usw' + id) &&
!await messageBoxProxy.confirm(t('publishRetry'), 'danger', ERROR_TITLE)) {
return;
}
const code = editor.getValue();
const isDiff = code !== prevCode;
const res = isDiff ? await API.usw.publish(id, code) : t('importReportUnchanged');
const title = `${new Date().toLocaleString()}\n${res}`;
const failed = /^Error:/.test(res);
$(PROGRESS).append(...failed && [
$create('div.error', {title}, res),
$create('div', t('publishReconnect')),
] || [
$create(`span.${isDiff ? 'success' : 'unchanged'}`, {title}),
]);
if (!failed) prevCode = code;
}
async function disconnect() {
await API.usw.revoke(editor.style.id);
prevCode = null; // to allow the next publishStyle to upload style
}
function updateUI(style = editor.style) {
const usw = style._usw || {};
const section = $('#publish');
toggleDataset(section, 'connected', usw.token);
for (const type of ['name', 'description']) {
const el = $(`dd[data-usw="${type}"]`, section);
el.textContent = el.title = usw[type] || '';
}
const elUrl = $('#usw-url');
elUrl.href = `${URLS.usw}${usw.id ? `style/${usw.id}` : ''}`;
elUrl.textContent = t('publishUsw').replace(/<(.+)>/, `$1${usw.id ? `#${usw.id}` : ''}`);
}
//#endregion
//#region Utility
function disableWhileActive(fn) {
/** @this {Element} */
return async function () {
this.disabled = true;
timerOn();
await fn().catch(console.error);
timerOff();
this.disabled = false;
};
}
function timerOn() {
if (!spinnerTimer) {
$(PROGRESS).textContent = '';
spinnerTimer = setTimeout(showSpinner, 250, PROGRESS);
}
}
function timerOff() {
$remove(`${PROGRESS} .lds-spinner`);
clearTimeout(spinnerTimer);
spinnerTimer = 0;
}
//#endregion
})();

View File

@ -7,54 +7,47 @@
const helpPopup = { const helpPopup = {
/** show(title = '', body) {
* @param {string} title - plain text
* @param {string|Node} body - Node, html or plain text
* @param {Node} [props] - DOM props for the popup element
* @returns {Element} the popup
*/
show(title = '', body, props) {
const div = $('#help-popup'); const div = $('#help-popup');
const contents = $('.contents', div); const contents = $('.contents', div);
div.style = '';
div.className = ''; div.className = '';
contents.textContent = ''; contents.textContent = '';
Object.assign(div, props);
if (body) { if (body) {
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body); contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
} }
$('.title', div).textContent = title; $('.title', div).textContent = title;
$('.dismiss', div).onclick = helpPopup.close; $('.dismiss', div).onclick = helpPopup.close;
window.on('keydown', helpPopup.close, true); window.on('keydown', helpPopup.close, true);
div.style.display = 'block'; // reset any inline styles
div.style = 'display: block';
helpPopup.originalFocus = document.activeElement; helpPopup.originalFocus = document.activeElement;
helpPopup.div = div;
moveFocus(div, 0);
return div; return div;
}, },
close(event) { close(event) {
let el;
const canClose = const canClose =
!event || !event ||
event.type === 'click' || event.type === 'click' || (
getEventKeyName(event) === 'Escape' && !$('.CodeMirror-hints, #message-box') && ( getEventKeyName(event) === 'Escape' &&
!(el = document.activeElement) || !$('.CodeMirror-hints, #message-box') && (
!el.closest('#search-replace-dialog') !document.activeElement ||
!document.activeElement.closest('#search-replace-dialog') &&
document.activeElement.matches(':not(input), .can-close-on-esc')
)
); );
const {div} = helpPopup; const div = $('#help-popup');
if (!canClose || !div) { if (!canClose || !div) {
return; return;
} }
if (event && (el = div.codebox) && !el.options.readOnly && !el.isClean()) { if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(async () => { setTimeout(async () => {
const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges')); const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges'));
return ok && helpPopup.close(); return ok && helpPopup.close();
}); });
return; return;
} }
if (div.contains(document.activeElement) && (el = helpPopup.originalFocus)) { if (div.contains(document.activeElement) && helpPopup.originalFocus) {
el.focus(); helpPopup.originalFocus.focus();
} }
const contents = $('.contents', div); const contents = $('.contents', div);
div.style.display = ''; div.style.display = '';
@ -110,74 +103,55 @@ function clipString(str, limit = 100) {
} }
/* exported createHotkeyInput */ /* exported createHotkeyInput */
function createHotkeyInput(prefId, {buttons = true, onDone}) { function createHotkeyInput(prefId, onDone = () => {}) {
const RX_ERR = new RegExp('^(' + [ return $create('input', {
/Space/, type: 'search',
/(Shift-)?./, // a single character
/(?=.)(Shift-?|Ctrl-?|Control-?|Alt-?|Meta-?)*(Escape|Tab|Page(Up|Down)|Arrow(Up|Down|Left|Right)|Home|End)?/,
].map(r => r.source || r).join('|') + ')$', 'i');
const initialValue = prefs.get(prefId);
const input = $create('input', {
spellcheck: false, spellcheck: false,
onpaste: e => onkeydown(e, e.clipboardData.getData('text')), value: prefs.get(prefId),
onkeydown, onkeydown(event) {
}); const key = CodeMirror.keyName(event);
buttons = buttons && [ if (key === 'Tab' || key === 'Shift-Tab') {
['confirmOK', 'Enter'],
['undo', initialValue],
['genericResetLabel', ''],
].map(([label, val]) =>
$create('button', {onclick: e => onkeydown(e, val)}, t(label)));
const [btnOk, btnUndo, btnReset] = buttons || [];
onkeydown(null, initialValue);
return buttons
? $create('fragment', [input, $create('.buttons', buttons)])
: input;
function onkeydown(e, key) {
let newValue;
if (e && e.type === 'keydown') {
key = getEventKeyName(e);
}
switch (e && key) {
case 'Tab':
case 'Shift-Tab':
return; return;
case 'BackSpace':
case 'Delete':
newValue = '';
break;
case 'Enter':
if (input.checkValidity() && onDone) onDone();
break;
case 'Escape':
if (onDone) onDone();
break;
default:
newValue = key.replace(/\b.$/, c => c.toUpperCase());
}
if (newValue != null) {
const error = RX_ERR.test(newValue) ? t('genericError') : '';
if (e && !error) prefs.set(prefId, newValue);
input.setCustomValidity(error);
input.value = newValue;
input.focus();
if (buttons) {
btnOk.disabled = Boolean(error);
btnUndo.disabled = newValue === initialValue;
btnReset.disabled = !newValue;
} }
} event.preventDefault();
if (e) { event.stopPropagation();
e.preventDefault(); switch (key) {
e.stopPropagation(); case 'Enter':
} if (this.checkValidity()) onDone(true);
} return;
case 'Esc':
onDone(false);
return;
default:
// disallow: [Shift?] characters, modifiers-only, [modifiers?] + Esc, Tab, nav keys
if (!key || new RegExp('^(' + [
'(Back)?Space',
'(Shift-)?.', // a single character
'(Shift-?|Ctrl-?|Alt-?|Cmd-?){0,2}(|Esc|Tab|(Page)?(Up|Down)|Left|Right|Home|End|Insert|Delete)',
].join('|') + ')$', 'i').test(key)) {
this.value = key || this.value;
this.setCustomValidity('Not allowed');
return;
}
}
this.value = key;
this.setCustomValidity('');
prefs.set(prefId, key);
},
oninput() {
// fired on pressing "x" to clear the field
prefs.set(prefId, '');
},
onpaste(event) {
event.preventDefault();
},
});
} }
/* exported showCodeMirrorPopup */ /* exported showCodeMirrorPopup */
function showCodeMirrorPopup(title, html, options) { function showCodeMirrorPopup(title, html, options) {
const popup = helpPopup.show(title, html, {className: 'big'}); const popup = helpPopup.show(title, html);
popup.classList.add('big');
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({ let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
mode: 'css', mode: 'css',
@ -192,7 +166,7 @@ function showCodeMirrorPopup(title, html, options) {
}, options)); }, options));
cm.focus(); cm.focus();
$.root.style.pointerEvents = 'none'; document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto'; popup.style.pointerEvents = 'auto';
const onKeyDown = event => { const onKeyDown = event => {
@ -207,7 +181,7 @@ function showCodeMirrorPopup(title, html, options) {
window.on('closeHelp', () => { window.on('closeHelp', () => {
window.off('keydown', onKeyDown, true); window.off('keydown', onKeyDown, true);
$.root.style.removeProperty('pointer-events'); document.documentElement.style.removeProperty('pointer-events');
cm = popup.codebox = null; cm = popup.codebox = null;
}, {once: true}); }, {once: true});

View File

@ -1,160 +0,0 @@
@media screen and (prefers-color-scheme: dark), dark {
:root {
/* Comfortable dark themes don't use absolutes so the range is compressed */
--c00: hsl(0, 0%, 80%);
--c10: hsl(0, 0%, 73.5%);
--c20: hsl(0, 0%, 66%);
--c30: hsl(0, 0%, 59.5%);
--c40: hsl(0, 0%, 53%);
--c45: hsl(0, 0%, 49.75%);
--c50: hsl(0, 0%, 46.5%);
--c60: hsl(0, 0%, 40%);
--c65: hsl(0, 0%, 36.75%);
--c70: hsl(0, 0%, 33.5%);
--c75: hsl(0, 0%, 30.25%);
--c80: hsl(0, 0%, 27%);
--c85: hsl(0, 0%, 23.75%);
--c90: hsl(0, 0%, 20.5%);
--c95: hsl(0, 0%, 17.25%);
--c100: hsl(0, 0%, 14%);
/* min/max are exposed in case we want to use an overdrive color for emphasis */
--cmin: hsl(0, 0%, 100%);
--cmax: hsl(0, 0%, 0%);
--accent-1: hsl(180, 100%, 95%);
--accent-3: hsl(180, 30%, 18%);
--input-bg: var(--c95);
--red1: hsl(0, 85%, 55%);
}
textarea,
input[type=url],
input[type=time] {
background-color: var(--input-bg);
color: var(--fg);
}
input::-webkit-inner-spin-button {
filter: invert(.8);
}
input[type=radio]:checked:after {
background-color: var(--fg);
}
input[type=time]::-webkit-calendar-picker-indicator {
filter: invert(1);
}
select {
background-color: var(--bg);
}
.onoffswitch {
--knob: var(--c50);
}
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
background-color: var(--bg) !important;
border: 0;
}
::-webkit-scrollbar {
width: 17px;
height: 17px;
background: var(--bg);
}
::-webkit-scrollbar-corner {
background: var(--bg);
border: 0;
}
/* buttons */
::-webkit-scrollbar-button:single-button {
height: 17px;
width: 17px;
background-size: 9px;
background-position: 4px 7px;
background-repeat: no-repeat;
}
::-webkit-scrollbar-button:horizontal:single-button {
background-position: 7px 4px;
}
/* up */
::-webkit-scrollbar-button:single-button:vertical:decrement {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 35%)'><polygon points='1,0 0,1 2,1'/></svg>");
}
::-webkit-scrollbar-button:single-button:vertical:decrement:hover {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 45%)'><polygon points='1,0 0,1 2,1'/></svg>");
}
::-webkit-scrollbar-button:single-button:vertical:decrement:active {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 55%)'><polygon points='1,0 0,1 2,1'/></svg>");
}
/* down */
::-webkit-scrollbar-button:single-button:vertical:increment {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 35%)'><polygon points='0,0 2,0 1,1'/></svg>");
}
::-webkit-scrollbar-button:single-button:vertical:increment:hover {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 45%)'><polygon points='0,0 2,0 1,1'/></svg>");
}
::-webkit-scrollbar-button:single-button:vertical:increment:active {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 55%)'><polygon points='0,0 2,0 1,1'/></svg>");
}
/* left */
::-webkit-scrollbar-button:single-button:horizontal:decrement {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 35%)'><polygon points='0,1 1,2 1,0'/></svg>");
}
::-webkit-scrollbar-button:single-button:horizontal:decrement:hover {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 45%)'><polygon points='0,1 1,2 1,0'/></svg>");
}
::-webkit-scrollbar-button:single-button:horizontal:decrement:active {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 55%)'><polygon points='0,1 1,2 1,0'/></svg>");
}
/* right */
::-webkit-scrollbar-button:single-button:horizontal:increment {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 35%)'><polygon points='0,0 0,2 1,1'/></svg>");
}
::-webkit-scrollbar-button:single-button:horizontal:increment:hover {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 45%)'><polygon points='0,0 0,2 1,1'/></svg>");
}
::-webkit-scrollbar-button:single-button:horizontal:increment:active {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2' fill='hsl(0, 0%, 55%)'><polygon points='0,0 0,2 1,1'/></svg>");
}
::-webkit-scrollbar-track-piece {
background: hsl(0, 0%, 17%);
border: 1px solid var(--bg);
}
::-webkit-scrollbar-track-piece:hover {
background: hsl(0, 0%, 20%);
}
::-webkit-scrollbar-track-piece:active {
background: hsl(0, 0%, 25%);
}
::-webkit-scrollbar-thumb {
background: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1' fill='hsl(0, 0%, 30%)'><rect width='1' height='1'/></svg>") 2px 2px no-repeat;
}
::-webkit-scrollbar-thumb:horizontal {
background-size: 100% 13px;
}
::-webkit-scrollbar-thumb:vertical {
background-size: 13px 100%;
}
::-webkit-scrollbar-thumb:hover {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1' fill='hsl(0, 0%, 33%)'><rect width='1' height='1'/></svg>");
}
::-webkit-scrollbar-thumb:active {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1' fill='hsl(0, 0%, 40%)'><rect width='1' height='1'/></svg>");
}
::-webkit-resizer {
background: var(--input-bg) linear-gradient(-45deg,
transparent 3px, #888 3px,
#888 4px, transparent 4px,
transparent 6px, #888 6px,
#888 7px, transparent 7px) no-repeat;
border: 2px solid transparent;
}
:-webkit-autofill {
box-shadow: 0 0 0 1000px var(--input-bg) inset;
-webkit-text-fill-color: #fff;
}
@supports (-moz-appearance: none) {
/* Workarounds for FF bugs/quirks */
textarea {
border: 1px solid var(--c65);
}
* {
scrollbar-color: var(--c75) var(--bg);
}
}
}

View File

@ -1,52 +1,15 @@
@supports not (accent-color: red) {
/* This suppresses a bug in all? browsers: they apply transitions during page load.
* It was fixed by crrev.com/886802 in Chrome 93, which we detect via `accent-color`.
* Using an increased specificity to override sane selectors in user styles.
* Using \1 to simplify js code because \0 is converted to \xFFFD per spec. */
html#stylus #header *:not(#\1transition-suppressor) {
transition: none !important;
}
}
:root {
--family: Arial, "Helvetica Neue", Helvetica, system-ui, sans-serif;
--input-height: 22px;
--cmin: hsl(0, 0%, 00%);
--c00: hsl(0, 0%, 00%);
--c10: hsl(0, 0%, 10%);
--c20: hsl(0, 0%, 20%);
--c30: hsl(0, 0%, 30%);
--c40: hsl(0, 0%, 40%);
--c45: hsl(0, 0%, 45%);
--c50: hsl(0, 0%, 50%);
--c60: hsl(0, 0%, 60%);
--c65: hsl(0, 0%, 65%);
--c70: hsl(0, 0%, 70%);
--c75: hsl(0, 0%, 75%);
--c80: hsl(0, 0%, 80%);
--c85: hsl(0, 0%, 85%);
--c90: hsl(0, 0%, 90%);
--c95: hsl(0, 0%, 95%);
--c100: hsl(0, 0%, 100%);
--cmax: hsl(0, 0%, 100%);
--bg: var(--c100);
--fg: var(--c00);
--accent-1: hsl(180, 100%, 15%);
--accent-2: hsl(180, 50%, 40%);
--accent-3: hsl(180, 40%, 69%);
--red1: hsl(0, 70%, 45%);
}
body { body {
font: normal 12px var(--family); font: normal 12px Arial, system-ui, sans-serif;
background-color: var(--bg);
color: var(--fg);
margin: 0;
} }
body:lang(ja) { body:lang(ja) {
font-family: Arial, 'Meiryo UI', 'MS Gothic', system-ui, sans-serif; font-family: Arial, 'Meiryo UI', 'MS Gothic', system-ui, sans-serif;
} }
body:lang(zh-CN) { body:lang(zh-CN) {
font-family: Arial, 'Microsoft YaHei UI', 'Microsoft YaHei', system-ui, sans-serif; font-family: Arial, 'Microsoft YaHei UI', 'Microsoft YaHei', system-ui, sans-serif;
} }
body:lang(zh-TW), body:lang(zh-TW),
body:lang(zh-HK) { body:lang(zh-HK) {
font-family: Arial, 'Microsoft JhengHei UI', 'Microsoft JhengHei', system-ui, sans-serif; font-family: Arial, 'Microsoft JhengHei UI', 'Microsoft JhengHei', system-ui, sans-serif;
@ -61,12 +24,11 @@ button {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 2px 7px; padding: 2px 7px;
border: 1px solid var(--c60); border: 1px solid hsl(0, 0%, 62%);
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
line-height: 1.2; color: #000;
color: var(--fg); background-color: hsl(0, 0%, 100%);
background-color: var(--bg);
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAeCAYAAADtlXTHAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QwGBBwIHvKt6QAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAL0lEQVQI12NoaGgQZ2JgYGBkYmBgYGZiYGBggrMY4VxsYsyoskQQCB2MWAxAMhkADVECDhlW9CoAAAAASUVORK5CYII='); background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAeCAYAAADtlXTHAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QwGBBwIHvKt6QAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAL0lEQVQI12NoaGgQZ2JgYGBkYmBgYGZiYGBggrMY4VxsYsyoskQQCB2MWAxAMhkADVECDhlW9CoAAAAASUVORK5CYII=');
background-repeat: repeat-x; background-repeat: repeat-x;
background-size: 100% 100%; background-size: 100% 100%;
@ -74,79 +36,39 @@ button {
} }
button:not(:disabled):hover { button:not(:disabled):hover {
background-color: var(--c95); background-color: hsl(0, 0%, 95%);
border-color: var(--c50); border-color: hsl(0, 0%, 52%);
} }
button:active { button:active {
background-color: var(--c95); background-color: hsl(0, 0%, 95%);
border-color: var(--c50); border-color: hsl(0, 0%, 52%);
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAeCAYAAADtlXTHAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QwJARIWJNZvuQAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAMElEQVQI12NoaGgIZmJgYPjLxMDA8I+JgYHhP5z1Dy7xH5X7jxQCzWQ0A9DEILYBABm5HtJk2jPHAAAAAElFTkSuQmCC'); background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAeCAYAAADtlXTHAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QwJARIWJNZvuQAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAMElEQVQI12NoaGgIZmJgYPjLxMDA8I+JgYHhP5z1Dy7xH5X7jxQCzWQ0A9DEILYBABm5HtJk2jPHAAAAAElFTkSuQmCC');
background-repeat: repeat-x; background-repeat: repeat-x;
background-size: 100% 100%; background-size: 100% 100%;
} }
button .svg-icon {
cursor: auto;
}
[data-ui-theme="light"] button .svg-icon {
/* Our svgs are pixel-aligned so the default #000 looks too strong */
fill: #333;
}
/* For some odd reason these hovers appear lighter than all other button hovers in every browser */ /* For some odd reason these hovers appear lighter than all other button hovers in every browser */
#message-box-buttons button:not(:disabled):hover { #message-box-buttons button:not(:disabled):hover {
background-color: var(--c90); background-color: hsl(0, 0%, 90%);
border-color: var(--c50); border-color: hsl(0, 0%, 50%);
} }
input { input {
font: inherit; font: inherit;
border: 1px solid var(--c65); border: 1px solid hsl(0, 0%, 66%);
transition: border-color .1s, box-shadow .1s; transition: border-color .1s, box-shadow .1s;
} }
input:not([type]), input:not([type]),
input[type=text],
input[type=number],
input[type=search] { input[type=search] {
background: var(--bg); background: #fff;
color: var(--fg); color: #000;
height: var(--input-height); height: 22px;
min-height: var(--input-height)!important; min-height: 22px!important;
line-height: var(--input-height); line-height: 22px;
box-sizing: border-box;
padding: 0 3px; padding: 0 3px;
border: 1px solid var(--c65); border: 1px solid hsl(0, 0%, 66%);
}
input:invalid {
background-color: rgba(255, 0, 0, 0.1);
color: darkred;
}
.svg-icon {
cursor: pointer;
vertical-align: middle;
transition: fill .5s;
width: 20px;
height: 20px;
fill: var(--c40);
}
.svg-icon:hover {
fill: var(--fg);
}
.svg-icon.info {
width: 14px;
height: 16px;
margin-left: .5ex;
}
.svg-icon.config {
width: 16px;
height: 16px;
} }
.svg-icon.checked { .svg-icon.checked {
@ -154,7 +76,7 @@ input:invalid {
height: 8px; height: 8px;
width: 8px; width: 8px;
display: none; display: none;
fill: var(--fg); fill: #000;
margin: 2px 0 0 2px; margin: 2px 0 0 2px;
} }
@ -169,7 +91,7 @@ input[type="checkbox"]:not(.slider) {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
border: 1px solid var(--c45); border: 1px solid hsl(0, 0%, 46%);
height: 12px; height: 12px;
width: 12px; width: 12px;
display: inline-flex; display: inline-flex;
@ -180,8 +102,8 @@ input[type="checkbox"]:not(.slider) {
} }
input[type="checkbox"]:not(.slider):hover { input[type="checkbox"]:not(.slider):hover {
border-color: var(--c30); border-color: hsl(0, 0%, 32%);
background-color: var(--c80); background-color: hsl(0, 0%, 82%);
} }
input[type="checkbox"]:not(.slider):checked + .svg-icon.checked { input[type="checkbox"]:not(.slider):checked + .svg-icon.checked {
@ -193,33 +115,29 @@ input[type="checkbox"]:not(.slider):checked + .svg-icon.checked {
input[type="checkbox"]:not(.slider):disabled { input[type="checkbox"]:not(.slider):disabled {
background-color: transparent; background-color: transparent;
border-color: var(--c50); border-color: hsl(0, 0%, 50%);
} }
input[type="checkbox"]:not(.slider):disabled + .svg-icon.checked { input[type="checkbox"]:not(.slider):disabled + .svg-icon.checked {
fill: var(--c50); fill: hsl(0, 0%, 50%);
} }
input[type="checkbox"]:not(.slider):disabled + .svg-icon.checked + span { input[type="checkbox"]:not(.slider):disabled + .svg-icon.checked + span {
color: var(--c50); color: hsl(0, 0%, 50%);
} }
label { label {
transition: color .1s; transition: color .1s;
} }
.checkbox-wrapper {
padding-left: 16px;
position: relative;
}
select { select {
-moz-appearance: none; -moz-appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
height: var(--input-height); height: 22px;
font: inherit; font: inherit;
color: var(--fg); color: #000;
background-color: transparent; background-color: transparent;
border: 1px solid var(--c65); border: 1px solid hsl(0, 0%, 66%);
padding: 0 20px 0 6px; padding: 0 20px 0 6px;
transition: color .5s; transition: color .5s;
} }
@ -237,7 +155,7 @@ select {
display: inline-flex; display: inline-flex;
height: 14px; height: 14px;
width: 14px; width: 14px;
fill: var(--fg); fill: #000;
position: absolute; position: absolute;
top: 4px; top: 4px;
right: 4px; right: 4px;
@ -247,15 +165,15 @@ select {
input[type="radio"] { input[type="radio"] {
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
background: var(--c90); background: hsl(0, 0%, 88%);
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--c60); border: 1px solid hsl(0, 0%, 60%);
cursor: default; cursor: default;
height: 13px; height: 13px;
width: 13px; width: 13px;
position: relative; position: relative;
margin: 0 4px 1px 0;
} }
input[type="radio"]:after { input[type="radio"]:after {
content: ''; content: '';
background-color: transparent; background-color: transparent;
@ -269,15 +187,11 @@ input[type="radio"]:after {
top: 2px; top: 2px;
position: absolute; position: absolute;
} }
input[type="radio"]:checked:after { input[type="radio"]:checked:after {
background-color: var(--c30); background-color: hsl(0, 0%, 30%);
transform: scale(1); transform: scale(1);
} }
.radio-wrapper {
display: flex;
align-items: center;
line-height: 1.5;
}
/* restore disabled state dimming */ /* restore disabled state dimming */
button:disabled, button:disabled,
@ -289,44 +203,13 @@ select[disabled] > option {
select:disabled + .select-arrow, select:disabled + .select-arrow,
select[disabled] + .select-arrow { select[disabled] + .select-arrow {
fill: var(--c50); fill: hsl(0, 0%, 50%);
}
summary {
-moz-user-select: none;
user-select: none;
} }
/* global stuff we use everywhere */ /* global stuff we use everywhere */
.hidden { .hidden {
display: none !important; display: none !important;
} }
.rel {
position: relative;
}
.abs {
position: absolute;
}
html:not(.all-disabled) body:not(#stylus-popup) #disableAll-label:not([data-persist]) {
display: none;
}
html:not(.all-disabled) #disableAll-label::before {
content: attr(data-on);
}
.all-disabled #disableAll-label::before {
content: attr(data-off);
}
.all-disabled #disableAll-label {
font-weight: bold;
color: var(--red1);
}
.all-disabled #disableAll-label .svg-icon {
fill: var(--red1);
}
.all-disabled #disableAll {
border-color: var(--red1);
}
:focus, :focus,
.CodeMirror-focused, .CodeMirror-focused,
@ -334,7 +217,6 @@ html:not(.all-disabled) #disableAll-label::before {
textarea[data-focused-via-click]:focus, textarea[data-focused-via-click]:focus,
input:not([type])[data-focused-via-click]:focus, /* same as "text" */ input:not([type])[data-focused-via-click]:focus, /* same as "text" */
input[type="text"][data-focused-via-click]:focus, input[type="text"][data-focused-via-click]:focus,
input[type="url"][data-focused-via-click]:focus,
input[type="search"][data-focused-via-click]:focus, input[type="search"][data-focused-via-click]:focus,
input[type="number"][data-focused-via-click]:focus { input[type="number"][data-focused-via-click]:focus {
/* Using box-shadow instead of the ugly outline in new Chrome */ /* Using box-shadow instead of the ugly outline in new Chrome */
@ -348,95 +230,24 @@ input[type="number"][data-focused-via-click]:focus {
box-shadow: none; box-shadow: none;
} }
/* header resizer */
:root {
--header-width: 280px;
--header-resizer-width: 8px;
}
#header-resizer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: var(--header-resizer-width);
box-sizing: border-box;
cursor: e-resize;
border-width: 0 1px;
border-style: solid;
color: hsla(0, 0%, 50%, .5);
border-color: currentColor;
pointer-events: auto;
}
#header-resizer:active {
border-color: var(--c50);
}
#header-resizer::after {
content: '';
position: absolute;
border-right: 2px dotted currentColor;
left: 2px;
width: 0;
height: 100%;
}
body.resizing-h {
cursor: e-resize;
}
body.resizing-v {
cursor: n-resize;
}
body.resizing-h > *,
body.resizing-v > * {
pointer-events: none;
-moz-user-select: none;
user-select: none;
}
/* header resizer - end */
.split-btn {
position: relative;
white-space: nowrap;
--menu-pad: .5em;
}
.split-btn-pedal {
margin-left: -1px !important;
padding-left: .25em !important;
padding-right: .25em !important;
min-width: 0 !important;
}
.split-btn-pedal::after {
--side: 4px;
content: '';
border: var(--side) solid transparent;
display: inline-block;
border-top: calc(var(--side) * 1.3) solid currentColor;
vertical-align: bottom;
}
.split-btn.active .split-btn-pedal {
box-shadow: inset 0 0 100px rgba(0, 0, 0, .2);
}
.split-btn-menu {
background: var(--bg);
position: absolute;
box-shadow: 2px 3px 7px rgba(0, 0, 0, .5);
border: 1px solid hsl(180deg, 50%, 50%);
white-space: nowrap;
cursor: pointer;
padding: .25em 0;
z-index: 1000;
}
.split-btn-menu > * {
padding: var(--menu-pad) 1em;
display: block;
}
.split-btn-menu > :hover {
background-color: hsla(180deg, 50%, 50%, .25);
color: var(--fg);
}
@supports (-moz-appearance: none) { @supports (-moz-appearance: none) {
.moz-appearance-bug .svg-icon.checked,
.moz-appearance-bug .onoffswitch input,
.moz-appearance-bug input[type="radio"]:after {
display: none !important;
}
.moz-appearance-bug input[type="checkbox"] {
-moz-appearance: checkbox !important;
}
.moz-appearance-bug input[type="radio"] {
-moz-appearance: radio !important;
}
.firefox select { .firefox select {
padding: 0 20px 0 2px; padding: 0 20px 0 2px;
line-height: var(--input-height)!important; line-height: 22px!important;
} }
svg { svg {
@ -446,9 +257,9 @@ body.resizing-v > * {
/* We can customize everything about number inputs except arrows. They're horrible in Linux FF, so we'll hide them unless hovered or focused. */ /* We can customize everything about number inputs except arrows. They're horrible in Linux FF, so we'll hide them unless hovered or focused. */
.firefox.non-windows input[type="number"] { .firefox.non-windows input[type="number"] {
-moz-appearance: textfield; -moz-appearance: textfield;
background: var(--bg); background: #fff;
color: var(--fg); color: #000;
border: 1px solid var(--c65); border: 1px solid hsl(0, 0%, 66%);
} }
.firefox.non-windows input[type="number"]:not(:disabled):hover, .firefox.non-windows input[type="number"]:not(:disabled):hover,
@ -457,14 +268,18 @@ body.resizing-v > * {
} }
.firefox.non-windows input[type="color"] { .firefox.non-windows input[type="color"] {
background: var(--bg); background: #fff;
border: 1px solid var(--c65); border: 1px solid hsl(0, 0%, 66%);
padding: 4px; padding: 4px;
} }
}
@media (max-width: 850px) { /* Firefox cannot handle fractions in font-size */
#header-resizer { .firefox button:not(.install) {
display: none !important; line-height: 13px;
padding: 3px 7px;
}
.firefox.moz-appearance-bug button:not(.install) {
padding: 2px 4px;
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 B

Some files were not shown because too many files have changed in this diff Show More