Merge branch 'master' into dev-color-scheme

This commit is contained in:
eight04 2021-10-20 20:12:12 +08:00
commit 241f5b9f41
237 changed files with 50629 additions and 39122 deletions

View File

@ -1,4 +1,2 @@
vendor/
vendor-overwrites/*
!vendor-overwrites/colorpicker
!vendor-overwrites/csslint
vendor-overwrites/

View File

@ -8,6 +8,9 @@ env:
es6: true
webextensions: true
globals:
require: readonly # in polyfill.js
rules:
accessor-pairs: [2]
array-bracket-spacing: [2, never]
@ -19,7 +22,7 @@ rules:
brace-style: [2, 1tbs, {allowSingleLine: false}]
camelcase: [2, {properties: never}]
class-methods-use-this: [2]
comma-dangle: [0]
comma-dangle: [2, {arrays: always-multiline, objects: always-multiline}]
comma-spacing: [2, {before: false, after: true}]
comma-style: [2, last]
complexity: [0]
@ -42,7 +45,15 @@ rules:
id-blacklist: [0]
id-length: [0]
id-match: [0]
indent-legacy: [2, 2, {VariableDeclarator: 0, SwitchCase: 1}]
indent: [2, 2, {
SwitchCase: 1,
ignoreComments: true,
ignoredNodes: [
"TemplateLiteral > *",
"ConditionalExpression",
"ForStatement"
]
}]
jsx-quotes: [0]
key-spacing: [0]
keyword-spacing: [2]
@ -86,7 +97,7 @@ rules:
no-empty: [2, {allowEmptyCatch: true}]
no-eq-null: [0]
no-eval: [2]
no-ex-assign: [2]
no-ex-assign: [0]
no-extend-native: [2]
no-extra-bind: [2]
no-extra-boolean-cast: [2]
@ -136,6 +147,9 @@ rules:
no-proto: [2]
no-redeclare: [2]
no-regex-spaces: [2]
no-restricted-globals: [2, name, event]
# `name` and `event` (in Chrome) are built-in globals
# but we don't use these globals so it's most likely a mistake/typo
no-restricted-imports: [0]
no-restricted-modules: [2, domain, freelist, smalloc, sys]
no-restricted-syntax: [2, WithStatement]
@ -163,7 +177,7 @@ rules:
no-unreachable: [2]
no-unsafe-finally: [2]
no-unsafe-negation: [2]
no-unused-expressions: [1]
no-unused-expressions: [2]
no-unused-labels: [0]
no-unused-vars: [2, {args: after-used}]
no-use-before-define: [2, nofunc]
@ -189,7 +203,7 @@ rules:
prefer-const: [1, {destructuring: all, ignoreReadBeforeAssign: true}]
quote-props: [0]
quotes: [1, single, avoid-escape]
radix: [2, as-needed]
radix: [2, always]
require-jsdoc: [0]
require-yield: [2]
semi-spacing: [2, {before: false, after: true}]
@ -220,3 +234,7 @@ overrides:
webextensions: false
parserOptions:
ecmaVersion: 2017
- files: ["**/*worker*.js"]
env:
worker: true

View File

@ -1,9 +0,0 @@
* **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.
-->

48
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,48 @@
---
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

@ -3,18 +3,12 @@ on: [push, pull_request]
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: ['10', '12', '14']
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
node-version: '14'
- run: npm install
- run: npm test

View File

@ -21,12 +21,9 @@ Stylus is a fork of Stylish for Chrome, also compatible with Firefox as a WebExt
## Screenshots
![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 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)
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) | ![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)
## Help
@ -47,15 +44,15 @@ See our [contributing](./.github/CONTRIBUTING.md) page for more details.
## License
Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/):
Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/):
Copyright &copy; 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com)
Current Stylus:
Current Stylus:
Copyright &copy; 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors)
**[GNU GPLv3](./LICENSE)**
**[GNU GPLv3](./LICENSE)**
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

View File

@ -1,19 +1,18 @@
{
"InaccessibleFileHint": {
"message": "Stylus لا يستطيع الوصول الى بعض انواع الملفات ( ملفات pdf و json )"
},
"addStyleLabel": {
"message": "كتابة نمط جديد",
"description": "Label for the button to go to the add style page"
"message": "كتابة نمط جديد"
},
"addStyleTitle": {
"message": "إضافة نمط",
"description": "Title of the page for adding styles"
"message": "إضافة نمط"
},
"appliesAdd": {
"message": "إضافة",
"description": "Label for the button to add an 'applies' entry"
"message": "إضافة"
},
"appliesDisplay": {
"message": "ينطبق على: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,84 +20,64 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "والمزيد",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "و المزيد"
},
"appliesDomainOption": {
"message": "عناوين URL في النطاق",
"description": "Option to make the style apply to the entered string as a domain"
"message": "عناوين URL في النطاق"
},
"appliesHelp": {
"message": "استخدم عناصر تحكم 'ينطبق على' لتقييد عناوين URL التي ينطبق عليها الرمز في هذا القسم.",
"description": "Help text for 'applies to' section"
"message": "استخدم عناصر تحكم 'ينطبق على' لتقييد عناوين URL التي ينطبق عليها الرمز في هذا القسم."
},
"appliesLabel": {
"message": "ينطبق على",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "ينطبق على"
},
"appliesRegexpOption": {
"message": "عناوين URL التي تطابق regexp",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "عناوين URL التي تطابق regexp"
},
"appliesRemove": {
"message": "إزالة",
"description": "Label for the button to remove an 'applies' entry"
"message": "إزالة"
},
"appliesSpecify": {
"message": "تحديد",
"description": "Label for the button to make a style apply only to specific sites"
"message": "تحديد"
},
"appliesToEverything": {
"message": "كل شيء",
"description": "Text displayed for styles that apply to all sites"
"message": "كل شيء"
},
"appliesUrlOption": {
"message": "عنوان URL",
"description": "Option to make the style apply to the entered string as a URL"
"message": "عنوان URL"
},
"appliesUrlPrefixOption": {
"message": "عناوين URL البادئة بـ",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "عناوين URL البادئة بـ"
},
"checkAllUpdates": {
"message": "البحث عن تحديثات لكل الأنماط",
"description": "Label for the button to check all styles for updates"
"message": "البحث عن تحديثات لكل الأنماط"
},
"checkForUpdate": {
"message": "البحث عن تحديث",
"description": "Label for the button to check a single style for an update"
"message": "البحث عن تحديث"
},
"checkingForUpdate": {
"message": "جارٍ البحث...",
"description": "Text to display when checking a style for an update"
"message": "جارٍ البحث..."
},
"deleteStyleConfirm": {
"message": "هل تريد بالتأكيد حذف هذا النمط؟",
"description": "Confirmation before deleting a style"
"message": "هل تريد بالتأكيد حذف هذا النمط؟"
},
"deleteStyleLabel": {
"message": "حذف",
"description": "Label for the button to delete a style"
"message": "حذف"
},
"description": {
"message": "يمكنك تغيير نمط الويب باستخدام Stylus، وهي أداة لإدارة أنماط المستخدم. وتتيح Stylus لك بسهولة تثبيت المظاهر والأشكال الخارجية لكل من Google، وFacebook وYouTube وOrkut فضلاً عن الكثير جدًا من مواقع الويب الأخرى.",
"description": "Extension description"
"message": "يمكنك تغيير نمط الويب باستخدام Stylus، وهي أداة لإدارة أنماط المستخدم. وتتيح Stylus لك بسهولة تثبيت المظاهر والأشكال الخارجية لكل من Google، وFacebook وYouTube وOrkut فضلاً عن الكثير جدًا من مواقع الويب الأخرى."
},
"disableStyleLabel": {
"message": "تعطيل",
"description": "Label for the button to disable a style"
"message": "تعطيل"
},
"editStyleHeading": {
"message": "تعديل النمط",
"description": "Title of the page for editing styles"
"message": "تعديل النمط"
},
"editStyleLabel": {
"message": "تعديل",
"description": "Label for the button to go to the edit style page"
"message": "تعديل"
},
"editStyleTitle": {
"message": "تعديل النمط $stylename$",
"description": "Title of the page for editing styles",
"placeholders": {
"stylename": {
"content": "$1"
@ -106,60 +85,46 @@
}
},
"enableStyleLabel": {
"message": "تمكين",
"description": "Label for the button to enable a style"
"message": "تمكين"
},
"findStylesForSite": {
"message": "البحث عن المزيد من الأنماط لموقع الويب هذا",
"description": "Text for a link that gets a list of styles for the current site"
"message": "البحث عن المزيد من الأنماط لموقع الويب هذا"
},
"helpAlt": {
"message": "مساعدة",
"description": "Alternate text for help buttons"
"message": "مساعدة"
},
"installUpdate": {
"message": "تثبيت التحديث",
"description": "Label for the button to install an update for a single style"
"message": "تثبيت التحديث"
},
"manageHeading": {
"message": "الأنماط المثبتة",
"description": "Heading for the manage page"
"message": "الأنماط المثبتة"
},
"noStylesForSite": {
"message": "لم يتم تثبيت أي أنماط لموقع الويب هذا.",
"description": "Text displayed when no styles are installed for the current site"
"message": "لم يتم تثبيت أي أنماط لموقع الويب هذا."
},
"openManage": {
"message": "إدارة الأنماط المثبتة",
"description": "Link to open the manage page."
"message": "إدارة الأنماط المثبتة"
},
"sectionAdd": {
"message": "إضافة قسم آخر",
"description": "Label for the button to add a section"
"message": "إضافة قسم آخر"
},
"sectionCode": {
"message": "الرمز",
"description": "Label for the code for a section"
"message": "الرمز"
},
"sectionRemove": {
"message": "إزالة القسم",
"description": "Label for the button to remove a section"
"message": "إزالة القسم"
},
"styleCancelEditLabel": {
"message": "رجوع للإدارة",
"description": "Label for cancel button for style editing"
"message": "رجوع للإدارة"
},
"styleChangesNotSaved": {
"message": "لقد أجريت تغييرات على هذا النمط بدون حفظها.",
"description": "Text for the prompt when changes are made to a style and the user tries to leave without saving"
"message": "لقد أجريت تغييرات على هذا النمط بدون حفظها."
},
"styleEnabledLabel": {
"message": "ممكّن",
"description": "Label for the enabled state of styles"
"message": "ممكّن"
},
"styleInstall": {
"message": "هل تريد تثبيت '$stylename$' في Stylus؟",
"description": "Confirmation when installing a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -167,20 +132,16 @@
}
},
"styleMissingName": {
"message": "أدخل اسمًا",
"description": "Error displayed when user saves without providing a name"
"message": "أدخل اسمًا"
},
"styleSaveLabel": {
"message": "حفظ",
"description": "Label for save button for style editing"
"message": "حفظ"
},
"styleToMozillaFormatHelp": {
"message": "يمكن استخدام تنسيق موزيلا للرمز باستخدام Stylus للمتصفح فايرفوكس ويمكن إرساله إلى userstyles.org.",
"description": "Help info for the Mozilla format header section that converts the code to/from Mozilla format"
"message": "يمكن استخدام تنسيق موزيلا للرمز باستخدام Stylus للمتصفح فايرفوكس ويمكن إرساله إلى userstyles.org."
},
"updateCheckFailBadResponseCode": {
"message": "أخفق التحديث - استجاب الخادم بالرمز $code$",
"description": "Text that displays when an update check failed because the response code indicates an error",
"placeholders": {
"code": {
"content": "$1"
@ -188,15 +149,12 @@
}
},
"updateCheckFailServerUnreachable": {
"message": "أخفق التحديث - الخادم يتعذر الوصول إليه.",
"description": "Text that displays when an update check failed because the update server is unreachable"
"message": "أخفق التحديث - الخادم يتعذر الوصول إليه."
},
"updateCheckSucceededNoUpdate": {
"message": "النمط محدّث.",
"description": "Text that displays when an update check completed and no update is available"
"message": "النمط محدّث."
},
"updateCompleted": {
"message": "اكتمل التحديث.",
"description": "Text that displays when an update completed"
"message": "اكتمل التحديث."
}
}
}

View File

@ -1,19 +1,15 @@
{
"addStyleLabel": {
"message": "Писане на нов стил",
"description": "Label for the button to go to the add style page"
"message": "Писане на нов стил"
},
"addStyleTitle": {
"message": "Добавяне на стил",
"description": "Title of the page for adding styles"
"message": "Добавяне на стил"
},
"appliesAdd": {
"message": "Добавяне",
"description": "Label for the button to add an 'applies' entry"
"message": "Добавяне"
},
"appliesDisplay": {
"message": "Приложимо за: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,196 +17,148 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "и още",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "и още"
},
"appliesDomainOption": {
"message": "Адреси на домейна",
"description": "Option to make the style apply to the entered string as a domain"
"message": "Адреси на домейна"
},
"appliesHelp": {
"message": "Използвайте 'Приложимо за', за да ограничите адресите, за които се отнася кода в отдела.",
"description": "Help text for 'applies to' section"
"message": "Използвайте 'Приложимо за', за да ограничите адресите, за които се отнася кода в отдела."
},
"appliesLabel": {
"message": "Приложимо за",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Приложимо за"
},
"appliesRegexpOption": {
"message": "Адреси, съвпадащи с регулярен израз",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "Адреси, съвпадащи с регулярен израз"
},
"appliesRemove": {
"message": "Премахване",
"description": "Label for the button to remove an 'applies' entry"
"message": "Премахване"
},
"appliesSpecify": {
"message": "Уточняване",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Уточняване"
},
"appliesToEverything": {
"message": "Всичко",
"description": "Text displayed for styles that apply to all sites"
"message": "Всичко"
},
"appliesUrlOption": {
"message": "Адрес",
"description": "Option to make the style apply to the entered string as a URL"
"message": "Адрес"
},
"appliesUrlPrefixOption": {
"message": "Адреси, започващи с",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "Адреси, започващи с"
},
"applyAllUpdates": {
"message": "Прилагане на всички обновления",
"description": "Label for the button to apply all detected updates"
"message": "Прилагане на всички обновления"
},
"backupButtons": {
"message": "Резервни копия",
"description": "Heading for backup"
"message": "Резервни копия"
},
"backupMessage": {
"message": "Изберете файл или го влачете до страницата.",
"description": "Message for backup"
"message": "Изберете файл или го влачете до страницата."
},
"bckpInstStyles": {
"message": "Изнасяне на стилове",
"description": ""
"message": "Изнасяне на стилове"
},
"checkAllUpdates": {
"message": "Проверка на всички стилове за обновления",
"description": "Label for the button to check all styles for updates"
"message": "Проверка на всички стилове за обновления"
},
"checkAllUpdatesForce": {
"message": "Повторна проверка",
"description": "Label for the button to apply all detected updates"
"message": "Повторна проверка"
},
"checkForUpdate": {
"message": "Проверка за обновления",
"description": "Label for the button to check a single style for an update"
"message": "Проверка за обновления"
},
"checkingForUpdate": {
"message": "Проверяване...",
"description": "Text to display when checking a style for an update"
"message": "Проверяване..."
},
"cm_autocompleteOnTyping": {
"message": "Автоматично завършване при въвеждане",
"description": "Label for the checkbox in the style editor."
"message": "Автоматично завършване при въвеждане"
},
"cm_indentWithTabs": {
"message": "Подпрозорци с умен отстъп",
"description": "Label for the checkbox controlling tabs with smart indentation option for the style editor."
"message": "Подпрозорци с умен отстъп"
},
"cm_keyMap": {
"message": "Клавиши",
"description": "Label for the drop-down list controlling the keymap for the style editor."
"message": "Клавиши"
},
"cm_lineWrapping": {
"message": "Пренасяне",
"description": "Label for the checkbox controlling word wrap option for the style editor."
"message": "Пренасяне"
},
"cm_matchHighlight": {
"message": "Осветяване",
"description": "Label for the drop-down list controlling the automatic highlighting of current word/selection occurrences in the style editor."
"message": "Осветяване"
},
"cm_matchHighlightSelection": {
"message": "Само избраното",
"description": "Style editor's 'highglight' drop-down list option: highlight the occurrences of currently selected text"
"message": "Само избраното"
},
"cm_matchHighlightToken": {
"message": "Низа под показалеца",
"description": "Style editor's 'highglight' drop-down list option: highlight the occurrences of the word/token under cursor even if nothing is selected"
"message": "Низа под показалеца"
},
"cm_resizeGripHint": {
"message": "Щракнете два пъти за възстановяване/увеличаване на височината",
"description": "Tooltip for the resize grip in style editor"
"message": "Щракнете два пъти за възстановяване/увеличаване на височината"
},
"cm_smartIndent": {
"message": "Умен отстъп",
"description": "Label for the checkbox controlling smart indentation option for the style editor."
"message": "Умен отстъп"
},
"cm_tabSize": {
"message": "Табулация",
"description": "Label for the text box controlling tab size option for the style editor."
"message": "Табулация"
},
"cm_theme": {
"message": "Тема",
"description": "Label for the style editor's CSS theme."
"message": "Тема"
},
"confirmCancel": {
"message": "Отказ",
"description": ""
"message": "Отказ"
},
"confirmDelete": {
"message": "Изтриване",
"description": ""
"message": "Изтриване"
},
"confirmNo": {
"message": "Не",
"description": "'No' button in a confirm dialog"
"message": "Не"
},
"confirmOK": {
"message": "Добре",
"description": ""
"message": "Добре"
},
"confirmStop": {
"message": "Спиране",
"description": "'Stop' button in a confirm dialog"
"message": "Спиране"
},
"confirmYes": {
"message": "Да",
"description": "'Yes' button in a confirm dialog"
"message": "Да"
},
"dbError": {
"message": "Възникна грешка с базата от данни. Искате ли да посетите страницата с възможни решения?",
"description": "Prompt when a DB error is encountered"
"message": "Възникна грешка с базата от данни. Искате ли да посетите страницата с възможни решения?"
},
"defaultTheme": {
"message": "по подразбиране",
"description": "Default CodeMirror CSS theme option on the edit style page"
"message": "по подразбиране"
},
"deleteStyleConfirm": {
"message": "Сигурни ли сте, че искате да изтриете стила?",
"description": "Confirmation before deleting a style"
"message": "Сигурни ли сте, че искате да изтриете стила?"
},
"deleteStyleLabel": {
"message": "Изтриване",
"description": "Label for the button to delete a style"
"message": "Изтриване"
},
"description": {
"message": "Пресъздайте стила на Мрежата със Стайлус, разширението за стилове. То ви позволява лесно да инсталиране теми за много сайтове.",
"description": "Extension description"
"message": "Пресъздайте стила на Мрежата със Стайлус, разширението за стилове. То ви позволява лесно да инсталиране теми за много сайтове."
},
"disableAllStyles": {
"message": "Изключване на всички стилове",
"description": "Label for the checkbox that turns all enabled styles off."
"message": "Изключване на всички стилове"
},
"disableStyleLabel": {
"message": "Изключване",
"description": "Label for the button to disable a style"
"message": "Изключване"
},
"dragDropMessage": {
"message": "Пуснете резервното копие където и да е по страницата, за да го внесете.",
"description": "Drag'n'drop message"
"message": "Пуснете резервното копие където и да е по страницата, за да го внесете."
},
"editDeleteText": {
"message": "Изтриване",
"description": "Label for the context menu item in the editor to delete selected text"
"message": "Изтриване"
},
"editGotoLine": {
"message": "Отиване на ред",
"description": "Go to line or line:column on Ctrl-G in style code editor"
"message": "Отиване на ред"
},
"editStyleHeading": {
"message": "Редактиране на стила",
"description": "Title of the page for editing styles"
"message": "Редактиране на стила"
},
"editStyleLabel": {
"message": "Редактиране",
"description": "Label for the button to go to the edit style page"
"message": "Редактиране"
},
"editStyleTitle": {
"message": "Редактиране на стила $stylename$",
"description": "Title of the page for editing styles",
"placeholders": {
"stylename": {
"content": "$1"
@ -218,116 +166,88 @@
}
},
"enableStyleLabel": {
"message": "Включване",
"description": "Label for the button to enable a style"
"message": "Включване"
},
"exportLabel": {
"message": "Изнасяне",
"description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)"
"message": "Изнасяне"
},
"findStylesForSite": {
"message": "Още стилове за този сайт",
"description": "Text for a link that gets a list of styles for the current site"
"message": "Още стилове за този сайт"
},
"genericDisabledLabel": {
"message": "Изключено",
"description": "Used in various lists/options to indicate that something is disabled"
"message": "Изключено"
},
"genericHistoryLabel": {
"message": "Хронология",
"description": "Used in various places to show a history log of something"
"message": "Хронология"
},
"helpAlt": {
"message": "Помощ",
"description": "Alternate text for help buttons"
"message": "Помощ"
},
"helpKeyMapCommand": {
"message": "Въведете име",
"description": "Placeholder text of inputbox in keymap help popup on the edit style page. Must be very short"
"message": "Въведете име"
},
"helpKeyMapHotkey": {
"message": "Натиснете клавиш",
"description": "Placeholder text of inputbox in keymap help popup on the edit style page. Must be very short"
"message": "Натиснете клавиш"
},
"importAppendLabel": {
"message": "Прибавяне към стила",
"description": "Label for the button to import a style and append to the existing sections"
"message": "Прибавяне към стила"
},
"importAppendTooltip": {
"message": "Прибавяне на внесения стил към текущия",
"description": "Tooltip for the button to import a style and append to the existing sections"
"message": "Прибавяне на внесения стил към текущия"
},
"importLabel": {
"message": "Внасяне",
"description": "Label for the button to import a style ('edit' page) or all styles ('manage' page)"
"message": "Внасяне"
},
"importReplaceLabel": {
"message": "Презаписване на стила",
"description": "Label for the button to import and overwrite current style"
"message": "Презаписване на стила"
},
"importReplaceTooltip": {
"message": "Презаписване на съдържанието на текщия стил с това от внесения",
"description": "Label for the button to import and overwrite current style"
"message": "Презаписване на съдържанието на текщия стил с това от внесения"
},
"importReportLegendAdded": {
"message": "добавени",
"description": "Text after the number of styles added in the report shown after importing styles"
"message": "добавени"
},
"importReportLegendIdentical": {
"message": "пропуснати еднакви",
"description": "Text after the number of styles skipped due to being identical to the already installed ones in the report shown after importing styles"
"message": "пропуснати еднакви"
},
"importReportLegendInvalid": {
"message": "пропуснати невалидни",
"description": "Text after the number of styles skipped due to being invalid (not a Stylus/Stylish backup file probably) in the report shown after importing styles"
"message": "пропуснати невалидни"
},
"importReportLegendUpdatedBoth": {
"message": "с обновени код и метаданни",
"description": "Text after the number of styles updated entirely in the report shown after importing styles"
"message": "с обновени код и метаданни"
},
"importReportLegendUpdatedCode": {
"message": "с обновен код",
"description": "Text after the number of styles with updated code (meta info is unchanged) in the report shown after importing styles"
"message": "с обновен код"
},
"importReportLegendUpdatedMeta": {
"message": "с обновени метаданни",
"description": "Text after the number of styles with updated meta info like name/url in the report shown after importing styles"
"message": "с обновени метаданни"
},
"importReportTitle": {
"message": "Внасянето на стилове завърши",
"description": "Title of the report shown after importing styles"
"message": "Внасянето на стилове завърши"
},
"importReportUnchanged": {
"message": "Нищо не беше променено.",
"description": "Message in the report shown after importing styles"
"message": "Нищо не беше променено."
},
"importReportUndone": {
"message": "върнати стила",
"description": "Text after the number of styles reverted in the message box shown after undoing the import of styles"
"message": "върнати стила"
},
"importReportUndoneTitle": {
"message": "Внасянето беше отменено",
"description": "Title of the message box shown after undoing the import of styles"
"message": "Внасянето беше отменено"
},
"installUpdate": {
"message": "Инсталиране на обновлението",
"description": "Label for the button to install an update for a single style"
"message": "Инсталиране на обновлението"
},
"linkGetHelp": {
"message": "Помощ",
"description": "Homepage link text on the manage page e.g. https://add0n.com/stylus.html#features with chat/FAQ/intro/info"
"message": "Помощ"
},
"linkGetStyles": {
"message": "Вземете стилове",
"description": "Help link text on the manage page e.g. https://userstyles.org"
"message": "Вземете стилове"
},
"linterIssues": {
"message": "Проблеми",
"description": "Label for the CSS linter issues block on the style edit page"
"message": "Проблеми"
},
"linterIssuesHelp": {
"message": "Проблеми, намерени от $link$ при следните правила:",
"description": "Help popup message for the selected CSS linter issues block on the style edit page",
"placeholders": {
"link": {
"content": "$1"
@ -335,244 +255,181 @@
}
},
"manageFavicons": {
"message": "Иконки на приложимите сайтовете",
"description": "Label for the checkbox that toggles applies-to favicons in the new UI on manage page"
"message": "Иконки на приложимите сайтовете"
},
"manageFaviconsGray": {
"message": "Сиви",
"description": "Label for the checkbox that toggles grayed out mode of applies-to favicons in the new UI on manage page"
"message": "Сиви"
},
"manageFaviconsHelp": {
"message": "Разширението използва външна услуга https://www.google.com/s2/favicons",
"description": "Label for the checkbox that toggles applies-to favicons in the new UI on manage page"
"message": "Разширението използва външна услуга https://www.google.com/s2/favicons"
},
"manageFilters": {
"message": "Филтри",
"description": "Label for filters container"
"message": "Филтри"
},
"manageHeading": {
"message": "Инсталирани стилове",
"description": "Heading for the manage page"
"message": "Инсталирани стилове"
},
"manageMaxTargets": {
"message": "Брой на видимите приложими адреси",
"description": "Label for the numeric input box to limit max number of applies-to targets in the new UI on manage page"
"message": "Брой на видимите приложими адреси"
},
"manageNewUI": {
"message": "Нов интерфейс за управление",
"description": "Label for the checkbox that toggles the new UI on manage page"
"message": "Нов интерфейс за управление"
},
"manageOnlyEnabled": {
"message": "Само включените стилове",
"description": "Checkbox to show only enabled styles"
"message": "Само включените стилове"
},
"manageOnlyLocal": {
"message": "Само местно създадените стилове",
"description": "Checkbox to show only locally created styles i.e. non-updatable"
"message": "Само местно създадените стилове"
},
"manageOnlyLocalTooltip": {
"message": "(стиловете, които не са инсталирани през userstyles.org)",
"description": "Tooltip for the checkbox to show only locally created styles i.e. non-updatable"
"message": "(стиловете, които не са инсталирани през userstyles.org)"
},
"manageOnlyUpdates": {
"message": "Само стилове с обновления или проблеми",
"description": "Checkbox to show only styles that have updates after check-all-styles-for-updates was performed"
"message": "Само стилове с обновления или проблеми"
},
"manageTitle": {
"message": "Стилове",
"description": "Title for the manage page"
"message": "Стилове"
},
"menuShowBadge": {
"message": "Брой на активните стилове",
"description": "Label (must be very short) for the checkbox in the toolbar button context menu controlling toolbar badge text."
"message": "Брой на активните стилове"
},
"noStylesForSite": {
"message": "Няма инсталирани стилове за сайта.",
"description": "Text displayed when no styles are installed for the current site"
"message": "Няма инсталирани стилове за сайта."
},
"openManage": {
"message": "Управление",
"description": "Link to open the manage page."
"message": "Управление"
},
"openStylesManager": {
"message": "Управление на стиловете",
"description": "Label for the style maanger opener in the browser action context menu."
"message": "Управление на стиловете"
},
"optionsActions": {
"message": "Действия",
"description": ""
"message": "Действия"
},
"optionsAdvanced": {
"message": "Разширени",
"description": ""
"message": "Разширени"
},
"optionsAdvancedContextDelete": {
"message": "Добавяне на 'Изтриване' в контекстното меню на редактора",
"description": ""
"message": "Добавяне на 'Изтриване' в контекстното меню на редактора"
},
"optionsAdvancedExposeIframes": {
"message": "Разкриване на 'iframes' чрез HTML[stylus-iframe]",
"description": ""
"message": "Разкриване на 'iframes' чрез HTML[stylus-iframe]"
},
"optionsBadgeDisabled": {
"message": "Цвят на фона, когато е изключено",
"description": ""
"message": "Цвят на фона, когато е изключено"
},
"optionsBadgeNormal": {
"message": "Цвят на фона",
"description": ""
"message": "Цвят на фона"
},
"optionsCheck": {
"message": "Обновяване на стиловете",
"description": ""
"message": "Обновяване на стиловете"
},
"optionsCheckUpdate": {
"message": "Проверка и инсталиране на наличните обновления",
"description": ""
"message": "Проверка и инсталиране на наличните обновления"
},
"optionsCustomizeBadge": {
"message": "Значка на иконката на лентата",
"description": ""
"message": "Значка на иконката на лентата"
},
"optionsCustomizeIcon": {
"message": "Иконка на лентата със сечива",
"description": ""
"message": "Иконка на лентата със сечива"
},
"optionsCustomizePopup": {
"message": "Падащ прозорец",
"description": ""
"message": "Падащ прозорец"
},
"optionsCustomizeUpdate": {
"message": "Обновления",
"description": ""
"message": "Обновления"
},
"optionsHeading": {
"message": "Настройки",
"description": "Heading for options section on manage page."
"message": "Настройки"
},
"optionsIconDark": {
"message": "Тъмни теми",
"description": ""
"message": "Тъмни теми"
},
"optionsIconLight": {
"message": "Светли теми",
"description": ""
"message": "Светли теми"
},
"optionsOpen": {
"message": "Отваряне",
"description": ""
"message": "Отваряне"
},
"optionsOpenManager": {
"message": "Управление на стиловете",
"description": ""
"message": "Управление на стиловете"
},
"optionsPopupWidth": {
"message": "Ширина на падащия прозорец (в пиксели)",
"description": ""
"message": "Ширина на падащия прозорец (в пиксели)"
},
"optionsReset": {
"message": "Зануляване на настройки на първоначалните стойности",
"description": ""
"message": "Зануляване на настройки на първоначалните стойности"
},
"optionsResetButton": {
"message": "Зануляване на настройките",
"description": ""
"message": "Зануляване на настройките"
},
"optionsSubheading": {
"message": "Още настройки",
"description": "Subheading for options section on manage page."
"message": "Още настройки"
},
"optionsUpdateImportNote": {
"message": "При внасянето на резервни копия от стари версии или от Стайлиш направете ръчна проверка за обновления, за да сте сигурни, че стиловете са актуални.",
"description": ""
"message": "При внасянето на резервни копия от стари версии или от Стайлиш направете ръчна проверка за обновления, за да сте сигурни, че стиловете са актуални."
},
"popupStylesFirst": {
"message": "Стилове преди командите",
"description": "Label for the checkbox controlling section order in the popup."
"message": "Стилове преди командите"
},
"prefShowBadge": {
"message": "Брой на активните стилове за текущия сайт",
"description": "Label for the checkbox controlling toolbar badge text."
"message": "Брой на активните стилове за текущия сайт"
},
"replace": {
"message": "Заместване",
"description": "Label before the replace input field in the editor shown on Ctrl-H"
"message": "Заместване"
},
"replaceAll": {
"message": "Заместване на всички",
"description": "Label before the replace input field in the editor shown on 'replaceAll' hotkey"
"message": "Заместване на всички"
},
"replaceWith": {
"message": "Заместване с",
"description": "Label before the replace-with input field in the editor shown on Ctrl-H etc."
"message": "Заместване с"
},
"retrieveBckp": {
"message": "Внасяне на стилове",
"description": ""
"message": "Внасяне на стилове"
},
"search": {
"message": "Търсене",
"description": "Label before the search input field in the editor shown on Ctrl-F"
"message": "Търсене"
},
"searchRegexp": {
"message": "Използвайте синтаксиса /re/ за търсене с регулярни изрази",
"description": "Label after the search input field in the editor shown on Ctrl-F"
},
"searchStyles": {
"message": "Търсене на съдържанието",
"description": "Label for the search filter textbox on the Manage styles page"
"message": "Използвайте синтаксиса /re/ за търсене с регулярни изрази"
},
"sectionAdd": {
"message": "Добавяне на друг отдел",
"description": "Label for the button to add a section"
"message": "Добавяне на друг отдел"
},
"sectionCode": {
"message": "Код",
"description": "Label for the code for a section"
"message": "Код"
},
"sectionRemove": {
"message": "Премахване на отдела",
"description": "Label for the button to remove a section"
"message": "Премахване на отдела"
},
"shortcuts": {
"message": "Клавишни комбинации",
"description": "Go to shortcut configuration"
"message": "Клавишни комбинации"
},
"shortcutsNote": {
"message": "Задаване на клавишни комбинации",
"description": ""
"message": "Задаване на клавишни комбинации"
},
"styleBadRegexp": {
"message": "Регулярният израз не е правилен.",
"description": "Validation message for a bad regexp in a style"
"message": "Регулярният израз не е правилен."
},
"styleBeautify": {
"message": "Разкрасяване",
"description": "Label for the CSS-beautifier button on the edit style page"
"message": "Разкрасяване"
},
"styleBeautifyIndentConditional": {
"message": "Отстъп на @media, @supports",
"description": "CSS-beautifier option"
"message": "Отстъп на @media, @supports"
},
"styleCancelEditLabel": {
"message": "Назад към стиловете",
"description": "Label for cancel button for style editing"
"message": "Назад към стиловете"
},
"styleChangesNotSaved": {
"message": "Направили сте промени по стила без да ги запазите.",
"description": "Text for the prompt when changes are made to a style and the user tries to leave without saving"
"message": "Направили сте промени по стила без да ги запазите."
},
"styleEnabledLabel": {
"message": "Включено",
"description": "Label for the enabled state of styles"
"message": "Включено"
},
"styleFromMozillaFormatPrompt": {
"message": "Поставете кода във формат на Мозила",
"description": "Prompt in the dialog displayed after clicking 'Import from Mozilla format' button"
"message": "Поставете кода във формат на Мозила"
},
"styleInstall": {
"message": "Да се инсталира ли '$stylename$'?",
"description": "Confirmation when installing a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -580,68 +437,52 @@
}
},
"styleMissingName": {
"message": "Въведете име",
"description": "Error displayed when user saves without providing a name"
"message": "Въведете име"
},
"styleMozillaFormatHeading": {
"message": "Формат на Мозила",
"description": "Heading for the section with buttons to import/export Mozilla format of the style"
"message": "Формат на Мозила"
},
"styleNotAppliedRegexpProblemTooltip": {
"message": "Стилът не е приложен поради неправилно използване на регулярни изрази",
"description": "Tooltip in the popup for styles that were not applied at all"
"message": "Стилът не е приложен поради неправилно използване на регулярни изрази"
},
"styleRegexpInvalidExplanation": {
"message": "Има правила на регулярни изрази, които не могат да бъдат компилирани.",
"description": ""
"message": "Има правила на регулярни изрази, които не могат да бъдат компилирани."
},
"styleRegexpPartialExplanation": {
"message": "Стилът използва частично съвпадащи регулярни изрази и нарушава <a href='https://developer.mozilla.org/docs/Web/CSS/@document'>Спецификацията @document</a>, която изисква пълно съвпадение на адреса. Засегнатите отдели не са приложени. Стилът вероятно е създаден в Stylish-for-Chrome, което неправилно проверява правилата на 'regexp()' още от първата версия (познат дефект).",
"description": ""
"message": "Стилът използва частично съвпадащи регулярни изрази и нарушава <a href='https://developer.mozilla.org/docs/Web/CSS/@document'>Спецификацията @document</a>, която изисква пълно съвпадение на адреса. Засегнатите отдели не са приложени. Стилът вероятно е създаден в Stylish-for-Chrome, което неправилно проверява правилата на 'regexp()' още от първата версия (познат дефект)."
},
"styleRegexpProblemTooltip": {
"message": "Брой на неприложените отдели поради неправилно използване на регулярни изрази",
"description": "Tooltip in the popup for styles that were applied only partially"
"message": "Брой на неприложените отдели поради неправилно използване на регулярни изрази"
},
"styleRegexpTestButton": {
"message": "Тест на регулярния израз",
"description": "RegExp test button label in the editor shown when applies-to list has a regexp value"
"message": "Тест на регулярния израз"
},
"styleRegexpTestFull": {
"message": "Съвпадащи подпрозорци",
"description": "RegExp test report: label for the fully matching expressions"
"message": "Съвпадащи подпрозорци"
},
"styleRegexpTestInvalid": {
"message": "Неправилните регулярни изрази са пропуснати",
"description": "RegExp test report: label for the invalid expressions"
"message": "Неправилните регулярни изрази са пропуснати"
},
"styleRegexpTestNone": {
"message": "Няма съвпадащи подпрозорци",
"description": "RegExp test report: label for expressions that didn't match any tabs"
"message": "Няма съвпадащи подпрозорци"
},
"styleRegexpTestPartial": {
"message": "Не съвпада напълно, затова е пропуснато",
"description": "RegExp test report: label for the partially matching expressions"
"message": "Не съвпада напълно, затова е пропуснато"
},
"styleRegexpTestTitle": {
"message": "Списък със съвпадащи отворени подпрозорци (щракнете на адреса, за да се фокусира на подпрозореца)",
"description": "RegExp test report: title of the report"
"message": "Списък със съвпадащи отворени подпрозорци (щракнете на адреса, за да се фокусира на подпрозореца)"
},
"styleSaveLabel": {
"message": "Запазване",
"description": "Label for save button for style editing"
"message": "Запазване"
},
"styleToMozillaFormatHelp": {
"message": "Форматът на Мозила може да се подаде в userstyles.org и да се използва със Стайлиш (Stylish)",
"description": "Help info for the Mozilla format header section that converts the code to/from Mozilla format"
"message": "Форматът на Мозила може да се подаде в userstyles.org и да се използва със Стайлиш (Stylish)"
},
"styleToMozillaFormatTitle": {
"message": "Стил във формат на Мозила",
"description": "Title of the popup with the style code in Mozilla format, shown after pressing the Export button on Edit style page"
"message": "Стил във формат на Мозила"
},
"styleUpdate": {
"message": "Сигурни ли сте, че искате да обновите '$stylename$'?",
"description": "Confirmation when updating a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -649,44 +490,34 @@
}
},
"stylusUnavailableForURL": {
"message": "Разширението не работи на такива страници.",
"description": "Note in the toolbar pop-up when on a URL Stylus can't affect"
"message": "Разширението не работи на такива страници."
},
"stylusUnavailableForURLdetails": {
"message": "Като предпазна мярка, четецът забранява на разширенията да влияят на вградените страници (например chrome://version, about:addons, стандартната страница от Хром 61 и други), както и на страниците на други разширения. Достъпът до магазина с добавки на всеки четец също е ограничен.",
"description": "Sub-note in the toolbar pop-up when on a URL Stylus can't affect"
"message": "Като предпазна мярка, четецът забранява на разширенията да влияят на вградените страници (например chrome://version, about:addons, стандартната страница от Хром 61 и други), както и на страниците на други разширения. Достъпът до магазина с добавки на всеки четец също е ограничен."
},
"toggleStyle": {
"message": "Превключване на стила",
"description": "Label for the checkbox to enable/disable a style"
"message": "Превключване на стила"
},
"undo": {
"message": "Отмяна",
"description": "Button label"
"message": "Отмяна"
},
"undoGlobal": {
"message": "Отмяна във всички отдели",
"description": "CSS-beautify global Undo button label"
"message": "Отмяна във всички отдели"
},
"unreachableContentScript": {
"message": "Няма връзка със страницата. Презаредете подпрозореца.",
"description": "Note in the toolbar popup usually on file:// URLs after [re]loading Stylus"
"message": "Няма връзка със страницата. Презаредете подпрозореца."
},
"unreachableFileHint": {
"message": "Разширението ще има достъп до адреси от типа file:// само ако включите съответната отметка на страницата chrome://extensions.",
"description": "Note in the toolbar popup for file:// URLs"
"message": "Разширението ще има достъп до адреси от типа file:// само ако включите съответната отметка на страницата chrome://extensions."
},
"updateAllCheckSucceededNoUpdate": {
"message": "Няма намерени обновления.",
"description": "Text that displays when an update all check completed and no updates are available"
"message": "Няма намерени обновления."
},
"updateAllCheckSucceededSomeEdited": {
"message": "Някои от стиловете не са проверени, за да не се загубят местните редакции. Обновленията могат да бъдат принудени с индивидуална проверка или с пускането на още една проверка за всички (местните промени ще бъдат презаписани).",
"description": "Text that displays when an update all check completed and no updates are available"
"message": "Някои от стиловете не са проверени, за да не се загубят местните редакции. Обновленията могат да бъдат принудени с индивидуална проверка или с пускането на още една проверка за всички (местните промени ще бъдат презаписани)."
},
"updateCheckFailBadResponseCode": {
"message": "Неуспешно обновяване: сървърът отговори с код $code$.",
"description": "Text that displays when an update check failed because the response code indicates an error",
"placeholders": {
"code": {
"content": "$1"
@ -694,47 +525,36 @@
}
},
"updateCheckFailServerUnreachable": {
"message": "Неуспешно обновяване: няма връзка със сървъра.",
"description": "Text that displays when an update check failed because the update server is unreachable"
"message": "Неуспешно обновяване: няма връзка със сървъра."
},
"updateCheckHistory": {
"message": "Хронология на проверките",
"description": ""
"message": "Хронология на проверките"
},
"updateCheckManualUpdateForce": {
"message": "Инсталиране на обновлението (местните редакции ще бъдат презаписани)",
"description": "Additional text displayed when an update check skipped updating the style to avoid losing local modifications"
"message": "Инсталиране на обновлението (местните редакции ще бъдат презаписани)"
},
"updateCheckManualUpdateHint": {
"message": "Принудителното обновяване ще презапише местните редакции.",
"description": "Additional text displayed when an update check skipped updating the style to avoid losing local modifications"
"message": "Принудителното обновяване ще презапише местните редакции."
},
"updateCheckSkippedLocallyEdited": {
"message": "Стилът е бил местно редактиран.",
"description": "Text that displays when an update check skipped updating the style to avoid losing local modifications"
"message": "Стилът е бил местно редактиран."
},
"updateCheckSkippedMaybeLocallyEdited": {
"message": "Стилът може да е бил местно редактиран.",
"description": "Text that displays when an update check skipped updating the style to avoid losing possible local modifications"
"message": "Стилът може да е бил местно редактиран."
},
"updateCheckSucceededNoUpdate": {
"message": "Стилът е обновен.",
"description": "Text that displays when an update check completed and no update is available"
"message": "Стилът е обновен."
},
"updateCompleted": {
"message": "Обновяването е завършено.",
"description": "Text that displays when an update completed"
"message": "Обновяването е завършено."
},
"updatesCurrentlyInstalled": {
"message": "Инсталирани обновления:",
"description": "Text that displays when an update is installed on options page. Followed by the number of currently installed updates."
"message": "Инсталирани обновления:"
},
"writeStyleFor": {
"message": "Писане на стил за: ",
"description": "Label for toolbar pop-up that precedes the links to write a new style"
"message": "Писане на стил за: "
},
"writeStyleForURL": {
"message": "този адрес",
"description": "Text for link in toolbar pop-up to write a new style for the current URL"
"message": "този адрес"
}
}

View File

@ -1,19 +1,15 @@
{
"addStyleLabel": {
"message": "Напиши нов стил",
"description": "Label for the button to go to the add style page"
"message": "Напиши нов стил"
},
"addStyleTitle": {
"message": "Добави стил",
"description": "Title of the page for adding styles"
"message": "Добави стил"
},
"appliesAdd": {
"message": "Добави",
"description": "Label for the button to add an 'applies' entry"
"message": "Добави"
},
"appliesDisplay": {
"message": "Прилага се към: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,136 +17,103 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "и още",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "и още"
},
"appliesDomainOption": {
"message": "URLи на домейна",
"description": "Option to make the style apply to the entered string as a domain"
"message": "URLи на домейна"
},
"appliesHelp": {
"message": "Използвайте \"Прилага се към\", за да ограничете адресите, за които ще работи кодът в тази секция.",
"description": "Help text for 'applies to' section"
"message": "Използвайте \"Прилага се към\", за да ограничете адресите, за които ще работи кодът в тази секция."
},
"appliesLabel": {
"message": "Прилага се към",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Прилага се към"
},
"appliesRegexpOption": {
"message": "Адреси, съвпадащи с regexp",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "Адреси, съвпадащи с regexp"
},
"appliesRemove": {
"message": "Премахни",
"description": "Label for the button to remove an 'applies' entry"
"message": "Премахни"
},
"appliesSpecify": {
"message": "Уточни",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Уточни"
},
"appliesToEverything": {
"message": "Всички",
"description": "Text displayed for styles that apply to all sites"
"message": "Всички"
},
"appliesUrlPrefixOption": {
"message": "URL започващи с",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "URL започващи с"
},
"applyAllUpdates": {
"message": "Приложи всички промени",
"description": "Label for the button to apply all detected updates"
"message": "Приложи всички промени"
},
"checkAllUpdates": {
"message": "Провери всички стилове за обновления",
"description": "Label for the button to check all styles for updates"
"message": "Провери всички стилове за обновления"
},
"checkForUpdate": {
"message": "Провери за обновление",
"description": "Label for the button to check a single style for an update"
"message": "Провери за обновление"
},
"checkingForUpdate": {
"message": "Проверявам...",
"description": "Text to display when checking a style for an update"
"message": "Проверявам..."
},
"cm_indentWithTabs": {
"message": "Използвай табулация с умно отместване",
"description": "Label for the checkbox controlling tabs with smart indentation option for the style editor."
"message": "Използвай табулация с умно отместване"
},
"cm_keyMap": {
"message": "Клавишни комбинации",
"description": "Label for the drop-down list controlling the keymap for the style editor."
"message": "Клавишни комбинации"
},
"cm_lineWrapping": {
"message": "Автоматично пренасяне",
"description": "Label for the checkbox controlling word wrap option for the style editor."
"message": "Автоматично пренасяне"
},
"cm_smartIndent": {
"message": "Използвай умно отместване",
"description": "Label for the checkbox controlling smart indentation option for the style editor."
"message": "Използвай умно отместване"
},
"cm_tabSize": {
"message": "Размер на табулацията",
"description": "Label for the text box controlling tab size option for the style editor."
"message": "Размер на табулацията"
},
"cm_theme": {
"message": "Тема",
"description": "Label for the style editor's CSS theme."
"message": "Тема"
},
"confirmNo": {
"message": "Не",
"description": "'No' button in a confirm dialog"
"message": "Не"
},
"confirmStop": {
"message": "Спри",
"description": "'Stop' button in a confirm dialog"
"message": "Спри"
},
"confirmYes": {
"message": "Да",
"description": "'Yes' button in a confirm dialog"
"message": "Да"
},
"dbError": {
"message": "Грешка в базата данни на Stylus. Желаеш ли да посетиш уебстраницата с възможни решения?",
"description": "Prompt when a DB error is encountered"
"message": "Грешка в базата данни на Stylus. Желаеш ли да посетиш уебстраницата с възможни решения?"
},
"defaultTheme": {
"message": "по подразбиране",
"description": "Default CodeMirror CSS theme option on the edit style page"
"message": "по подразбиране"
},
"deleteStyleConfirm": {
"message": "Наистина ли искаш да изтриеш този стил?",
"description": "Confirmation before deleting a style"
"message": "Наистина ли искаш да изтриеш този стил?"
},
"deleteStyleLabel": {
"message": "Изтрий",
"description": "Label for the button to delete a style"
"message": "Изтрий"
},
"description": {
"message": "Промени уеба със Stylus, мениджър на потребителски стилове. Stylus ти позволява лесно да инсталираш теми и скинове за много популярни сайтове.",
"description": "Extension description"
"message": "Промени уеба със Stylus, мениджър на потребителски стилове. Stylus ти позволява лесно да инсталираш теми и скинове за много популярни сайтове."
},
"disableAllStyles": {
"message": "Изключи всички стилове",
"description": "Label for the checkbox that turns all enabled styles off."
"message": "Изключи всички стилове"
},
"disableStyleLabel": {
"message": "Забрани",
"description": "Label for the button to disable a style"
"message": "Забрани"
},
"editGotoLine": {
"message": "Иди на ред (или ред:кол)",
"description": "Go to line or line:column on Ctrl-G in style code editor"
"message": "Иди на ред (или ред:кол)"
},
"editStyleHeading": {
"message": "Промени стила",
"description": "Title of the page for editing styles"
"message": "Промени стила"
},
"editStyleLabel": {
"message": "Редактирай",
"description": "Label for the button to go to the edit style page"
"message": "Редактирай"
},
"editStyleTitle": {
"message": "Редактирай стил $stylename$",
"description": "Title of the page for editing styles",
"placeholders": {
"stylename": {
"content": "$1"
@ -158,68 +121,52 @@
}
},
"enableStyleLabel": {
"message": "Разреши",
"description": "Label for the button to enable a style"
"message": "Разреши"
},
"exportLabel": {
"message": "Експорт",
"description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)"
"message": "Експорт"
},
"helpAlt": {
"message": "Помощ",
"description": "Alternate text for help buttons"
"message": "Помощ"
},
"helpKeyMapCommand": {
"message": "Напиши име на команда",
"description": "Placeholder text of inputbox in keymap help popup on the edit style page. Must be very short"
"message": "Напиши име на команда"
},
"helpKeyMapHotkey": {
"message": "Натисни клавишна комбинация",
"description": "Placeholder text of inputbox in keymap help popup on the edit style page. Must be very short"
"message": "Натисни клавишна комбинация"
},
"importAppendLabel": {
"message": "Добави към стил",
"description": "Label for the button to import a style and append to the existing sections"
"message": "Добави към стил"
},
"importAppendTooltip": {
"message": "Добави импортирания стил към текущия",
"description": "Tooltip for the button to import a style and append to the existing sections"
"message": "Добави импортирания стил към текущия"
},
"importLabel": {
"message": "Импорт",
"description": "Label for the button to import a style ('edit' page) or all styles ('manage' page)"
"message": "Импорт"
},
"importReplaceLabel": {
"message": "Презапиши стила",
"description": "Label for the button to import and overwrite current style"
"message": "Презапиши стила"
},
"importReplaceTooltip": {
"message": "Презапишете съдържанието на текущия стил с импортирания",
"description": "Label for the button to import and overwrite current style"
"message": "Презапишете съдържанието на текущия стил с импортирания"
},
"installButton": {
"message": "Инсталирай стил",
"description": "Label for install button"
"message": "Инсталирай стил"
},
"installButtonInstalled": {
"message": "Стилът е инсталиран",
"description": "Text displayed when the style is successfully installed"
"message": "Стилът е инсталиран"
},
"installButtonReinstall": {
"message": "Преинсталирай стила",
"description": "Label for reinstall button"
"message": "Преинсталирай стила"
},
"installButtonUpdate": {
"message": "Обнови стила",
"description": "Label for update button"
"message": "Обнови стила"
},
"installUpdate": {
"message": "Инсталирай обновление",
"description": "Label for the button to install an update for a single style"
"message": "Инсталирай обновление"
},
"installUpdateFrom": {
"message": "В момента стилът се обновява от $url$",
"description": "Label to describe where the style gets update",
"placeholders": {
"url": {
"content": "$1"
@ -227,28 +174,22 @@
}
},
"installUpdateFromLabel": {
"message": "Провери за обновления",
"description": "Label for the checkbox to save current URL for update check"
"message": "Провери за обновления"
},
"license": {
"message": "Лиценз",
"description": "Label for the license"
"message": "Лиценз"
},
"linkGetHelp": {
"message": "Получете помощ",
"description": "Homepage link text on the manage page e.g. https://add0n.com/stylus.html#features with chat/FAQ/intro/info"
"message": "Получете помощ"
},
"linkGetStyles": {
"message": "Вземете стилове",
"description": "Help link text on the manage page e.g. https://userstyles.org"
"message": "Вземете стилове"
},
"linkTranslate": {
"message": "Преведете",
"description": "Transifex link text on the manage page"
"message": "Преведете"
},
"linterCSSLintIncompatible": {
"message": "CSSLint не поддържа $preprocessorname$ preprocessor",
"description": "The label to display when the preprocessor isn't compatible with CSSLint",
"placeholders": {
"preprocessorname": {
"content": "$1"
@ -256,12 +197,10 @@
}
},
"linterCSSLintSettings": {
"message": "(Укажете правилата: 0 = забранен; 1 = предупреждения; 2 = грешки)",
"description": "CSSLint rule config values"
"message": "(Укажете правилата: 0 = забранен; 1 = предупреждения; 2 = грешки)"
},
"linterConfigPopupTitle": {
"message": "Настройте конфигурация за $linter$ правила",
"description": "Stylelint or CSSLint popup header",
"placeholders": {
"linter": {
"content": "$1"
@ -269,20 +208,16 @@
}
},
"linterConfigTooltip": {
"message": "Щракнете, за да конфигурирате този linter",
"description": "Icon tooltip to indicate that it opens a popup with the selected linter configuration"
"message": "Щракнете, за да конфигурирате този linter"
},
"linterInvalidConfigError": {
"message": "Не е записано заради тези неправилни настройки",
"description": "Invalid linter config will show a message followed by a list of invalid entries"
"message": "Не е записано заради тези неправилни настройки"
},
"linterIssues": {
"message": "Проблеми",
"description": "Label for the CSS linter issues block on the style edit page"
"message": "Проблеми"
},
"linterIssuesHelp": {
"message": "Тези проблеми бяха намерени от $link$:",
"description": "Help popup message for the selected CSS linter issues block on the style edit page",
"placeholders": {
"link": {
"content": "$1"
@ -290,71 +225,54 @@
}
},
"linterJSONError": {
"message": "Невалиден JSON формат",
"description": "Setting linter config with invalid JSON"
"message": "Невалиден JSON формат"
},
"linterResetMessage": {
"message": "За да върнете погрешно нулиране, натиснете Ctrl-Z (или Cmd-Z) в текстовия прозорец",
"description": "Reset button tooltip to inform user on how to undo an accidental reset"
"message": "За да върнете погрешно нулиране, натиснете Ctrl-Z (или Cmd-Z) в текстовия прозорец"
},
"linterRulesLink": {
"message": "Вижте пълния списък с правила",
"description": "Stylelint or CSSLint rules label added immediately before a link"
"message": "Вижте пълния списък с правила"
},
"liveReloadError": {
"message": "Получи се грешка докато наблюдавахме файла",
"description": "The label of live-reload error"
"message": "Получи се грешка докато наблюдавахме файла"
},
"liveReloadLabel": {
"message": "Преглед на живо",
"description": "The label of live-reload feature"
"message": "Преглед на живо"
},
"manageFilters": {
"message": "Филтри",
"description": "Label for filters container"
"message": "Филтри"
},
"manageHeading": {
"message": "Инсталирани стилове",
"description": "Heading for the manage page"
"message": "Инсталирани стилове"
},
"manageNewStyleAsUsercss": {
"message": "като Потребителскиcss",
"description": "VERY SHORT label for the checkbox next to the 'Write new style' button in the style manager"
"message": "като Потребителскиcss"
},
"manageNewUI": {
"message": "Нова подредба на UI",
"description": "Label for the checkbox that toggles the new UI on manage page"
"message": "Нова подредба на UI"
},
"manageOnlyDisabled": {
"message": "Само забранените стилове",
"description": "Checkbox to show only disabled styles"
"message": "Само забранените стилове"
},
"manageOnlyEnabled": {
"message": "Само разрешените стилове",
"description": "Checkbox to show only enabled styles"
"message": "Само разрешените стилове"
},
"manageOnlyExternal": {
"message": "Само външните стилове",
"description": "Checkbox to show only externally installed styles i.e. updatable"
"message": "Само външните стилове"
},
"manageOnlyLocal": {
"message": "Само локалните стилове",
"description": "Checkbox to show only locally created styles i.e. non-updatable"
"message": "Само локалните стилове"
},
"manageOnlyLocalTooltip": {
"message": "(стиловете не инсталирани чрез страницата на userstyles.org)",
"description": "Tooltip for the checkbox to show only locally created styles i.e. non-updatable"
"message": "(стиловете не инсталирани чрез страницата на userstyles.org)"
},
"manageOnlyNonUsercss": {
"message": "Само не-Потребителскитеcss стилове",
"description": "Checkbox to show only non-Usercss (standard) styles"
"message": "Само не-Потребителскитеcss стилове"
},
"manageOnlyUpdates": {
"message": "Само с обновления или проблеми",
"description": "Checkbox to show only styles that have updates after check-all-styles-for-updates was performed"
"message": "Само с обновления или проблеми"
},
"manageOnlyUsercss": {
"message": "Само Потребителскиcss стилове",
"description": "Checkbox to show only Usercss styles"
"message": "Само Потребителскиcss стилове"
}
}
}

View File

@ -1 +0,0 @@
{}

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,18 @@
{
"addStyleLabel": {
"message": "Skriv ny stil",
"description": "Label for the button to go to the add style page"
"message": "Skriv ny stil"
},
"addStyleTitle": {
"message": "Tilføj stil",
"description": "Title of the page for adding styles"
"message": "Tilføj stil"
},
"alphaChannel": {
"message": "Gennemsigtighed",
"description": "Label of color's opacity"
"message": "Gennemsigtighed"
},
"appliesAdd": {
"message": "Tilføj",
"description": "Label for the button to add an 'applies' entry"
"message": "Tilføj"
},
"appliesDisplay": {
"message": "Anvendes på: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -25,107 +20,81 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "og mere",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "og mere"
},
"appliesDomainOption": {
"message": "URL'er på domænet",
"description": "Option to make the style apply to the entered string as a domain"
"message": "URL'er på domænet"
},
"appliesHelp": {
"message": "Brug 'Anvendt på'-styring til at begrænse hvilke URL'er koden i denne sektion anvendes på.",
"description": "Help text for 'applies to' section"
"message": "Brug 'Anvendt på'-styring til at begrænse hvilke URL'er koden i denne sektion anvendes på."
},
"appliesLabel": {
"message": "Anvendes på",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Anvendes på"
},
"appliesLineWidgetLabel": {
"message": "Vis 'Anvendes på'-info",
"description": "Label for the checkbox to display applies-to information in the single editor"
"message": "Vis 'Anvendes på'-info"
},
"appliesRegexpOption": {
"message": "URL'er der matcher regexp'en",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "URL'er der matcher regexp'en"
},
"appliesRemove": {
"message": "Fjern",
"description": "Label for the button to remove an 'applies' entry"
"message": "Fjern"
},
"appliesRemoveError": {
"message": "Kan ikke fjerne sidste 'Anvendes på'-optegnelse",
"description": "Error displayed when the last 'applies' is going to be removed"
"message": "Kan ikke fjerne sidste 'Anvendes på'-optegnelse"
},
"appliesSpecify": {
"message": "Specificér",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Specificér"
},
"appliesToEverything": {
"message": "Alt",
"description": "Text displayed for styles that apply to all sites"
"message": "Alt"
},
"appliesUrlPrefixOption": {
"message": "URL'er der starter med",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "URL'er der starter med"
},
"applyAllUpdates": {
"message": "Anvend alle opdateringer",
"description": "Label for the button to apply all detected updates"
"message": "Anvend alle opdateringer"
},
"author": {
"message": "Forfatter",
"description": "Label for the style author"
"message": "Forfatter"
},
"backupMessage": {
"message": "Vælg en fil eller træk og slip til denne side.",
"description": "Message for backup"
"message": "Vælg en fil eller træk og slip til denne side."
},
"bckpInstStyles": {
"message": "Eksportér stil",
"description": ""
"message": "Eksportér stil"
},
"checkAllUpdates": {
"message": "Tjek alle stiler for opdateringer",
"description": "Label for the button to check all styles for updates"
"message": "Tjek alle stiler for opdateringer"
},
"checkAllUpdatesForce": {
"message": "Tjek igen, jeg redigerede ikke nogen stil!",
"description": "Label for the button to apply all detected updates"
"message": "Tjek igen, jeg redigerede ikke nogen stil!"
},
"checkForUpdate": {
"message": "Tjek efter opdatering",
"description": "Label for the button to check a single style for an update"
"message": "Tjek efter opdatering"
},
"checkingForUpdate": {
"message": "Tjekker...",
"description": "Text to display when checking a style for an update"
"message": "Tjekker..."
},
"clickToUninstall": {
"message": "Klik for at afinstallere",
"description": "Label for the overlay on a style thumbnail when installed via inline search in the popup"
"message": "Klik for at afinstallere"
},
"cm_autoCloseBrackets": {
"message": "Luk automatisk paranteser og citationstegn",
"description": "Label for the checkbox in the style editor."
"message": "Luk automatisk paranteser og citationstegn"
},
"cm_autoCloseBracketsTooltip": {
"message": "Tilføj automatisk et lukket par når man åbner en af ()[]{}''\"\"",
"description": "Label for the checkbox in the style editor."
"message": "Tilføj automatisk et lukket par når man åbner en af ()[]{}''\"\""
},
"cm_autocompleteOnTyping": {
"message": "Autoudfyld på indtastning",
"description": "Label for the checkbox in the style editor."
"message": "Autoudfyld på indtastning"
},
"cm_colorpicker": {
"message": "Farvevælgere for CSS-farver",
"description": "Label for the checkbox controlling colorpicker option for the style editor."
"message": "Farvevælgere for CSS-farver"
},
"cm_indentWithTabs": {
"message": "Brug tabs med smart indrykning",
"description": "Label for the checkbox controlling tabs with smart indentation option for the style editor."
"message": "Brug tabs med smart indrykning"
},
"cm_keyMap": {
"message": "Tastegenveje",
"description": "Label for the drop-down list controlling the keymap for the style editor."
"message": "Tastegenveje"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,21 @@
{
"InaccessibleFileHint": {
"message": "Το Stylus δεν έχει πρόσβαση σε κάποια αρχεία (π.χ. τα αρχεία PDF και JSON)"
},
"addStyleLabel": {
"message": "Γράψτε νέο στυλ",
"description": "Label for the button to go to the add style page"
"message": "Γράψτε νέο στυλ"
},
"addStyleTitle": {
"message": "Προσθήκη στυλ",
"description": "Title of the page for adding styles"
"message": "Προσθήκη στυλ"
},
"alphaChannel": {
"message": "Αδιαφάνεια"
},
"appliesAdd": {
"message": "Προσθήκη",
"description": "Label for the button to add an 'applies' entry"
"message": "Προσθήκη"
},
"appliesDisplay": {
"message": "Ισχύει για: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,112 +23,231 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "και πολλά άλλα",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "και πολλά άλλα"
},
"appliesDomainOption": {
"message": "URL στον τομέα",
"description": "Option to make the style apply to the entered string as a domain"
"message": "URL στον τομέα"
},
"appliesHelp": {
"message": "Χρησιμοποιήστε το \"Ισχύει για\" έλεγχοι ώστε να περιοριστουν ποιες διευθύνσεις τον κώδικα σε αυτό το τμήμα να εφαρμόζονται.",
"description": "Help text for 'applies to' section"
"message": "Χρησιμοποιήστε το \"Ισχύει για\" έλεγχοι ώστε να περιοριστουν ποιες διευθύνσεις τον κώδικα σε αυτό το τμήμα να εφαρμόζονται."
},
"appliesLabel": {
"message": "Ισχύει για",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Ισχύει για"
},
"appliesLineWidgetWarning": {
"message": "Δε λειτουργεί με minified CSS."
},
"appliesRegexpOption": {
"message": "Διευθύνσεις URL που ταιριάζουν με την κανονική έκφραση",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "Διευθύνσεις URL που ταιριάζουν με την κανονική έκφραση"
},
"appliesRemove": {
"message": "Αφαίρεση",
"description": "Label for the button to remove an 'applies' entry"
"message": "Αφαίρεση"
},
"appliesSpecify": {
"message": "Καθορισμός",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Καθορισμός"
},
"appliesToEverything": {
"message": "Τα πάντα",
"description": "Text displayed for styles that apply to all sites"
"message": "Τα πάντα"
},
"appliesUrlOption": {
"message": "διεύθυνση URL"
},
"appliesUrlPrefixOption": {
"message": "Διευθύνσεις URL που αρχίζουν με",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "Διευθύνσεις URL που αρχίζουν με"
},
"applyAllUpdates": {
"message": "Εφαρμογή όλων των ενημερώσεων",
"description": "Label for the button to apply all detected updates"
"message": "Εφαρμογή όλων των ενημερώσεων"
},
"author": {
"message": "Συντάκτης"
},
"backupButtons": {
"message": "Δημιουργήστε αντίγραφο ασφαλείας"
},
"backupMessage": {
"message": "Επιλέξτε ένα αρχείο ή σύρετέ το σε αυτήν τη σελίδα"
},
"bckpInstStyles": {
"message": "Εξαγωγή στυλ"
},
"checkAllUpdates": {
"message": "Έλεγχος όλων των στυλ για ενημερώσεις",
"description": "Label for the button to check all styles for updates"
"message": "Έλεγχος όλων των στυλ για ενημερώσεις"
},
"checkAllUpdatesForce": {
"message": "Ελέγξτε πάλι, δεν επεξεργάστηκα κανένα στυλ!"
},
"checkForUpdate": {
"message": "Έλεγχος για ενημερώσεις",
"description": "Label for the button to check a single style for an update"
"message": "Έλεγχος για ενημερώσεις"
},
"checkingForUpdate": {
"message": "Έλεγχος...",
"description": "Text to display when checking a style for an update"
"message": "Έλεγχος..."
},
"clickToUninstall": {
"message": "Πατήστε για απεγκατάσταση"
},
"cm_autoCloseBrackets": {
"message": "Αυτόματο κλείσιμο παρενθέσεων και εισαγωγικών"
},
"cm_autocompleteOnTyping": {
"message": "Αυτόματη συμπλήρωση καθώς πληκτρολογείτε"
},
"cm_indentWithTabs": {
"message": "Χρήση καρτελών με έξυπνη εσοχή",
"description": "Label for the checkbox controlling tabs with smart indentation option for the style editor."
"message": "Χρήση καρτελών με έξυπνη εσοχή"
},
"cm_keyMap": {
"message": "Συντομεύσεις πληκτρολογίου"
},
"cm_lineWrapping": {
"message": "Αναδίπλωση λέξεων",
"description": "Label for the checkbox controlling word wrap option for the style editor."
"message": "Αναδίπλωση λέξεων"
},
"cm_matchHighlight": {
"message": "Υπογράμμιση"
},
"cm_matchHighlightSelection": {
"message": "Μόνο επιλογή"
},
"cm_resizeGripHint": {
"message": "Διπλό κλικ για μεγιστοποίηση/επαναφορά ύψους"
},
"cm_smartIndent": {
"message": "Χρήση έξυπνης εσοχής",
"description": "Label for the checkbox controlling smart indentation option for the style editor."
"message": "Χρήση έξυπνης εσοχής"
},
"cm_tabSize": {
"message": "Μέγεθος καρτέλας",
"description": "Label for the text box controlling tab size option for the style editor."
"message": "Μέγεθος καρτέλας"
},
"cm_theme": {
"message": "Θέμα"
},
"configOnChange": {
"message": "στην αλλαγή"
},
"configOnChangeTooltip": {
"message": "Αυτόματη αποθήκευση και εφαρμογή αλλαγών"
},
"configureStyle": {
"message": "Ρυθμίσεις"
},
"configureStyleOnHomepage": {
"message": "Ρυθμίσεις στην ιστοσελίδα"
},
"confirmCancel": {
"message": "Άκυρο"
},
"confirmClose": {
"message": "Κλείσιμο"
},
"confirmDefault": {
"message": "Χρήση προεπιλογής"
},
"confirmDelete": {
"message": "Διαγραφή"
},
"confirmDiscardChanges": {
"message": "Απόρριψη αλλαγών;"
},
"confirmNo": {
"message": "Όχι"
},
"confirmOK": {
"message": "ΟΚ"
},
"confirmSave": {
"message": "Αποθήκευση"
},
"confirmYes": {
"message": "Ναι"
},
"connectingDropbox": {
"message": "Σύνδεση με το Dropbox..."
},
"connectingDropboxNotAllowed": {
"message": "Η σύνδεση με το Dropbox είναι διαθέσιμη μόνο σε εφαρμογές εγκατεστημένες απευθείας από το κατάστημα ιστού webstore"
},
"copied": {
"message": "Αντιγράφηκε στο πρόχειρο"
},
"copy": {
"message": "Αντιγραφή στο πρόχειρο"
},
"dateAbbrDay": {
"message": "$value$μ",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateAbbrHour": {
"message": "$value$ω",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateAbbrMonth": {
"message": "$value$λ",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateAbbrYear": {
"message": "$value$χ",
"placeholders": {
"value": {
"content": "$1"
}
}
},
"dateInstalled": {
"message": "Ημερομηνία εγκατάστασης"
},
"dateUpdated": {
"message": "Ημερομηνία ενημέρωσης"
},
"dbError": {
"message": "Παρουσιάστηκε σφάλμα χρησιμοποιώντας την κομψή βάση δεδομένων. Θα θέλατε να επισκεφθείτε μια ιστοσελίδα με πιθανές λύσεις;",
"description": "Prompt when a DB error is encountered"
"message": "Παρουσιάστηκε σφάλμα χρησιμοποιώντας την κομψή βάση δεδομένων. Θα θέλατε να επισκεφθείτε μια ιστοσελίδα με πιθανές λύσεις;"
},
"defaultTheme": {
"message": "προεπιλογή"
},
"deleteStyleConfirm": {
"message": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το στυλ;",
"description": "Confirmation before deleting a style"
"message": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το στυλ;"
},
"deleteStyleLabel": {
"message": "Διαγραφή",
"description": "Label for the button to delete a style"
"message": "Διαγραφή"
},
"description": {
"message": "Επαναπροσδιορίση του διαδίκτυου με το Stylus, έναν διαχειριστή στυλ. Το Stylus σας επιτρέπει να εγκαταστήσετε εύκολα themes και skins για πολλές δημοφιλείς ιστοσελίδες.",
"description": "Extension description"
"message": "Επαναπροσδιορίση του διαδίκτυου με το Stylus, έναν διαχειριστή στυλ. Το Stylus σας επιτρέπει να εγκαταστήσετε εύκολα themes και skins για πολλές δημοφιλείς ιστοσελίδες."
},
"disableAllStyles": {
"message": "Απενεργοποιηση ολων των στυλ",
"description": "Label for the checkbox that turns all enabled styles off."
"message": "Απενεργοποιηση ολων των στυλ"
},
"disableStyleLabel": {
"message": "Απενεργοποίηση",
"description": "Label for the button to disable a style"
"message": "Απενεργοποίηση"
},
"dragDropMessage": {
"message": "Αποθέστε το αντίγραφο ασφαλείας σας οπουδήποτε σε αυτήν τη σελίδα για εισαγωγή."
},
"dragDropUsercssTabstrip": {
"message": "Για να εγκαταστήσετε το αρχείο, αποθέστε το στη λωρίδα καρτελών (την περιοχή όπου εμφανίζονται οι τίτλοι καρτελών)."
},
"editDeleteText": {
"message": "Διαγραφή"
},
"editGotoLine": {
"message": "Μετάβαση στη γραμμή (ή line:col)",
"description": "Go to line or line:column on Ctrl-G in style code editor"
"message": "Μετάβαση στη γραμμή (ή line:col)"
},
"editStyleHeading": {
"message": "Επεξεργασία Στυλ",
"description": "Title of the page for editing styles"
"message": "Επεξεργασία Στυλ"
},
"editStyleLabel": {
"message": "Επεξεργασία",
"description": "Label for the button to go to the edit style page"
"message": "Επεξεργασία"
},
"editStyleTitle": {
"message": "Επεξεργασία του στυλ $stylename$",
"description": "Title of the page for editing styles",
"placeholders": {
"stylename": {
"content": "$1"
@ -134,92 +255,393 @@
}
},
"enableStyleLabel": {
"message": "Ενεργοποίηση",
"description": "Label for the button to enable a style"
"message": "Ενεργοποίηση"
},
"excludeStyleByDomainLabel": {
"message": "Εξαίρεση του τρέχοντος τομέα"
},
"excludeStyleByUrlLabel": {
"message": "Εξαίρεση του τρέχοντος URL"
},
"exportLabel": {
"message": "Εξαγωγή"
},
"exportSavedSuccess": {
"message": "Το αρχείο αποθηκεύτηκε επιτυχώς."
},
"externalFeedback": {
"message": "Σχόλια"
},
"externalHomepage": {
"message": "Αρχική σελίδα"
},
"externalLink": {
"message": "Εξωτερική σύνδεση"
},
"externalSupport": {
"message": "Υποστήριξη"
},
"externalUsercssDocument": {
"message": "Τεκμηρίωση για Usercss"
},
"filteredStyles": {
"message": "Βλέπετε $numShown$ από 2$numTotal$ συνολικά",
"placeholders": {
"numShown": {
"content": "$1"
},
"numTotal": {
"content": "$2"
}
}
},
"filteredStylesAllHidden": {
"message": "Τα φίλτρα που εφαρμόζονται αυτήν τη στιγμή δεν ταιριάζουν με κανένα στυλ"
},
"findStyles": {
"message": "Εύρεση στυλ"
},
"findStylesForSite": {
"message": "Αναζήτηση περισσότερων στυλ για αυτή την ιστοσελίδα",
"description": "Text for a link that gets a list of styles for the current site"
"message": "Αναζήτηση περισσότερων στυλ για αυτή την ιστοσελίδα"
},
"genericAdd": {
"message": "Προσθήκη"
},
"genericClone": {
"message": "Δημιουργία αντιγράφου"
},
"genericDisabledLabel": {
"message": "Απενεργοποιημένο"
},
"genericEnabledLabel": {
"message": "Ενεργοποιημένο"
},
"genericError": {
"message": "Σφάλμα"
},
"genericHistoryLabel": {
"message": "Ιστορικό"
},
"genericNext": {
"message": "Επόμενο"
},
"genericPrevious": {
"message": "Προηγούμενο"
},
"genericResetLabel": {
"message": "Επαναφορά"
},
"genericSavedMessage": {
"message": "Αποθηκεύτηκε"
},
"genericTitle": {
"message": "Τίτλος"
},
"genericUnknown": {
"message": "Άγνωστο"
},
"gettingStyles": {
"message": "Λήψη όλων των στυλ..."
},
"helpAlt": {
"message": "Βοήθεια",
"description": "Alternate text for help buttons"
"message": "Βοήθεια"
},
"helpKeyMapCommand": {
"message": "Πληκτρολογήστε μια εντολή"
},
"helpKeyMapHotkey": {
"message": "Πληκτρολογήστε ένα hotkey"
},
"importLabel": {
"message": "Εισαγωγή"
},
"importReplaceLabel": {
"message": "Αντικατάσταση στυλ"
},
"importReportLegendAdded": {
"message": "προστέθηκαν"
},
"importReportLegendUpdatedCode": {
"message": "ενημερωμένος κώδικας"
},
"importReportTitle": {
"message": "Η εισαγωγή στυλ τελείωσε"
},
"importReportUnchanged": {
"message": "Τίποτα δεν άλλαξε"
},
"importReportUndoneTitle": {
"message": "Η εισαγωγή έχει αναιρεθεί"
},
"installButton": {
"message": "Εγκατάσταση στυλ"
},
"installButtonInstalled": {
"message": "Το στυλ έχει εγκατασταθεί."
},
"installButtonReinstall": {
"message": "Επανεγκατάσταση στυλ"
},
"installButtonUpdate": {
"message": "Ενημέρωση στυλ"
},
"installUpdate": {
"message": "Εγκατάσταση ενημέρωσης",
"description": "Label for the button to install an update for a single style"
"message": "Εγκατάσταση ενημέρωσης"
},
"installUpdateFromLabel": {
"message": "Έλεγχος για ενημερώσεις"
},
"license": {
"message": "Άδεια χρήσης"
},
"linkGetHelp": {
"message": "Βοήθεια"
},
"linkGetStyles": {
"message": "Λήψη στυλ"
},
"linkTranslate": {
"message": "Μετάφραση"
},
"linterConfigTooltip": {
"message": "Πατήστε εδώ για να ρυθμίσετε το linter"
},
"linterIssues": {
"message": "Ζητήματα"
},
"linterJSONError": {
"message": "Μη έγκυρη μορφή JSON"
},
"linterResetMessage": {
"message": "Για αναίρεση μιας κατά λάθος επαναφοράς, πατήστε Ctrl-Z (ή Cmd-Z) στο πλαίσιο κειμένου"
},
"manageFaviconsHelp": {
"message": "Το Stylus χρησιμοποιεί μία εξωτερική υπηρεσία https://www.google.com/s2/favicons"
},
"manageFilters": {
"message": "Φίλτρα",
"description": "Label for filters container"
"message": "Φίλτρα"
},
"manageHeading": {
"message": "Εγκατεστημένα Στυλ",
"description": "Heading for the manage page"
"message": "Εγκατεστημένα Στυλ"
},
"manageNewUI": {
"message": "Νέα διαχείριση διάταξης UI"
},
"manageOnlyDisabled": {
"message": "Μόνο απενεργοποιημένα στυλ"
},
"manageOnlyEnabled": {
"message": "Μόνο ενεργοποιημένα στυλ",
"description": "Checkbox to show only enabled styles"
"message": "Μόνο ενεργοποιημένα στυλ"
},
"manageOnlyExternal": {
"message": "Μόνο στυλ από άλλες ιστοσελίδες"
},
"manageOnlyLocal": {
"message": "Μόνο στυλ δημιουργημένα τοπικά"
},
"manageTitle": {
"message": "Κομψή",
"description": "Title for the manage page"
"message": "Κομψή"
},
"menuShowBadge": {
"message": "Εμφάνιση ενεργους καταμέτρησης στυλ",
"description": "Label (must be very short) for the checkbox in the toolbar button context menu controlling toolbar badge text."
"message": "Εμφάνιση ενεργους καταμέτρησης στυλ"
},
"noFileToImport": {
"message": "Για να εισάγετε τα στυλ σας, πρέπει πρώτα να τα εξάγετε."
},
"noStylesForSite": {
"message": "Δεν υπάρχουν εγκατεστημένα στυλ για αυτή την ιστοσελίδα.",
"description": "Text displayed when no styles are installed for the current site"
"message": "Δεν υπάρχουν εγκατεστημένα στυλ για αυτή την ιστοσελίδα."
},
"openManage": {
"message": "Διαχείριση εγκατεστημένων στυλ",
"description": "Link to open the manage page."
"message": "Διαχείριση εγκατεστημένων στυλ"
},
"openOptions": {
"message": "Επιλογές"
},
"openStylesManager": {
"message": "Άνοιγμα διαχείρισης στυλ"
},
"optionsActions": {
"message": "Ενέργειες"
},
"optionsAdvanced": {
"message": "Για προχωρημένους"
},
"optionsAdvancedContextDelete": {
"message": "Προσθήκη του 'Delete' στο μενού περιβάλλοντος του προγράμματος επεξεργασίας"
},
"optionsBadgeDisabled": {
"message": "Χρώμα φόντου όταν είναι απενεργοποιημένο"
},
"optionsBadgeNormal": {
"message": "Χρώμα υποβάθρου"
},
"optionsCheck": {
"message": "Ενημέρωση στυλ"
},
"optionsCheckUpdate": {
"message": "Έλεγχος και εγκατάσταση διαθέσιμων ενημερώσεων"
},
"optionsCustomizeBadge": {
"message": "Σήμα στο εικονίδιο της γραμμής εργαλείων"
},
"optionsCustomizePopup": {
"message": "Αναδυόμενο παράθυρο"
},
"optionsCustomizeUpdate": {
"message": "Ενημερώσεις"
},
"optionsHeading": {
"message": "Επιλογές",
"description": "Heading for options section on manage page."
"message": "Επιλογές"
},
"optionsIconDark": {
"message": "Σκούρο θέμα φυλλομετρητή"
},
"optionsOpen": {
"message": "Άνοιγμα"
},
"optionsOpenManager": {
"message": "Διαχείριση στυλ"
},
"optionsPopupWidth": {
"message": "Πλάτος αναδυόμενου παραθύρου (σε pixels)"
},
"optionsReset": {
"message": "Επαναφορά ρυθμίσεων στις προεπιλεγμένες"
},
"optionsResetButton": {
"message": "Επαναφορά επιλογών"
},
"optionsSubheading": {
"message": "Περισσότερες επιλογές"
},
"optionsSyncConnect": {
"message": "Σύνδεση"
},
"optionsSyncDisconnect": {
"message": "Αποσύνδεση"
},
"optionsSyncStatusConnected": {
"message": "Συνδεδεμένο"
},
"optionsSyncStatusConnecting": {
"message": "Σύνδεση..."
},
"optionsSyncStatusDisconnected": {
"message": "Αποσυνδέθηκε"
},
"optionsSyncStatusDisconnecting": {
"message": "Αποσύνδεση..."
},
"optionsSyncStatusSyncing": {
"message": "Συγχρονισμός ..."
},
"optionsSyncSyncNow": {
"message": "Συγχρονισμός τώρα"
},
"optionsUpdateInterval": {
"message": "Διάστημα αυτόματης ενημέρωσης των στυλ σε ώρες (0 για απενεργοποίηση)"
},
"paginationNext": {
"message": "Επόμενη σελίδα"
},
"paginationPrevious": {
"message": "Προηγούμενη σελίδα"
},
"popupBordersTooltip": {
"message": "Χρήσιμο για σκούρα θέματα στο καινούριο Chrome, καθώς δε βάφει πλέον τα ακριανά περιθώρια."
},
"popupOpenEditInPopup": {
"message": "Χρήση ενός απλού παραθύρου (χωρίς omnibox)"
},
"popupOpenEditInWindow": {
"message": "Άνοιγμα επεξαργαστή σε νέο παράθυρο"
},
"popupStylesFirst": {
"message": "Στυλ λίστας πριν των εντολών στο μενού του κουμπιού γραμμής εργαλείων",
"description": "Label for the checkbox controlling section order in the popup."
"message": "Στυλ λίστας πριν των εντολών στο μενού του κουμπιού γραμμής εργαλείων"
},
"prefShowBadge": {
"message": "Εμφάνιση αριθμού των στυλ που δραστηριοποιούνται για την τρέχουσα τοποθεσία στην μπάρα εργαλείων",
"description": "Label for the checkbox controlling toolbar badge text."
"message": "Εμφάνιση αριθμού των στυλ που δραστηριοποιούνται για την τρέχουσα τοποθεσία στην μπάρα εργαλείων"
},
"readingStyles": {
"message": "Ανάγνωση στυλ..."
},
"replace": {
"message": "Αντικατάσταση"
},
"replaceAll": {
"message": "Αντικατάσταση όλων"
},
"replaceWith": {
"message": "Αντικατάσταση με"
},
"retrieveBckp": {
"message": "Εισαγωγή στυλ"
},
"retrieveDropboxSync": {
"message": "Εισαγωγή από το Dropbox"
},
"search": {
"message": "Αναζήτηση"
},
"searchGlobalStyles": {
"message": "Επίσης, αναζητήστε καθολικά στυλ"
},
"searchRegexp": {
"message": "Χρησιμοποιήστε τη σύνταξη /re/ για αναζήτηση με regexp."
},
"searchResultInstallCount": {
"message": "Συνολικός αριθμός εγκαταστάσεων"
},
"searchResultUpdated": {
"message": "Ενημερωμένο"
},
"searchResultWeeklyCount": {
"message": "Εβδομαδιαίος αριθμός εγκαταστάσεων"
},
"searchStylesName": {
"message": "Όνομα"
},
"sectionAdd": {
"message": "Προσθήκη ένος άλλου τμήματος",
"description": "Label for the button to add a section"
"message": "Προσθήκη ένος άλλου τμήματος"
},
"sectionCode": {
"message": "Κώδικας",
"description": "Label for the code for a section"
"message": "Κώδικας"
},
"sectionRemove": {
"message": "Αφαίρεση ενότητας",
"description": "Label for the button to remove a section"
"message": "Αφαίρεση ενότητας"
},
"shortcuts": {
"message": "Συντομεύσεις"
},
"sortDateNewestFirst": {
"message": "πιο πρόσφατα πρώτα"
},
"sortDateOldestFirst": {
"message": "πιο παλιά πρώτα"
},
"styleBadRegexp": {
"message": "Το Regexp δεν είναι έγκυρο.",
"description": "Validation message for a bad regexp in a style"
"message": "Το Regexp δεν είναι έγκυρο."
},
"styleBeautify": {
"message": "Ωραιοποίηση"
},
"styleBeautifyIndentConditional": {
"message": "Διόρθωση εσοχής για @media και @supports"
},
"styleBeautifyPreserveNewlines": {
"message": "Διατήρηση νέων γραμμών (newlines)"
},
"styleCancelEditLabel": {
"message": "Πίσω στη διαχείριση",
"description": "Label for cancel button for style editing"
"message": "Πίσω στη διαχείριση"
},
"styleChangesNotSaved": {
"message": "Έχετε κάνει αλλαγές σε αυτό το ύφος χωρίς αποθήκευση.",
"description": "Text for the prompt when changes are made to a style and the user tries to leave without saving"
"message": "Έχετε κάνει αλλαγές σε αυτό το ύφος χωρίς αποθήκευση."
},
"styleEnabledLabel": {
"message": "Ενεργοποιημένη",
"description": "Label for the enabled state of styles"
"message": "Ενεργοποιημένη"
},
"styleInstall": {
"message": "Εγκατάσταση του '$stylename$' στο Stylus;",
"description": "Confirmation when installing a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -227,20 +649,19 @@
}
},
"styleMissingName": {
"message": "Εισάγετε ένα όνομα",
"description": "Error displayed when user saves without providing a name"
"message": "Εισάγετε ένα όνομα"
},
"styleRegexpTestNone": {
"message": "Δε βρέθηκαν καρτέλες που αντιστοιχούν."
},
"styleSaveLabel": {
"message": "Αποθήκευση",
"description": "Label for save button for style editing"
"message": "Αποθήκευση"
},
"styleToMozillaFormatHelp": {
"message": "Η μορφή του Mozilla κώδικα μπορεί να χρησιμοποιηθεί με το Stylish για το Firefox και μπορεί να υποβληθεί στο userstyles.org.",
"description": "Help info for the Mozilla format header section that converts the code to/from Mozilla format"
"message": "Η μορφή του Mozilla κώδικα μπορεί να χρησιμοποιηθεί με το Stylish για το Firefox και μπορεί να υποβληθεί στο userstyles.org."
},
"styleUpdate": {
"message": "Είστε σίγουροι ότι θέλετε να ενημερώσετε το '$stylename$';",
"description": "Confirmation when updating a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -248,16 +669,43 @@
}
},
"stylusUnavailableForURL": {
"message": "To Stylus δεν λειτουργεί σε σελίδες όπως αυτή.",
"description": "Note in the toolbar pop-up when on a URL Stylus can't affect"
"message": "To Stylus δεν λειτουργεί σε σελίδες όπως αυτή."
},
"stylusUnavailableForURLdetails": {
"message": "Ως μέτρο ασφαλείας, ο φυλλομετρητής απαγορεύει στα πρόσθετα να επέμβουν στις built-in σελίδες του (όπως π.χ. chrome://version, η σελίδα νέας καρτέλας από το Chrome 61 και μετά, about:addons, κλπ.), καθώς και τις σελίδες άλλωων προσθέτων. Επιπλέον, κάθε φυλλομετρητής περιορίζει την πρόσβαση στο κατάστημα προσθέτων (όπως το Chrome Web Store ή το AMO)."
},
"syncDropboxStyles": {
"message": "Εξαγωγή από το Dropbox"
},
"syncError": {
"message": "Ο συγχρονισμός απέτυχε"
},
"syncErrorRelogin": {
"message": "Ο συγχρονισμός απέτυχε.\nΠροσπαθήστε να συνδεθείτε ξανά στις επιλογές Stylus:\nκάντε κλικ στο 'αποσύνδεση' πρώτα και μετά στο 'σύνδεση'."
},
"toggleStyle": {
"message": "Αλλαγή στυλ"
},
"undo": {
"message": "Αναίρεση"
},
"undoGlobal": {
"message": "Αναίρεση όλων των ενεργειών"
},
"unreachableFileHint": {
"message": "Το Stylus έχει πρόσβαση στις file:// διευθύνσεις URL μόνο αν έχετε επιλέξει το αντίστοιχο πλαίσιο ελέγχου για το πρόσθετο Stylus στη σελίδα chrome://extensions."
},
"unzipStyles": {
"message": "Αποσυμπίεση στυλ..."
},
"updateAllCheckSucceededNoUpdate": {
"message": "Όλα τα στυλ είναι ενημερωμένα.",
"description": "Text that displays when an update all check completed and no updates are available"
"message": "Όλα τα στυλ είναι ενημερωμένα."
},
"updateAllCheckSucceededSomeEdited": {
"message": "Δεν έχει γίνει έλεγχος ενημερώσεων για κάποια στυλ, για να αποφευχθεί η πιθανότητα απώλειας τοπικών επεξεργασιών. Οι ενημερώσεις μπορούν να εξαναγκαστούν ελέγχοντας το κάθε στυλ ξεχωριστά ή ελέγχοντας πάλι όλα τα στυλ (τοπικές επεξεργασίες θα αντικατασταθούν)"
},
"updateCheckFailBadResponseCode": {
"message": "Αποτυχία ενημέρωσης: ο διακομιστής ανταποκρίθηκε με κωδικό $code$.",
"description": "Text that displays when an update check failed because the response code indicates an error",
"placeholders": {
"code": {
"content": "$1"
@ -265,23 +713,39 @@
}
},
"updateCheckFailServerUnreachable": {
"message": "Αποτυχία ενημέρωσης: απρόσιτος διακομιστής.",
"description": "Text that displays when an update check failed because the update server is unreachable"
"message": "Αποτυχία ενημέρωσης: απρόσιτος διακομιστής."
},
"updateCheckSkippedLocallyEdited": {
"message": "Το στυλ επεξεργάστηκε τοπικά στον υπολογιστή σας."
},
"updateCheckSkippedMaybeLocallyEdited": {
"message": "Το στυλ αυτό μπορεί να έχει επεξεργαστεί τοπικά στον υπολογιστή σας."
},
"updateCheckSucceededNoUpdate": {
"message": "Το στυλ είναι ενημερωμένο.",
"description": "Text that displays when an update check completed and no update is available"
"message": "Το στυλ είναι ενημερωμένο."
},
"updateCompleted": {
"message": "Η ενημέρωση ολοκληρώθηκε.",
"description": "Text that displays when an update completed"
"message": "Η ενημέρωση ολοκληρώθηκε."
},
"updatesCurrentlyInstalled": {
"message": "Ενημερώσεις που εγκαταστάθηκαν"
},
"uploadingFile": {
"message": "Μεταφόρτωση αρχείου..."
},
"usercssEditorNamePlaceholder": {
"message": "Καθορίστε το @name στον κώδικα"
},
"versionInvalidOlder": {
"message": "Η έκδοση αυτή είναι παλαιότερη από αυτήν που είναι ήδη εγκατεστημένη."
},
"writeStyleFor": {
"message": "Γράψτε νέο στυλ για:",
"description": "Label for toolbar pop-up that precedes the links to write a new style"
"message": "Γράψτε νέο στυλ για:"
},
"writeStyleForURL": {
"message": "αυτή την διεύθυνση URL",
"description": "Text for link in toolbar pop-up to write a new style for the current URL"
"message": "αυτή την διεύθυνση URL"
},
"zipStyles": {
"message": "Συμπίεση στυλ..."
}
}
}

View File

@ -1,4 +1,8 @@
{
"InaccessibleFileHint": {
"message": "Stylus can not access some file types (e.g. pdf & json files).",
"description": "Note in the toolbar popup for some file types that cannot be accessed"
},
"addStyleLabel": {
"message": "Write new style",
"description": "Label for the button to go to the add style page"
@ -182,6 +186,9 @@
"message": "Theme",
"description": "Label for the style editor's CSS theme."
},
"colorpickerPaletteHint": {
"message": "Right-click a swatch to cycle through its source lines"
},
"colorpickerSwitchFormatTooltip": {
"message": "Switch formats: HEX -> RGB -> HSL.\nShift-click to reverse the direction.\nAlso via PgUp (PageUp), PgDn (PageDown) keys.",
"description": "Tooltip for the switch button in the color picker popup in the style editor."
@ -242,6 +249,12 @@
"message": "Yes",
"description": "'Yes' button in a confirm dialog"
},
"connectingDropbox": {
"message": "Connecting Dropbox..."
},
"connectingDropboxNotAllowed": {
"message": "Connecting to Dropbox is only available in apps installed directly from the webstore"
},
"copied": {
"message": "Copied to clipboard",
"description": "Message shown when content has been copied to the clipboard"
@ -257,6 +270,42 @@
"message": "Stop using customized name, switch to the style's own name",
"description": "Tooltip of 'x' button shown in editor when changing the name input of a) styles updated from a URL i.e. not locally created, b) UserCSS styles"
},
"dateAbbrDay": {
"message": "$value$d",
"placeholders": {
"value": {
"content": "$1"
}
},
"description": "Day suffix in a short relative date, for example: 8d"
},
"dateAbbrHour": {
"message": "$value$h",
"placeholders": {
"value": {
"content": "$1"
}
},
"description": "Hour suffix in a short relative date, for example: 8h"
},
"dateAbbrMonth": {
"message": "$value$m",
"placeholders": {
"value": {
"content": "$1"
}
},
"description": "Month suffix in a short relative date, for example: 8m"
},
"dateAbbrYear": {
"message": "$value$y",
"placeholders": {
"value": {
"content": "$1"
}
},
"description": "Year suffix in a short relative date, for example: 8y"
},
"dateInstalled": {
"message": "Date installed",
"description": "Option text for the user to sort the style by install date"
@ -340,6 +389,9 @@
"message": "Export",
"description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)"
},
"exportSavedSuccess": {
"message": "File saved with success"
},
"externalFeedback": {
"message": "Feedback",
"description": "Label for the external link to send feedback for the style"
@ -400,6 +452,9 @@
"message": "Clone",
"description": "Used in various places for an action that clones something"
},
"genericDescription": {
"message": "Description"
},
"genericDisabledLabel": {
"message": "Disabled",
"description": "Used in various lists/options to indicate that something is disabled"
@ -440,6 +495,9 @@
"message": "Unknown",
"description": "Used in various parts of the UI to indicate if something is unknown (e.g. an unknown date)"
},
"gettingStyles": {
"message": "Getting all styles..."
},
"helpAlt": {
"message": "Help",
"description": "Alternate text for help buttons"
@ -468,6 +526,12 @@
"message": "Import",
"description": "Label for the button to import a style ('edit' page) or all styles ('manage' page)"
},
"importPreprocessor": {
"message": "Style with a <code>@preprocessor</code> won't work in the classic mode. You can switch the editor to Usercss mode: 1) open the style manager, 2) enable \"as Usercss\" checkbox, 3) click \"Write new style\"\n\nImport now anyway?"
},
"importPreprocessorTitle": {
"message": "Potential problem due to @preprocessor"
},
"importReplaceLabel": {
"message": "Overwrite style",
"description": "Label for the button to import and overwrite current style"
@ -521,7 +585,7 @@
"description": "Label for install button"
},
"installButtonInstalled": {
"message": "Style installed",
"message": "Style is installed",
"description": "Text displayed when the style is successfully installed"
},
"installButtonReinstall": {
@ -573,6 +637,18 @@
"message": "Get styles",
"description": "Help link text on the manage page e.g. https://userstyles.org"
},
"linkGetStylesInfo": {
"message": "This archive site was created by a userstyle community member to back up the slow and unresponsive userstyles.org. The archive updates its contents approximately once a day.",
"description": "Info shown when clicking the (i) icon of the uso-archive link in the manager"
},
"linkGetShareStyles": {
"message": "Get and share styles",
"description": "Link text for https://userstyles.world/ on the manage page"
},
"linkGetShareStylesInfo": {
"message": "The new community-driven userstyles.world site is created by userstyle authors in order to replace userstyles.org, which has been so slow and unresponsive for the past year that many authors stopped updating their styles.",
"description": "Info shown when clicking the (i) icon of the userstyles.world link in the manager"
},
"linkStylusWiki": {
"message": "Wiki",
"description": "Wiki link text on the manage page e.g. https://github.com/openstyles/stylus/wiki"
@ -730,105 +806,105 @@
},
"meta_invalidColor": {
"message": "Invalid @var color: $color$ is not a color",
"description": "Error displayed when the value of @var color is invalid",
"placeholders": {
"color": {
"content": "$1"
}
}
},
"description": "Error displayed when the value of @var color is invalid"
},
"meta_invalidNumber": {
"message": "Expect a number",
"description": "Error displayed when the value is expected to be a number"
},
"meta_invalidRange": {
"message": "Invalid @var $type$: value must be a number or an array",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"meta_invalidRangeMultipleUnits": {
"message": "Invalid @var $type$: multiple units are defined",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"meta_invalidRangeTooManyValues": {
"message": "Invalid @var $type$: the array contains too many items",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"meta_invalidRangeValue": {
"message": "Invalid @var $type$: items in the array must be number, string, or null",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeDefault": {
"message": "Invalid @var $type$: default value is null",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"meta_invalidRangeMin": {
"message": "Invalid @var $type$: default value is lower than the minimum",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeMax": {
"message": "Invalid @var $type$: default value is larger than the maximum",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeMin": {
"message": "Invalid @var $type$: default value is lower than the minimum",
"placeholders": {
"type": {
"content": "$1"
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeMultipleUnits": {
"message": "Invalid @var $type$: multiple units are defined",
"placeholders": {
"type": {
"content": "$1"
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeStep": {
"message": "Invalid @var $type$: default value is not a mutiple of the step",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeTooManyValues": {
"message": "Invalid @var $type$: the array contains too many items",
"placeholders": {
"type": {
"content": "$1"
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeUnits": {
"message": "Invalid @var $type$: '$units$' is not a valid unit",
"description": "Error displayed when the value of @var range or @var number is invalid",
"placeholders": {
"units": {
"content": "$2"
},
"type": {
"content": "$1"
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidRangeValue": {
"message": "Invalid @var $type$: items in the array must be number, string, or null",
"placeholders": {
"type": {
"content": "$1"
},
"units": {
"content": "$2"
}
}
},
"description": "Error displayed when the value of @var range or @var number is invalid"
},
"meta_invalidSelect": {
"message": "Invalid @var select: the default value must be an array or an object",
"description": "Error displayed when the value of @var select is invalid"
},
"meta_invalidSelectValue": {
"message": "Invalid @var select: values inside the array/object must be a string",
"description": "Error displayed when the value of @var select is invalid"
},
"meta_invalidSelectEmptyOptions": {
"message": "Invalid @var select: options list is empty",
"description": "Error displayed when the value of @var select is invalid"
@ -845,35 +921,30 @@
"message": "Invalid @var select: option name is duplicated",
"description": "Error displayed when the value of @var select is invalid"
},
"meta_invalidSelectValue": {
"message": "Invalid @var select: values inside the array/object must be a string",
"description": "Error displayed when the value of @var select is invalid"
},
"meta_invalidSelectValueMismatch": {
"message": "Invalid @var select: value doesn't exist in the option list",
"description": "Error displayed when the value of @var select is invalid"
},
"meta_invalidString": {
"message": "Expect a quoted string",
"description": "Error displayed when the value is expected to be a quoted string"
},
"meta_invalidURLProtocol": {
"message": "Invalid URL protocol. Only http and https are allowed: $protocol$",
"description": "Error displayed when the protocol of the URL is invalid",
"placeholders": {
"protocol": {
"content": "$1"
}
}
},
"description": "Error displayed when the protocol of the URL is invalid"
},
"meta_invalidVersion": {
"message": "Invalid version number. The value doesn't match SemVer pattern: $version$",
"description": "Error displayed when @version is invalid",
"placeholders": {
"version": {
"content": "$1"
}
}
},
"meta_invalidNumber": {
"message": "Expect a number",
"description": "Error displayed when the value is expected to be a number"
},
"meta_invalidString": {
"message": "Expect a quoted string",
"description": "Error displayed when the value is expected to be a quoted string"
"message": "Invalid version number",
"description": "Error displayed when @version is invalid"
},
"meta_invalidWord": {
"message": "Expect a word",
@ -881,12 +952,12 @@
},
"meta_missingChar": {
"message": "Expect characters: $chars$",
"description": "Error displayed when the value is expected to be some characters",
"placeholders": {
"chars": {
"content": "$1"
}
}
},
"description": "Error displayed when the value is expected to be some characters"
},
"meta_missingEOT": {
"message": "Expect EOT data",
@ -894,56 +965,75 @@
},
"meta_missingMandatory": {
"message": "Missing mandatory metadata: $keys$",
"description": "Error displayed when mandatory keys are missing",
"placeholders": {
"keys": {
"content": "$1"
}
}
},
"description": "Error displayed when mandatory keys are missing"
},
"meta_unknownJSONLiteral": {
"message": "Invalid JSON: $literal$ is not a valid JSON literal",
"description": "Error displayed when JSON value is invalid",
"placeholders": {
"literal": {
"content": "$1"
}
}
},
"description": "Error displayed when JSON value is invalid"
},
"meta_unknownMeta": {
"message": "Unknown metadata: $key$",
"description": "Error displayed when unknown metadata is parsed",
"placeholders": {
"key": {
"content": "$1"
}
}
},
"description": "Error displayed when unknown metadata is parsed"
},
"meta_unknownVarType": {
"message": "Unknown @$varkey$ type: $vartype$",
"description": "Error displayed when unknown variable type is parsed",
"meta_unknownMetaTypo": {
"message": "Maybe @$keyOk$? Unknown metadata: @$keyErr$",
"placeholders": {
"varkey": {
"keyErr": {
"content": "$1"
},
"vartype": {
"keyOk": {
"content": "$2"
}
}
},
"description": "Try translating it so that at least the first placeholder is visible in our narrow panel. This is the error displayed when an unknown metadata key was sufficiently similar to a known one to consider it a typo."
},
"meta_unknownPreprocessor": {
"message": "Unknown @preprocessor: $preprocessor$",
"description": "Error displayed when unknown @preprocessor is parsed",
"placeholders": {
"preprocessor": {
"content": "$1"
}
}
},
"description": "Error displayed when unknown @preprocessor is parsed"
},
"meta_unknownVarType": {
"message": "Unknown @$varkey$ type: $vartype$",
"placeholders": {
"vartype": {
"content": "$2"
},
"varkey": {
"content": "$1"
}
},
"description": "Error displayed when unknown variable type is parsed"
},
"noFileToImport": {
"message": "To import your styles, you should export it first."
},
"noStylesForSite": {
"message": "No styles installed for this site.",
"description": "Text displayed when no styles are installed for the current site"
},
"numberedLine": {
"message": "Line:",
"description": "Will be followed by one or more line numbers in the editor."
},
"openManage": {
"message": "Manage",
"description": "Link to open the manage page."
@ -987,6 +1077,12 @@
"optionsAdvancedAutoSwitchSchemeByTime": {
"message": "By night time:"
},
"optionsAdvancedPatchCsp": {
"message": "Patch <code>CSP</code> to allow style assets"
},
"optionsAdvancedPatchCspNote": {
"message": "Enable this if styles contain images or fonts which fail to load on sites with a strict <code>CSP</code> (<code>Content-Security-Policy</code>).\n\nEnabling this setting will relax <code>CSP</code> restrictions, allowing essential style content to load. This option is only intended for advanced users who understand the potential security implications, and accept responsibility for monitoring the content which they're allowing. Read about CSS-based attacks for more information.\n\nAlso be aware, this particular setting is not guaranteed to take effect if another installed extension modifies the network response first."
},
"optionsAdvancedStyleViaXhr": {
"message": "Instant inject mode"
},
@ -1014,12 +1110,12 @@
"optionsCustomizePopup": {
"message": "Popup"
},
"optionsCustomizeUpdate": {
"message": "Updates"
},
"optionsCustomizeSync": {
"message": "Sync to cloud"
},
"optionsCustomizeUpdate": {
"message": "Updates"
},
"optionsHeading": {
"message": "Options",
"description": "Heading for options section on manage page."
@ -1052,27 +1148,30 @@
"message": "More Options",
"description": "Subheading for options section on manage page."
},
"optionsUpdateImportNote": {
"message": "When importing style backups from old version or from Stylish, do a one-time check for updates manually in the styles manager to ensure all styles are updated."
},
"optionsUpdateInterval": {
"message": "Userstyle autoupdate interval in hours (specify 0 to disable)"
},
"optionsSyncNone": {
"message": "None"
},
"optionsSyncConnect": {
"message": "Connect"
},
"optionsSyncDisconnect": {
"message": "Disconnect"
},
"optionsSyncSyncNow": {
"message": "Sync now"
},
"optionsSyncLogin": {
"message": "Login"
},
"optionsSyncNone": {
"message": "None"
},
"optionsSyncStatusConnected": {
"message": "Connected"
},
"optionsSyncStatusConnecting": {
"message": "Connecting..."
},
"optionsSyncStatusDisconnected": {
"message": "Disconnected"
},
"optionsSyncStatusDisconnecting": {
"message": "Disconnecting..."
},
"optionsSyncStatusPull": {
"message": "Pulling style $loaded$ of $total$",
"placeholders": {
@ -1095,20 +1194,23 @@
}
}
},
"optionsSyncStatusRelogin": {
"message": "Session expired, please login again."
},
"optionsSyncStatusSyncing": {
"message": "Syncing..."
},
"optionsSyncStatusConnecting": {
"message": "Connecting..."
"optionsSyncSyncNow": {
"message": "Sync now"
},
"optionsSyncStatusConnected": {
"message": "Connected"
"optionsUpdateImportNote": {
"message": "When importing style backups from old version or from Stylish, do a one-time check for updates manually in the styles manager to ensure all styles are updated."
},
"optionsSyncStatusDisconnecting": {
"message": "Disconnecting..."
"optionsUpdateInterval": {
"message": "Userstyle autoupdate interval in hours (specify 0 to disable)"
},
"optionsSyncStatusDisconnected": {
"message": "Disconnected"
"overwriteFileExport": {
"message": "Do you want to overwrite an existing file?"
},
"paginationCurrent": {
"message": "Current page",
@ -1187,6 +1289,31 @@
"message": "Temporarily applies the changes without saving.\nSave the style to make the changes permanent.",
"description": "Tooltip for the checkbox in style editor to enable live preview while editing."
},
"publish": {
"message": "Publish",
"description": "Header for the section to link the style with userStyles.world"
},
"publishPush": {
"message": "Push update",
"description": "The 'Publish style' button's new name when a connection is established"
},
"publishReconnect": {
"message": "Try disconnecting then publish again"
},
"publishRetry": {
"message": "Stylus is still trying to publish this style, but you can retry if you see no authentication activity or popups. Retry now?"
},
"publishStyle": {
"message": "Publish style",
"description": "Publish the current style to userstyles.world"
},
"publishUsw": {
"message": "Using <userstyles.world>",
"description": "Name of the link to https://userstyles.world in the editor"
},
"readingStyles": {
"message": "Reading styles..."
},
"reload": {
"message": "Reload Stylus extension",
"description": "Context menu reload"
@ -1206,6 +1333,9 @@
"retrieveBckp": {
"message": "Import styles"
},
"retrieveDropboxSync": {
"message": "Dropbox Import"
},
"search": {
"message": "Search",
"description": "Label before the search input field in the editor shown on Ctrl-F"
@ -1226,10 +1356,6 @@
"message": "Number of matches in code and applies-to values",
"description": "Tooltip for the number of found search results in the editor shown on Ctrl-F"
},
"searchStyleQueryHint": {
"message": "Search style names case-insensitively:\nsome words - all words in any order\n\"some phrase\" - this exact phrase without quotes\n2020 - a year like this also shows styles updated in 2020",
"description": "Tooltip shown for the text input in the popup's inline style finder"
},
"searchRegexp": {
"message": "Use /re/ syntax for regexp search",
"description": "Label after the search input field in the editor shown on Ctrl-F"
@ -1242,6 +1368,12 @@
"message": "No styles found for this site.",
"description": "Error text in the popup when inline search didn't find any site-specific styles"
},
"searchResultNotMatching": {
"message": "The style is installed but it doesn't apply to the current site URL."
},
"searchResultNotMatchingNote": {
"message": "Try asking the author of this userstyle to add the URL.\n\nYou can also open the style in the manager and edit it yourself,\nbut be aware it disables automatic updates for this style."
},
"searchResultRating": {
"message": "Rating",
"description": "Text for label that shows the search result's rating"
@ -1254,14 +1386,34 @@
"message": "Weekly installs",
"description": "Text for label that shows the number of times a search result was installed during last week"
},
"searchStyles": {
"message": "Search contents",
"description": "Label for the search filter textbox on the Manage styles page"
"searchStyleQueryHint": {
"message": "Search style names case-insensitively:\nsome words - all words in any order\n\"some phrase\" - this exact phrase without quotes\n2020 - a year like this also shows styles updated in 2020",
"description": "Tooltip shown for the text input in the popup's inline style finder"
},
"searchStylesAll": {
"message": "All",
"description": "Option for `find styles` scope selector in the manager."
},
"searchStylesCode": {
"message": "CSS code",
"description": "Option for `find styles` scope selector in the manager."
},
"searchStylesHelp": {
"message": "</> key focuses the search field.\nPlain text: search within the name, code, homepage URL and sites it is applied to. Words with less than 3 letters are ignored.\nStyles matching a full URL: prefix the search with <url:>, e.g. <url:https://github.com/openstyles/stylus>\nRegular expressions: include slashes and flags, e.g. </body.*?\\ba\\b/simguy>\nExact words: wrap the query in double quotes, e.g. <\".header ~ div\">",
"message": "</> or <Ctrl-F> key focuses the search field.\nDefault mode is plain text search for all space-separated terms in any order.\nExact words: wrap the query in double quotes, e.g. <\".header ~ div\">\nRegular expressions: include slashes and flags, e.g. </body.*?\\ba\\b/i>\n\"By URL\" in scope selector: finds styles that apply to a fully specified URL e.g. https://www.example.org/\n\"Metadata\" in scope selector: searches in names, \"applies to\" specifiers, installation URL, update URL, and the entire metadata block for usercss styles.",
"description": "Text in the minihelp displayed when clicking (i) icon to the right of the search input field on the Manage styles page"
},
"searchStylesMatchUrl": {
"message": "By URL",
"description": "Option for `find styles` scope selector in the manager. See searchMatchUrlHint for more info."
},
"searchStylesMeta": {
"message": "Metadata",
"description": "Option for `find styles` scope selector in the manager."
},
"searchStylesName": {
"message": "Name",
"description": "Option for `find styles` scope selector in the manager."
},
"sectionAdd": {
"message": "Add another section",
"description": "Label for the button to add a section"
@ -1278,6 +1430,10 @@
"message": "Restore removed section",
"description": "Label for the button to restore a removed section"
},
"sections": {
"message": "Sections",
"description": "Header for the table of contents block listing style section names in the left panel of the classic editor"
},
"shortcuts": {
"message": "Shortcuts",
"description": "Go to shortcut configuration"
@ -1394,6 +1550,9 @@
"message": "Mozilla Format",
"description": "Heading for the section with buttons to import/export Mozilla format of the style"
},
"styleName": {
"message": "Style name"
},
"styleNotAppliedRegexpProblemTooltip": {
"message": "Style was not applied due to its incorrect usage of 'regexp()'",
"description": "Tooltip in the popup for styles that were not applied at all"
@ -1475,6 +1634,20 @@
"message": "As a security precaution, the browser prohibits extensions from affecting its built-in pages (like chrome://version, the standard new tab page as of Chrome 61, about:addons, and so on) as well as other extensions' pages. Each browser also restricts access to its own extensions gallery (like Chrome Web Store or AMO).",
"description": "Sub-note in the toolbar pop-up when on a URL Stylus can't affect"
},
"syncDropboxDeprecated": {
"message": "Dropbox import/export is replaced by a more advanced style sync in the options page."
},
"syncDropboxStyles": {
"message": "Dropbox Export"
},
"syncError": {
"message": "Sync failed",
"description": "Tooltip for the toolbar icon"
},
"syncErrorRelogin": {
"message": "Sync failed.\nTry to re-login in Stylus options:\nclick 'disconnect' first, then 'connect'.",
"description": "Tooltip for the toolbar icon"
},
"syncStorageErrorSaving": {
"message": "The value cannot be saved. Try reducing the amount of text.",
"description": "Displayed when trying to save an excessively big value via storage.sync API"
@ -1499,14 +1672,6 @@
"message": "To allow access open <about:config>, right-click the list, click 'New', then 'Boolean', paste <privacy.resistFingerprinting.block_mozAddonManager> and click OK, <true>, OK, reload the <addons.mozilla.org> page.",
"description": "Note in the popup when opened on addons.mozilla.org in Firefox >= 59"
},
"unreachableAMOHintNewFF": {
"message": "In Firefox 60 and newer you'll also have to remove AMO domain from <extensions.webextensions.restrictedDomains> in <about:config>.",
"description": "Note in the popup when opened on addons.mozilla.org in Firefox >= 59"
},
"unreachableAMOHintOldFF": {
"message": "Only Firefox 59 and newer can be configured to allow WebExtensions to add style elements on CSP-protected sites such as this one.",
"description": "Note in the popup when opened on addons.mozilla.org in Firefox < 59"
},
"unreachableContentScript": {
"message": "Could not communicate with the page. Try reloading the tab.",
"description": "Note in the toolbar popup usually on file:// URLs after [re]loading Stylus"
@ -1515,9 +1680,16 @@
"message": "Stylus can access file:// URLs only if you enable the corresponding checkbox for Stylus extension on chrome://extensions page.",
"description": "Note in the toolbar popup for file:// URLs"
},
"InaccessibleFileHint": {
"message": "Stylus can not access some file types (e.g. pdf & json files).",
"description": "Note in the toolbar popup for some file types that cannot be accessed"
"unreachableMozSiteHint": {
"message": "In Firefox 60 and newer you need to remove this domain from <extensions.webextensions.restrictedDomains> in <about:config>.",
"description": "Note in the popup when opened on a restricted mozilla site in Firefox >= 60"
},
"unreachableMozSiteHintOldFF": {
"message": "Only Firefox 59 and newer can be configured to allow WebExtensions to add style elements on CSP-protected sites such as this one.",
"description": "Note in the popup when opened on a restricted mozilla site in Firefox < 59"
},
"unzipStyles": {
"message": "Unzipping styles..."
},
"updateAllCheckSucceededNoUpdate": {
"message": "No updates found.",
@ -1571,6 +1743,9 @@
"message": "Updates installed:",
"description": "Text that displays when an update is installed on options page. Followed by the number of currently installed updates."
},
"uploadingFile": {
"message": "Uploading File..."
},
"usercssAvoidOverwriting": {
"message": "Please change the value of @name or @namespace to avoid overwriting an existing style.",
"description": "Shown in a message box when attempting to save a new Usercss style that would overwrite an existing one."
@ -1605,43 +1780,7 @@
"message": "this URL",
"description": "Text for link in toolbar pop-up to write a new style for the current URL"
},
"syncDropboxStyles": {
"message": "Dropbox Export"
},
"syncDropboxDeprecated": {
"message": "Dropbox import/export is replaced by a more advanced style sync in the options page."
},
"retrieveDropboxSync": {
"message": "Dropbox Import"
},
"overwriteFileExport": {
"message": "Do you want to overwrite an existing file?"
},
"exportSavedSuccess": {
"message": "File saved with success"
},
"noFileToImport": {
"message": "To import your styles, you should export it first."
},
"connectingDropbox": {
"message": "Connecting Dropbox..."
},
"connectingDropboxNotAllowed": {
"message": "Connecting to Dropbox is only available in apps installed directly from the webstore"
},
"gettingStyles": {
"message": "Getting all styles..."
},
"zipStyles": {
"message": "Zipping styles..."
},
"unzipStyles": {
"message": "Unzipping styles..."
},
"readingStyles": {
"message": "Reading styles..."
},
"uploadingFile": {
"message": "Uploading File..."
}
}

View File

@ -1,67 +1,51 @@
{
"appliesRemoveError": {
"message": "Cannot remove last 'applies to' entry",
"description": "Error displayed when the last 'applies' is going to be removed"
"message": "Cannot remove last 'applies to' entry"
},
"checkAllUpdatesForce": {
"message": "Check again—I didn't edit any styles!",
"description": "Label for the button to apply all detected updates"
"message": "Check again—I didn't edit any styles!"
},
"cm_autoCloseBrackets": {
"message": "Auto-close brackets and quotes",
"description": "Label for the checkbox in the style editor."
"message": "Auto-close brackets and quotes"
},
"cm_colorpicker": {
"message": "Colour pickers for CSS colours",
"description": "Label for the checkbox controlling colorpicker option for the style editor."
"message": "Colour pickers for CSS colours"
},
"cm_resizeGripHint": {
"message": "Double-click to maximise/restore the height",
"description": "Tooltip for the resize grip in style editor"
"message": "Double-click to maximise/restore the height"
},
"colorpickerTooltip": {
"message": "Open colour picker",
"description": "Tooltip for the colored squares shown before CSS colors in the style editor."
"message": "Open colour picker"
},
"description": {
"message": "Redesign the web with Stylus, a user-style manager. Stylus allows you to easily install themes and skins for many popular sites.",
"description": "Extension description"
"message": "Redesign the web with Stylus, a user-style manager. Stylus allows you to easily install themes and skins for many popular sites."
},
"editGotoLine": {
"message": "Go to line (or line:col)",
"description": "Go to line or line:column on Ctrl-G in style code editor"
"message": "Go to line (or line:col)"
},
"editStyleHeading": {
"message": "Edit style",
"description": "Title of the page for editing styles"
"message": "Edit style"
},
"license": {
"message": "Licence",
"description": "Label for the license"
"message": "Licence"
},
"manageFaviconsGray": {
"message": "Greyed out",
"description": "Label for the checkbox that toggles grayed out mode of applies-to favicons in the new UI on manage page"
"message": "Greyed out"
},
"optionsBadgeDisabled": {
"message": "Background colour when disabled",
"description": ""
"message": "Background colour when disabled"
},
"optionsBadgeNormal": {
"message": "Background colour",
"description": ""
"message": "Background colour"
},
"optionsUpdateImportNote": {
"message": "When importing style backups from an old version or from Stylish, do a one-time check for updates manually in the styles manager to ensure all styles are updated.",
"description": ""
"message": "When importing style backups from an old version or from Stylish, do a one-time check for updates manually in the styles manager to ensure all styles are updated."
},
"optionsUpdateInterval": {
"message": "Userstyle auto-update interval in hours (specify 0 to disable)",
"description": ""
"message": "Userstyle auto-update interval in hours (specify 0 to disable)"
},
"styleInstallFailed": {
"message": "Failed to install userstyle\n$error$",
"description": "Warning when installation failed",
"placeholders": {
"error": {
"content": "$1"
@ -69,15 +53,12 @@
}
},
"styleRegexpPartialExplanation": {
"message": "This style uses partially matching regexps in violation of <a href='https://developer.mozilla.org/docs/Web/CSS/@document'>CSS4 @document specification</a> which requires a full URL match. The affected CSS sections were not applied to the page. This style was probably created in Stylish-for-Chrome, which incorrectly checks 'regexp()' rules since the very first version (known bug).",
"description": ""
"message": "This style uses partially matching regexps in violation of <a href='https://developer.mozilla.org/docs/Web/CSS/@document'>CSS4 @document specification</a> which requires a full URL match. The affected CSS sections were not applied to the page. This style was probably created in Stylish-for-Chrome, which incorrectly checks 'regexp()' rules since the very first version (known bug)."
},
"styleUpdateDiscardChanges": {
"message": "The style has been changed outside the editor. Would you like to reload the style?",
"description": "Confirmation to update the style in the editor"
"message": "The style has been changed outside the editor. Would you like to reload the style?"
},
"usercssConfigIncomplete": {
"message": "The style was updated or deleted after the configuration dialogue was shown. These variables were not saved to avoid corrupting the style's metadata:",
"description": ""
"message": "The style was updated or deleted after the configuration dialogue was shown. These variables were not saved to avoid corrupting the style's metadata:"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,15 @@
{
"addStyleLabel": {
"message": "Uusi Tyyli",
"description": "Label for the button to go to the add style page"
"message": "Uusi Tyyli"
},
"addStyleTitle": {
"message": "Lisää Tyyli",
"description": "Title of the page for adding styles"
"message": "Lisää Tyyli"
},
"appliesAdd": {
"message": "Lisää",
"description": "Label for the button to add an 'applies' entry"
"message": "Lisää"
},
"appliesDisplay": {
"message": "Kooskee: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,80 +17,61 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "ja lisää",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "ja lisää"
},
"appliesDomainOption": {
"message": "URL ositteita domainilla",
"description": "Option to make the style apply to the entered string as a domain"
"message": "URL ositteita domainilla"
},
"appliesHelp": {
"message": "Käytä 'Koskee' kontrolleja rajoittaaksesi mitä URL osoitteisiin tämä osio koodista koskee.",
"description": "Help text for 'applies to' section"
"message": "Käytä 'Koskee' kontrolleja rajoittaaksesi mitä URL osoitteisiin tämä osio koodista koskee."
},
"appliesLabel": {
"message": "Koskee",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Koskee"
},
"appliesRegexpOption": {
"message": "URL ositteet jotka vastaavat regexpiä",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "URL ositteet jotka vastaavat regexpiä"
},
"appliesRemove": {
"message": "Poista",
"description": "Label for the button to remove an 'applies' entry"
"message": "Poista"
},
"appliesSpecify": {
"message": "Tarkenna",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Tarkenna"
},
"appliesToEverything": {
"message": "Kaikki",
"description": "Text displayed for styles that apply to all sites"
"message": "Kaikki"
},
"appliesUrlPrefixOption": {
"message": "URL osoitteet jotka alkavat",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "URL osoitteet jotka alkavat"
},
"checkAllUpdates": {
"message": "Tarkista kaikki tyylit päivityksien varalta",
"description": "Label for the button to check all styles for updates"
"message": "Tarkista kaikki tyylit päivityksien varalta"
},
"checkForUpdate": {
"message": "Hae päivityksiä",
"description": "Label for the button to check a single style for an update"
"message": "Hae päivityksiä"
},
"checkingForUpdate": {
"message": "Tarkistetaan...",
"description": "Text to display when checking a style for an update"
"message": "Tarkistetaan..."
},
"deleteStyleConfirm": {
"message": "Oletko varma että haluat poistaa tämän tyylin?",
"description": "Confirmation before deleting a style"
"message": "Oletko varma että haluat poistaa tämän tyylin?"
},
"deleteStyleLabel": {
"message": "Poista",
"description": "Label for the button to delete a style"
"message": "Poista"
},
"description": {
"message": "Uudelleen stailaa netti Stylusillä, käyttäjän tyyli hallintapaneelilla. Stylus antaa sinun helposti asentaa teemoja ja skinejä palvelluille kuten Google, Facebook, YouTube, Orkut, ja monelle, monelle muulle sivustolle.",
"description": "Extension description"
"message": "Uudelleen stailaa netti Stylusillä, käyttäjän tyyli hallintapaneelilla. Stylus antaa sinun helposti asentaa teemoja ja skinejä palvelluille kuten Google, Facebook, YouTube, Orkut, ja monelle, monelle muulle sivustolle."
},
"disableStyleLabel": {
"message": "Poista Käytöstä",
"description": "Label for the button to disable a style"
"message": "Poista Käytöstä"
},
"editStyleHeading": {
"message": "Muokkaa Tyyliä",
"description": "Title of the page for editing styles"
"message": "Muokkaa Tyyliä"
},
"editStyleLabel": {
"message": "Muokkaa",
"description": "Label for the button to go to the edit style page"
"message": "Muokkaa"
},
"editStyleTitle": {
"message": "Muokkaa Tyyliä $stylename$",
"description": "Title of the page for editing styles",
"placeholders": {
"stylename": {
"content": "$1"
@ -102,76 +79,58 @@
}
},
"enableStyleLabel": {
"message": "Aktivoi",
"description": "Label for the button to enable a style"
"message": "Aktivoi"
},
"findStylesForSite": {
"message": "Hae lisää tyylejä tälle sivustolle",
"description": "Text for a link that gets a list of styles for the current site"
"message": "Hae lisää tyylejä tälle sivustolle"
},
"helpAlt": {
"message": "Apu",
"description": "Alternate text for help buttons"
"message": "Apu"
},
"installUpdate": {
"message": "Asenna päivitys",
"description": "Label for the button to install an update for a single style"
"message": "Asenna päivitys"
},
"manageHeading": {
"message": "Asennetut Tyylit",
"description": "Heading for the manage page"
"message": "Asennetut Tyylit"
},
"manageTitle": {
"message": "Tyylikäs",
"description": "Title for the manage page"
"message": "Tyylikäs"
},
"noStylesForSite": {
"message": "Ei asennettuja tyylejä tällä sivustolla.",
"description": "Text displayed when no styles are installed for the current site"
"message": "Ei asennettuja tyylejä tällä sivustolla."
},
"openManage": {
"message": "Hallitse asennettuja tyylejä",
"description": "Link to open the manage page."
"message": "Hallitse asennettuja tyylejä"
},
"popupStylesFirst": {
"message": "List styles before commands in the toolbar button menu",
"description": "Label for the checkbox controlling section order in the popup."
"message": "List styles before commands in the toolbar button menu"
},
"prefShowBadge": {
"message": "Show number of styles active for the current site on the toolbar button",
"description": "Label for the checkbox controlling toolbar badge text."
"message": "Show number of styles active for the current site on the toolbar button"
},
"sectionAdd": {
"message": "Lisää uusi osio",
"description": "Label for the button to add a section"
"message": "Lisää uusi osio"
},
"sectionCode": {
"message": "Koodi",
"description": "Label for the code for a section"
"message": "Koodi"
},
"sectionRemove": {
"message": "Poista osio",
"description": "Label for the button to remove a section"
"message": "Poista osio"
},
"styleBadRegexp": {
"message": "Regexp ei kelpaa.",
"description": "Validation message for a bad regexp in a style"
"message": "Regexp ei kelpaa."
},
"styleCancelEditLabel": {
"message": "Takaisin hallintapaneeliin",
"description": "Label for cancel button for style editing"
"message": "Takaisin hallintapaneeliin"
},
"styleChangesNotSaved": {
"message": "Olet tehnyt muutoksia tähän tyyliin tallentamatta.",
"description": "Text for the prompt when changes are made to a style and the user tries to leave without saving"
"message": "Olet tehnyt muutoksia tähän tyyliin tallentamatta."
},
"styleEnabledLabel": {
"message": "Aktivoitu",
"description": "Label for the enabled state of styles"
"message": "Aktivoitu"
},
"styleInstall": {
"message": "Asennetaanko '$stylename$' Stylusiin?",
"description": "Confirmation when installing a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -179,24 +138,19 @@
}
},
"styleMissingName": {
"message": "Syötä nimi",
"description": "Error displayed when user saves without providing a name"
"message": "Syötä nimi"
},
"styleSaveLabel": {
"message": "Tallenna",
"description": "Label for save button for style editing"
"message": "Tallenna"
},
"styleToMozillaFormatHelp": {
"message": "Mozilla formaattia koodista voidaan käyttää Stylish Firefoxille ohjelmassa ja voidaan lähettää userstyles.orgiin.",
"description": "Help info for the Mozilla format header section that converts the code to/from Mozilla format"
"message": "Mozilla formaattia koodista voidaan käyttää Stylish Firefoxille ohjelmassa ja voidaan lähettää userstyles.orgiin."
},
"updateAllCheckSucceededNoUpdate": {
"message": "All styles are up to date.",
"description": "Text that displays when an update all check completed and no updates are available"
"message": "All styles are up to date."
},
"updateCheckFailBadResponseCode": {
"message": "Päivitys epäonnistui: palvelin vastasi koodilla $code$.",
"description": "Text that displays when an update check failed because the response code indicates an error",
"placeholders": {
"code": {
"content": "$1"
@ -204,15 +158,12 @@
}
},
"updateCheckFailServerUnreachable": {
"message": "Päivitys epäonnistui: ei voitu yhdistää palvelimeen.",
"description": "Text that displays when an update check failed because the update server is unreachable"
"message": "Päivitys epäonnistui: ei voitu yhdistää palvelimeen."
},
"updateCheckSucceededNoUpdate": {
"message": "Tyyli on ajan tasalla.",
"description": "Text that displays when an update check completed and no update is available"
"message": "Tyyli on ajan tasalla."
},
"updateCompleted": {
"message": "Päivitys suoritettu.",
"description": "Text that displays when an update completed"
"message": "Päivitys suoritettu."
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,15 @@
{
"addStyleLabel": {
"message": "Nije styl skriuwe",
"description": "Label for the button to go to the add style page"
"message": "Nije styl skriuwe"
},
"addStyleTitle": {
"message": "Styl tafoegje",
"description": "Title of the page for adding styles"
"message": "Styl tafoegje"
},
"appliesAdd": {
"message": "Tafoegje",
"description": "Label for the button to add an 'applies' entry"
"message": "Tafoegje"
},
"appliesDisplay": {
"message": "Fan tapassing op: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,107 +17,81 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "en mear",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "en mear"
},
"appliesDomainOption": {
"message": "URLs op it domein",
"description": "Option to make the style apply to the entered string as a domain"
"message": "URLs op it domein"
},
"appliesHelp": {
"message": "Brûk de Fan tapassing op-funksjes om de URLs foar de koade yn dizze seksje te beheinen.",
"description": "Help text for 'applies to' section"
"message": "Brûk de Fan tapassing op-funksjes om de URLs foar de koade yn dizze seksje te beheinen."
},
"appliesLabel": {
"message": "Fan tapassing op",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Fan tapassing op"
},
"appliesRegexpOption": {
"message": "URLs oerienkommend mei de regexp",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "URLs oerienkommend mei de regexp"
},
"appliesRemove": {
"message": "Fuortsmite",
"description": "Label for the button to remove an 'applies' entry"
"message": "Fuortsmite"
},
"appliesSpecify": {
"message": "Spesifisearje",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Spesifisearje"
},
"appliesToEverything": {
"message": "Alles",
"description": "Text displayed for styles that apply to all sites"
"message": "Alles"
},
"appliesUrlPrefixOption": {
"message": "URLs begjinnend mei",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "URLs begjinnend mei"
},
"applyAllUpdates": {
"message": "Alle fernijingen tapasse",
"description": "Label for the button to apply all detected updates"
"message": "Alle fernijingen tapasse"
},
"checkAllUpdates": {
"message": "Alle stilen kontrolearje op fernijingen",
"description": "Label for the button to check all styles for updates"
"message": "Alle stilen kontrolearje op fernijingen"
},
"checkForUpdate": {
"message": "Kontrolearje op fernijing",
"description": "Label for the button to check a single style for an update"
"message": "Kontrolearje op fernijing"
},
"checkingForUpdate": {
"message": "Kontrolearje...",
"description": "Text to display when checking a style for an update"
"message": "Kontrolearje..."
},
"cm_indentWithTabs": {
"message": "Ljepblêden mei tûke ynspringing brûke",
"description": "Label for the checkbox controlling tabs with smart indentation option for the style editor."
"message": "Ljepblêden mei tûke ynspringing brûke"
},
"cm_keyMap": {
"message": "Toetseboerdyndieling",
"description": "Label for the drop-down list controlling the keymap for the style editor."
"message": "Toetseboerdyndieling"
},
"cm_lineWrapping": {
"message": "Teksttebekrin",
"description": "Label for the checkbox controlling word wrap option for the style editor."
"message": "Teksttebekrin"
},
"cm_smartIndent": {
"message": "Tûke ynspringing brûke",
"description": "Label for the checkbox controlling smart indentation option for the style editor."
"message": "Tûke ynspringing brûke"
},
"cm_tabSize": {
"message": "Ljepblêdgrutte",
"description": "Label for the text box controlling tab size option for the style editor."
"message": "Ljepblêdgrutte"
},
"cm_theme": {
"message": "Tema",
"description": "Label for the style editor's CSS theme."
"message": "Tema"
},
"confirmNo": {
"message": "Nee",
"description": "'No' button in a confirm dialog"
"message": "Nee"
},
"confirmStop": {
"message": "Stoppe",
"description": "'Stop' button in a confirm dialog"
"message": "Stoppe"
},
"confirmYes": {
"message": "Ja",
"description": "'Yes' button in a confirm dialog"
"message": "Ja"
},
"dbError": {
"message": "Der is in flater bard by it brûken fan de Stylus-database. Wolle jo in webside mei mooglike oplossingen besykje?",
"description": "Prompt when a DB error is encountered"
"message": "Der is in flater bard by it brûken fan de Stylus-database. Wolle jo in webside mei mooglike oplossingen besykje?"
},
"defaultTheme": {
"message": "standert",
"description": "Default CodeMirror CSS theme option on the edit style page"
"message": "standert"
},
"deleteStyleConfirm": {
"message": "Binne jo wis dat jo dizze styl fuortsmite wolle?",
"description": "Confirmation before deleting a style"
"message": "Binne jo wis dat jo dizze styl fuortsmite wolle?"
},
"deleteStyleLabel": {
"message": "Fuortsmite",
"description": "Label for the button to delete a style"
"message": "Fuortsmite"
}
}
}

View File

@ -1,19 +1,15 @@
{
"addStyleTitle": {
"message": "Engadir Estilo",
"description": "Title of the page for adding styles"
"message": "Engadir Estilo"
},
"alphaChannel": {
"message": "Opacidade",
"description": "Label of color's opacity"
"message": "Opacidade"
},
"appliesAdd": {
"message": "Engadir",
"description": "Label for the button to add an 'applies' entry"
"message": "Engadir"
},
"appliesDisplay": {
"message": "Aplica a: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,47 +17,36 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "e mais",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "e mais"
},
"appliesDomainOption": {
"message": "URLs no dominio",
"description": "Option to make the style apply to the entered string as a domain"
"message": "URLs no dominio"
},
"appliesLabel": {
"message": "Aplica para",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Aplica para"
},
"appliesLineWidgetWarning": {
"message": "Non funciona con CSS minificado",
"description": "A warning that applies-to information won't show properly with minified CSS"
"message": "Non funciona con CSS minificado"
},
"appliesRegexpOption": {
"message": "URLs que concorden co regexp",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "URLs que concorden co regexp"
},
"appliesRemove": {
"message": "Suprimir",
"description": "Label for the button to remove an 'applies' entry"
"message": "Suprimir"
},
"appliesSpecify": {
"message": "Especificar",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Especificar"
},
"appliesToEverything": {
"message": "Todo",
"description": "Text displayed for styles that apply to all sites"
"message": "Todo"
},
"appliesUrlPrefixOption": {
"message": "URLs que comecen por",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "URLs que comecen por"
},
"applyAllUpdates": {
"message": "Aplicar tódalas actualizacións",
"description": "Label for the button to apply all detected updates"
"message": "Aplicar tódalas actualizacións"
},
"author": {
"message": "Autor",
"description": "Label for the style author"
"message": "Autor"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
{}

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,19 +1,15 @@
{
"addStyleLabel": {
"message": "Упиши нови стил",
"description": "Label for the button to go to the add style page"
"message": "Упиши нови стил"
},
"addStyleTitle": {
"message": "Додај стил",
"description": "Title of the page for adding styles"
"message": "Додај стил"
},
"appliesAdd": {
"message": "Додај",
"description": "Label for the button to add an 'applies' entry"
"message": "Додај"
},
"appliesDisplay": {
"message": "Примењује се на: $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -21,140 +17,106 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "и још",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "и још"
},
"appliesDomainOption": {
"message": "УРЛ адресе на домену",
"description": "Option to make the style apply to the entered string as a domain"
"message": "УРЛ адресе на домену"
},
"appliesHelp": {
"message": "Употреба 'Примењује се на' одређује опсег УРЛ адреса на које се код у овом одељку примењује.",
"description": "Help text for 'applies to' section"
"message": "Употреба 'Примењује се на' одређује опсег УРЛ адреса на које се код у овом одељку примењује."
},
"appliesLabel": {
"message": "Примењује се на",
"description": "Label for 'applies to' fields on the edit/add screen"
"message": "Примењује се на"
},
"appliesRegexpOption": {
"message": "УРЛ адресе које одговарају регуларном изразу",
"description": "Option to make the style apply to the entered string as a regular expression"
"message": "УРЛ адресе које одговарају регуларном изразу"
},
"appliesRemove": {
"message": "Уклони",
"description": "Label for the button to remove an 'applies' entry"
"message": "Уклони"
},
"appliesSpecify": {
"message": "Детаљније",
"description": "Label for the button to make a style apply only to specific sites"
"message": "Детаљније"
},
"appliesToEverything": {
"message": "Све",
"description": "Text displayed for styles that apply to all sites"
"message": "Све"
},
"appliesUrlOption": {
"message": "УРЛ",
"description": "Option to make the style apply to the entered string as a URL"
"message": "УРЛ"
},
"appliesUrlPrefixOption": {
"message": "УРЛ адресе које почињу са",
"description": "Option to make the style apply to the entered string as a URL prefix"
"message": "УРЛ адресе које почињу са"
},
"applyAllUpdates": {
"message": "Примени сва ажурирања",
"description": "Label for the button to apply all detected updates"
"message": "Примени сва ажурирања"
},
"checkAllUpdates": {
"message": "Проверите ажурирања за све стилове",
"description": "Label for the button to check all styles for updates"
"message": "Проверите ажурирања за све стилове"
},
"checkForUpdate": {
"message": "Проверите ажурирање",
"description": "Label for the button to check a single style for an update"
"message": "Проверите ажурирање"
},
"checkingForUpdate": {
"message": "Проверавање...",
"description": "Text to display when checking a style for an update"
"message": "Проверавање..."
},
"cm_indentWithTabs": {
"message": "Користи картице са паметним увлачењем редова",
"description": "Label for the checkbox controlling tabs with smart indentation option for the style editor."
"message": "Користи картице са паметним увлачењем редова"
},
"cm_keyMap": {
"message": "Мапа тастера",
"description": "Label for the drop-down list controlling the keymap for the style editor."
"message": "Мапа тастера"
},
"cm_lineWrapping": {
"message": "Преламање текста",
"description": "Label for the checkbox controlling word wrap option for the style editor."
"message": "Преламање текста"
},
"cm_smartIndent": {
"message": "Користи паметно увлачење редова",
"description": "Label for the checkbox controlling smart indentation option for the style editor."
"message": "Користи паметно увлачење редова"
},
"cm_tabSize": {
"message": "Величина картице",
"description": "Label for the text box controlling tab size option for the style editor."
"message": "Величина картице"
},
"cm_theme": {
"message": "Тема",
"description": "Label for the style editor's CSS theme."
"message": "Тема"
},
"confirmNo": {
"message": "Не",
"description": "'No' button in a confirm dialog"
"message": "Не"
},
"confirmStop": {
"message": "Заустави",
"description": "'Stop' button in a confirm dialog"
"message": "Заустави"
},
"confirmYes": {
"message": "Да",
"description": "'Yes' button in a confirm dialog"
"message": "Да"
},
"dbError": {
"message": "Дошло је до грешке користећи Stylus базу података. Да ли желите да посетите веб страницу са могућим решењима?",
"description": "Prompt when a DB error is encountered"
"message": "Дошло је до грешке користећи Stylus базу података. Да ли желите да посетите веб страницу са могућим решењима?"
},
"defaultTheme": {
"message": "подразумевано",
"description": "Default CodeMirror CSS theme option on the edit style page"
"message": "подразумевано"
},
"deleteStyleConfirm": {
"message": "Да ли сте сигурни да желите да избришете овај стил?",
"description": "Confirmation before deleting a style"
"message": "Да ли сте сигурни да желите да избришете овај стил?"
},
"deleteStyleLabel": {
"message": "Избриши",
"description": "Label for the button to delete a style"
"message": "Избриши"
},
"description": {
"message": "Измените стил интернет мреже управљачем корисничких стилова. Stylus вам омогућава да лако инсталирате теме и скинове за многе популарне сајтове.",
"description": "Extension description"
"message": "Измените стил интернет мреже управљачем корисничких стилова. Stylus вам омогућава да лако инсталирате теме и скинове за многе популарне сајтове."
},
"disableAllStyles": {
"message": "Искључи све стилове",
"description": "Label for the checkbox that turns all enabled styles off."
"message": "Искључи све стилове"
},
"disableStyleLabel": {
"message": "Онемогући",
"description": "Label for the button to disable a style"
"message": "Онемогући"
},
"editGotoLine": {
"message": "Иди на ред (или line:col)",
"description": "Go to line or line:column on Ctrl-G in style code editor"
"message": "Иди на ред (или line:col)"
},
"editStyleHeading": {
"message": "Уреди стил",
"description": "Title of the page for editing styles"
"message": "Уреди стил"
},
"editStyleLabel": {
"message": "Уреди",
"description": "Label for the button to go to the edit style page"
"message": "Уреди"
},
"editStyleTitle": {
"message": "Уреди стил $stylename$",
"description": "Title of the page for editing styles",
"placeholders": {
"stylename": {
"content": "$1"
@ -162,68 +124,52 @@
}
},
"enableStyleLabel": {
"message": "Омогући",
"description": "Label for the button to enable a style"
"message": "Омогући"
},
"exportLabel": {
"message": "Извези",
"description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)"
"message": "Извези"
},
"findStylesForSite": {
"message": "Пронађи још стилова за овај сајт",
"description": "Text for a link that gets a list of styles for the current site"
"message": "Пронађи још стилова за овај сајт"
},
"helpAlt": {
"message": "Помоћ",
"description": "Alternate text for help buttons"
"message": "Помоћ"
},
"helpKeyMapCommand": {
"message": "Укуцај име команде",
"description": "Placeholder text of inputbox in keymap help popup on the edit style page. Must be very short"
"message": "Укуцај име команде"
},
"helpKeyMapHotkey": {
"message": "Притисни пречицу",
"description": "Placeholder text of inputbox in keymap help popup on the edit style page. Must be very short"
"message": "Притисни пречицу"
},
"importAppendLabel": {
"message": "Додај стилу",
"description": "Label for the button to import a style and append to the existing sections"
"message": "Додај стилу"
},
"importAppendTooltip": {
"message": "Додај увезени стил тренутном стилу",
"description": "Tooltip for the button to import a style and append to the existing sections"
"message": "Додај увезени стил тренутном стилу"
},
"importLabel": {
"message": "Увези",
"description": "Label for the button to import a style ('edit' page) or all styles ('manage' page)"
"message": "Увези"
},
"importReplaceLabel": {
"message": "Упиши преко стила",
"description": "Label for the button to import and overwrite current style"
"message": "Упиши преко стила"
},
"importReplaceTooltip": {
"message": "Одбаци садржај тренутног стила и упиши преко њега увезени стил",
"description": "Label for the button to import and overwrite current style"
"message": "Одбаци садржај тренутног стила и упиши преко њега увезени стил"
},
"installUpdate": {
"message": "Инсталирај ажурирање",
"description": "Label for the button to install an update for a single style"
"message": "Инсталирај ажурирање"
},
"linkGetHelp": {
"message": "Помоћ",
"description": "Homepage link text on the manage page e.g. https://add0n.com/stylus.html#features with chat/FAQ/intro/info"
"message": "Помоћ"
},
"linkGetStyles": {
"message": "Преузмите стилове",
"description": "Help link text on the manage page e.g. https://userstyles.org"
"message": "Преузмите стилове"
},
"linterIssues": {
"message": "Проблеми",
"description": "Label for the CSS linter issues block on the style edit page"
"message": "Проблеми"
},
"linterIssuesHelp": {
"message": "Проблем пронађен од стране $link$:",
"description": "Help popup message for the selected CSS linter issues block on the style edit page",
"placeholders": {
"link": {
"content": "$1"
@ -231,104 +177,76 @@
}
},
"manageFilters": {
"message": "Филтери",
"description": "Label for filters container"
"message": "Филтери"
},
"manageHeading": {
"message": "Инсталирани стилови",
"description": "Heading for the manage page"
"message": "Инсталирани стилови"
},
"manageOnlyEnabled": {
"message": "Само омогућени стилови",
"description": "Checkbox to show only enabled styles"
"message": "Само омогућени стилови"
},
"menuShowBadge": {
"message": "Прикажи број активних стилова",
"description": "Label (must be very short) for the checkbox in the toolbar button context menu controlling toolbar badge text."
"message": "Прикажи број активних стилова"
},
"noStylesForSite": {
"message": "Нема инсталираних стилова за овај сајт.",
"description": "Text displayed when no styles are installed for the current site"
"message": "Нема инсталираних стилова за овај сајт."
},
"openManage": {
"message": "Управљај инсталираним стиловима",
"description": "Link to open the manage page."
"message": "Управљај инсталираним стиловима"
},
"optionsHeading": {
"message": "Опције",
"description": "Heading for options section on manage page."
"message": "Опције"
},
"popupStylesFirst": {
"message": "Излистај стилове пре команди у менију дугмета на алатној траци",
"description": "Label for the checkbox controlling section order in the popup."
"message": "Излистај стилове пре команди у менију дугмета на алатној траци"
},
"prefShowBadge": {
"message": "Прикажи број активних стилова за тренутни сајт на дугмету на алатној траци",
"description": "Label for the checkbox controlling toolbar badge text."
"message": "Прикажи број активних стилова за тренутни сајт на дугмету на алатној траци"
},
"replace": {
"message": "Замени",
"description": "Label before the replace input field in the editor shown on Ctrl-H"
"message": "Замени"
},
"replaceAll": {
"message": "Замени све",
"description": "Label before the replace input field in the editor shown on 'replaceAll' hotkey"
"message": "Замени све"
},
"replaceWith": {
"message": "Замени са",
"description": "Label before the replace-with input field in the editor shown on Ctrl-H etc."
"message": "Замени са"
},
"search": {
"message": "Претражи",
"description": "Label before the search input field in the editor shown on Ctrl-F"
"message": "Претражи"
},
"searchRegexp": {
"message": "Користи /re/ синтаксу за претрагу регуларним изразом",
"description": "Label after the search input field in the editor shown on Ctrl-F"
},
"searchStyles": {
"message": "Претражи садржај",
"description": "Label for the search filter textbox on the Manage styles page"
"message": "Користи /re/ синтаксу за претрагу регуларним изразом"
},
"sectionAdd": {
"message": "Додај нови одељак",
"description": "Label for the button to add a section"
"message": "Додај нови одељак"
},
"sectionCode": {
"message": "Код",
"description": "Label for the code for a section"
"message": "Код"
},
"sectionRemove": {
"message": "Уклони одељак",
"description": "Label for the button to remove a section"
"message": "Уклони одељак"
},
"styleBadRegexp": {
"message": "Регуларни израз је неисправан.",
"description": "Validation message for a bad regexp in a style"
"message": "Регуларни израз је неисправан."
},
"styleBeautify": {
"message": "Улепшај",
"description": "Label for the CSS-beautifier button on the edit style page"
"message": "Улепшај"
},
"styleCancelEditLabel": {
"message": "Назад на управљање",
"description": "Label for cancel button for style editing"
"message": "Назад на управљање"
},
"styleChangesNotSaved": {
"message": "Направили сте измене овог стила које нисте сачували.",
"description": "Text for the prompt when changes are made to a style and the user tries to leave without saving"
"message": "Направили сте измене овог стила које нисте сачували."
},
"styleEnabledLabel": {
"message": "Омогућено",
"description": "Label for the enabled state of styles"
"message": "Омогућено"
},
"styleFromMozillaFormatPrompt": {
"message": "Налепи код у Mozilla формату",
"description": "Prompt in the dialog displayed after clicking 'Import from Mozilla format' button"
"message": "Налепи код у Mozilla формату"
},
"styleInstall": {
"message": "Инсталирати '$stylename$' у Stylus?",
"description": "Confirmation when installing a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -336,28 +254,22 @@
}
},
"styleMissingName": {
"message": "Унесите назив",
"description": "Error displayed when user saves without providing a name"
"message": "Унесите назив"
},
"styleMozillaFormatHeading": {
"message": "Mozilla формат",
"description": "Heading for the section with buttons to import/export Mozilla format of the style"
"message": "Mozilla формат"
},
"styleSaveLabel": {
"message": "Сачувај",
"description": "Label for save button for style editing"
"message": "Сачувај"
},
"styleToMozillaFormatHelp": {
"message": "Mozilla формат кода се може користити у Stylish за Firefox и може се послати на userstyles.org.",
"description": "Help info for the Mozilla format header section that converts the code to/from Mozilla format"
"message": "Mozilla формат кода се може користити у Stylish за Firefox и може се послати на userstyles.org."
},
"styleToMozillaFormatTitle": {
"message": "Стил у Mozilla формату",
"description": "Title of the popup with the style code in Mozilla format, shown after pressing the Export button on Edit style page"
"message": "Стил у Mozilla формату"
},
"styleUpdate": {
"message": "Да ли сте сигурни да желите да ажурирате '$stylename$'?",
"description": "Confirmation when updating a style",
"placeholders": {
"stylename": {
"content": "$1"
@ -365,24 +277,19 @@
}
},
"stylusUnavailableForURL": {
"message": "Stylus не ради на страницама као што је ова.",
"description": "Note in the toolbar pop-up when on a URL Stylus can't affect"
"message": "Stylus не ради на страницама као што је ова."
},
"undo": {
"message": "Опозови",
"description": "Button label"
"message": "Опозови"
},
"undoGlobal": {
"message": "Опозови (свеобухватно)",
"description": "CSS-beautify global Undo button label"
"message": "Опозови (свеобухватно)"
},
"updateAllCheckSucceededNoUpdate": {
"message": "Сви стилови су ажурирани.",
"description": "Text that displays when an update all check completed and no updates are available"
"message": "Сви стилови су ажурирани."
},
"updateCheckFailBadResponseCode": {
"message": "Ажурирање није успело: сервер је одговорио кодом $code$.",
"description": "Text that displays when an update check failed because the response code indicates an error",
"placeholders": {
"code": {
"content": "$1"
@ -390,23 +297,18 @@
}
},
"updateCheckFailServerUnreachable": {
"message": "Ажурирање није успело: сервер није доступан.",
"description": "Text that displays when an update check failed because the update server is unreachable"
"message": "Ажурирање није успело: сервер није доступан."
},
"updateCheckSucceededNoUpdate": {
"message": "Стил је ажуриран.",
"description": "Text that displays when an update check completed and no update is available"
"message": "Стил је ажуриран."
},
"updateCompleted": {
"message": "Ажурирање је комплетирано.",
"description": "Text that displays when an update completed"
"message": "Ажурирање је комплетирано."
},
"writeStyleFor": {
"message": "Упиши стил за:",
"description": "Label for toolbar pop-up that precedes the links to write a new style"
"message": "Упиши стил за:"
},
"writeStyleForURL": {
"message": "ову УРЛ адресу",
"description": "Text for link in toolbar pop-up to write a new style for the current URL"
"message": "ову УРЛ адресу"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,12 @@
{
"addStyleLabel": {
"message": "క్రొత్త స్టైల్ వ్రాయండి",
"description": "Label for the button to go to the add style page"
"message": "క్రొత్త స్టైల్ వ్రాయండి"
},
"appliesAdd": {
"message": "చేర్చు",
"description": "Label for the button to add an 'applies' entry"
"message": "చేర్చు"
},
"appliesDisplay": {
"message": "వేటికి వర్తిస్తుంది; $applies$",
"description": "Text on the manage screen to describe what the style applies to",
"placeholders": {
"applies": {
"content": "$1"
@ -17,51 +14,39 @@
}
},
"appliesDisplayTruncatedSuffix": {
"message": "ఇంకా మరిన్ని",
"description": "Text added to appliesDisplay when there are more sites for the style than are displayed"
"message": "ఇంకా మరిన్ని"
},
"appliesRemove": {
"message": "తొలగించు",
"description": "Label for the button to remove an 'applies' entry"
"message": "తొలగించు"
},
"appliesToEverything": {
"message": "అన్నిటికీ",
"description": "Text displayed for styles that apply to all sites"
"message": "అన్నిటికీ"
},
"deleteStyleConfirm": {
"message": "మీరు నజంగానే ఈ శైలిని తొలగించాలనుకుంటున్నారా?",
"description": "Confirmation before deleting a style"
"message": "మీరు నజంగానే ఈ శైలిని తొలగించాలనుకుంటున్నారా?"
},
"deleteStyleLabel": {
"message": "తొలగించు",
"description": "Label for the button to delete a style"
"message": "తొలగించు"
},
"disableStyleLabel": {
"message": "అచేతనించు",
"description": "Label for the button to disable a style"
"message": "అచేతనించు"
},
"editStyleLabel": {
"message": "మార్చు",
"description": "Label for the button to go to the edit style page"
"message": "మార్చు"
},
"enableStyleLabel": {
"message": "చేతనించు",
"description": "Label for the button to enable a style"
"message": "చేతనించు"
},
"helpAlt": {
"message": "సహాయం",
"description": "Alternate text for help buttons"
"message": "సహాయం"
},
"manageHeading": {
"message": "స్థాపిత శైలులు",
"description": "Heading for the manage page"
"message": "స్థాపిత శైలులు"
},
"manageTitle": {
"message": "స్టైలిష్",
"description": "Title for the manage page"
"message": "స్టైలిష్"
},
"styleSaveLabel": {
"message": "భద్రపరచు",
"description": "Label for save button for style editing"
"message": "భద్రపరచు"
}
}
}

File diff suppressed because it is too large Load Diff

234
_locales/uk/messages.json Normal file
View File

@ -0,0 +1,234 @@
{
"InaccessibleFileHint": {
"message": "Stylus не може отримати доступ до деяких типів файлів (наприклад, файли PDF і JSON)."
},
"addStyleLabel": {
"message": "Написати новий стиль"
},
"addStyleTitle": {
"message": "Додати стиль"
},
"alphaChannel": {
"message": "Прозорість"
},
"appliesAdd": {
"message": "Додати"
},
"appliesDisplay": {
"message": "Застосувати до $applies$",
"placeholders": {
"applies": {
"content": "$1"
}
}
},
"appliesDisplayTruncatedSuffix": {
"message": "та інші"
},
"appliesDomainOption": {
"message": "URL в домені"
},
"appliesHelp": {
"message": "Щоб вказати, до яких URL відноситься код в цьому розділі, скористайтеся параметром \"Застосувати до\"."
},
"appliesLabel": {
"message": "Застосувати до"
},
"appliesLineWidgetLabel": {
"message": "Показати цільові сайти секцій"
},
"appliesRemove": {
"message": "Видалити"
},
"appliesRemoveError": {
"message": "Не можна видалити останній елемент"
},
"appliesSpecify": {
"message": "Вказати"
},
"appliesToEverything": {
"message": "Все"
},
"appliesUrlPrefixOption": {
"message": "URL, що починаються з "
},
"applyAllUpdates": {
"message": "Застосувати всі оновлення"
},
"author": {
"message": "Автор"
},
"backupButtons": {
"message": "Резервне копіювання"
},
"backupMessage": {
"message": "Виберіть файл або перетягніть на цю сторінку."
},
"bckpInstStyles": {
"message": "Експорт стилів"
},
"checkingForUpdate": {
"message": "Перевірка ... "
},
"clickToUninstall": {
"message": "Натисніть, щоб видалити "
},
"cm_selectByTokensTooltip": {
"message": "Приклади токенов: .foo-бар-2 #aabbcc 0,32 !important\nЯкщо вимкнено: вибираються слова, розділені розділовими знаками."
},
"confirmCancel": {
"message": "Скасувати "
},
"confirmDelete": {
"message": "Видалити "
},
"confirmNo": {
"message": "Ні"
},
"confirmSave": {
"message": "Зберегти"
},
"confirmStop": {
"message": "Зупинити"
},
"confirmYes": {
"message": "Так"
},
"deleteStyleLabel": {
"message": "Видалити"
},
"disableStyleLabel": {
"message": "Вимкнути"
},
"findStyles": {
"message": "Знайти стилі"
},
"findStylesForSite": {
"message": "Знайти більше стилів для цього сайту"
},
"findStylesInline": {
"message": "і показати тут"
},
"findStylesInlineTooltip": {
"message": "Показувати знайдені стилі в цьому вікні."
},
"genericAdd": {
"message": "Додати"
},
"genericDescription": {
"message": "Опис"
},
"genericDisabledLabel": {
"message": "Відключено"
},
"genericEnabledLabel": {
"message": "Увімкнено"
},
"genericError": {
"message": "Помилка"
},
"genericHistoryLabel": {
"message": "Історія"
},
"genericNext": {
"message": "Наступний "
},
"genericPrevious": {
"message": "Попередній"
},
"genericResetLabel": {
"message": "Скинути"
},
"genericSavedMessage": {
"message": "Збережено"
},
"genericTitle": {
"message": "Ім'я"
},
"genericUnknown": {
"message": "Невідомо"
},
"gettingStyles": {
"message": "Отримання всіх стилів ..."
},
"helpAlt": {
"message": "Довідка"
},
"helpKeyMapCommand": {
"message": "Введіть ім'я команди"
},
"helpKeyMapHotkey": {
"message": "Натисніть клавішу"
},
"hostDisabled": {
"message": "Цей хост вимкнено через помилку в поточній версії браузера, що використовується"
},
"importLabel": {
"message": "Імпорт"
},
"importReplaceLabel": {
"message": "Замінити стиль"
},
"importReportLegendIdentical": {
"message": "ідентичні пропущені"
},
"importReportLegendInvalid": {
"message": "недісних пропущено"
},
"importReportTitle": {
"message": "Імпорт стилів закінчено"
},
"manageFavicons": {
"message": "Піктограми для цільових сайтів"
},
"manageNewUI": {
"message": "Новий інтерфейс"
},
"meta_unknownJSONLiteral": {
"message": "Невірний JSON: $literal$не є дійсним літералом JSON",
"placeholders": {
"literal": {
"content": "$1"
}
}
},
"openManage": {
"message": "Менеджер"
},
"optionsReset": {
"message": "Скидання налаштувань до значень за замовчуванням"
},
"optionsResetButton": {
"message": "Скинути параметри"
},
"optionsSubheading": {
"message": "Додатково"
},
"optionsSyncLogin": {
"message": "Логін"
},
"retrieveBckp": {
"message": "Імпорт стилів"
},
"sectionCode": {
"message": "Код"
},
"sections": {
"message": "Розділи"
},
"styleBeautify": {
"message": "Облагородити"
},
"styleCancelEditLabel": {
"message": "Всі стилі"
},
"syncErrorRelogin": {
"message": "Помилка синхронізації.\nСпробуйте повторно ввійти в налаштування Stylus:\nнатисніть спочатку «від’єднати», а потім «підключити». "
},
"toggleStyle": {
"message": "Включити/виключити стиль"
},
"zipStyles": {
"message": "Запаковування стилів ... "
}
}

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,182 +1,26 @@
/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */
/* global createWorkerApi */// worker-util.js
'use strict';
importScripts('/js/worker-util.js');
const {loadScript, createAPI} = workerUtil;
/** @namespace BackgroundWorker */
createWorkerApi({
createAPI({
parseMozFormat(arg) {
loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js');
return parseMozFormat(arg);
},
compileUsercss,
parseUsercssMeta(text, indexOffset = 0) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.parse(text, indexOffset);
async compileUsercss(...args) {
require(['/js/usercss-compiler']); /* global compileUsercss */
return compileUsercss(...args);
},
nullifyInvalidVars(vars) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
require(['/js/meta-parser']); /* global metaParser */
return metaParser.nullifyInvalidVars(vars);
}
},
parseMozFormat(...args) {
require(['/js/moz-parser']); /* global extractSections */
return extractSections(...args);
},
parseUsercssMeta(text) {
require(['/js/meta-parser']);
return metaParser.parse(text);
},
});
function compileUsercss(preprocessor, code, vars) {
loadScript(
'/vendor-overwrites/csslint/parserlib.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/moz-parser.js'
);
const builder = getUsercssCompiler(preprocessor);
vars = simpleVars(vars);
return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code)
.then(code => parseMozFormat({code, emptyDocument: preprocessor === 'stylus'}))
.then(({sections, errors}) => {
if (builder.postprocess) {
builder.postprocess(sections, vars);
}
return {sections, errors};
});
function simpleVars(vars) {
if (!vars) {
return {};
}
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
// need to test each va's default value.
return Object.keys(vars).reduce((output, key) => {
const va = vars[key];
output[key] = Object.assign({}, va, {
value: va.value === null || va.value === undefined ?
getVarValue(va, 'default') : getVarValue(va, 'value')
});
return output;
}, {});
}
function getVarValue(va, prop) {
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') {
// TODO: handle customized image
return va.options.find(o => o.name === va[prop]).value;
}
if ((va.type === 'number' || va.type === 'range') && va.units) {
return va[prop] + va.units;
}
return va[prop];
}
}
function getUsercssCompiler(preprocessor) {
const BUILDER = {
default: {
postprocess(sections, vars) {
loadScript('/js/sections-util.js');
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
if (!varDef) return;
varDef = ':root {\n' + varDef + '}\n';
for (const section of sections) {
if (!styleCodeEmpty(section.code)) {
section.code = varDef + section.code;
}
}
}
},
stylus: {
preprocess(source, vars) {
loadScript('/vendor/stylus-lang-bundle/stylus.min.js');
return new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
if (!Error.captureStackTrace) Error.captureStackTrace = () => {};
self.stylus(varDef + source).render((err, output) => {
if (err) {
reject(err);
} else {
resolve(output);
}
});
});
}
},
less: {
preprocess(source, vars) {
if (!self.less) {
self.less = {
logLevel: 0,
useFileCache: false,
};
}
loadScript('/vendor/less-bundle/less.min.js');
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
return self.less.render(varDefs + source)
.then(({css}) => css);
}
},
uso: {
preprocess(source, vars) {
loadScript('/vendor-overwrites/colorpicker/colorconverter.js');
const pool = new Map();
return Promise.resolve(doReplace(source));
function getValue(name, rgbName) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), name);
}
return null;
}
const {type, value} = vars[name];
switch (type) {
case 'color': {
let color = pool.get(rgbName || name);
if (color == null) {
color = colorConverter.parse(value);
if (color) {
if (color.type === 'hsl') {
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
}
const {r, g, b} = color;
color = rgbName
? `${r}, ${g}, ${b}`
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
// the pool stores `false` for bad colors to differentiate from a yet unknown color
pool.set(rgbName || name, color || false);
}
return color || null;
}
case 'dropdown':
case 'select': // prevent infinite recursion
pool.set(name, '');
return doReplace(value);
}
return value;
}
function doReplace(text) {
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
if (!pool.has(name)) {
const value = getValue(name);
pool.set(name, value === null ? match : value);
}
return pool.get(name);
});
}
}
}
};
if (preprocessor) {
if (!BUILDER[preprocessor]) {
throw new Error('unknwon preprocessor');
}
return BUILDER[preprocessor];
}
return BUILDER.default;
}

View File

@ -1,69 +1,119 @@
/* global download prefs openURL FIREFOX CHROME
URLS ignoreChromeError chromeLocal semverCompare
styleManager msg navigatorUtil workerUtil contentScripts sync
findExistingTab activateTab isTabReplaceable getActiveTab colorScheme */
/* global API msg */// msg.js
/* global addAPI bgReady */// common.js
/* global createWorker */// worker-util.js
/* global prefs */
/* global styleMan */
/* global syncMan */
/* global updateMan */
/* global usercssMan */
/* global uswApi */
/* global
FIREFOX
URLS
activateTab
download
findExistingTab
openURL
*/ // toolbox.js
/* global colorScheme */ // color-scheme.js
'use strict';
// eslint-disable-next-line no-var
var backgroundWorker = workerUtil.createWorker({
url: '/background/background-worker.js'
});
//#region API
// eslint-disable-next-line no-var
var browserCommands, contextMenus;
addAPI(/** @namespace API */ {
// *************************************************************************
// browser commands
browserCommands = {
openManage,
openOptions: () => openManage({options: true}),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
/** 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,
sync: syncMan,
updater: updateMan,
usercss: usercssMan,
usw: uswApi,
colorScheme,
/** @type {BackgroundWorker} */
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 || {});
},
reload: () => chrome.runtime.reload(),
};
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
deleteStyle: styleManager.deleteStyle,
editSave: styleManager.editSave,
findStyle: styleManager.findStyle,
getAllStyles: styleManager.getAllStyles, // used by importer
getSectionsByUrl: styleManager.getSectionsByUrl,
getStyle: styleManager.get,
getStylesByUrl: styleManager.getStylesByUrl,
importStyle: styleManager.importStyle,
importManyStyles: styleManager.importMany,
installStyle: styleManager.installStyle,
styleExists: styleManager.styleExists,
toggleStyle: styleManager.toggleStyle,
addInclusion: styleManager.addInclusion,
removeInclusion: styleManager.removeInclusion,
addExclusion: styleManager.addExclusion,
removeExclusion: styleManager.removeExclusion,
/** @returns {string} */
getTabUrlPrefix() {
const {url} = this.sender.tab;
if (url.startsWith(URLS.ownOrigin)) {
return 'stylus';
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];
},
/**
* Opens the editor or activates an existing tab
* @param {{
id?: number
domain?: string
'url-prefix'?: string
}} params
* @returns {Promise<chrome.tabs.Tab>}
*/
async openEditor(params) {
const u = new URL(chrome.runtime.getURL('edit.html'));
u.search = new URLSearchParams(params);
const wnd = prefs.get('openEditInWindow');
const wndPos = wnd && prefs.get('windowPosition');
const wndBase = wnd && prefs.get('openEditInWindow.popup') ? {type: 'popup'} : {};
const ffBug = wnd && FIREFOX; // https://bugzil.la/1271047
const tab = await openURL({
url: `${u}`,
currentWindow: null,
newWindow: wnd && Object.assign(wndBase, !ffBug && wndPos),
});
if (ffBug) await browser.windows.update(tab.windowId, wndPos);
return tab;
},
/** @returns {Promise<chrome.tabs.Tab>} */
async openManage({options = false, search, searchMode} = {}) {
let url = chrome.runtime.getURL('manage.html');
if (search) {
url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`;
}
return url.match(/^([\w-]+:\/+[^/#]+)/)[1];
if (options) {
url += '#stylus-options';
}
const 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;
}
return openURL({url, ignoreExisting: true}).then(activateTab); // activateTab unminimizes the window
},
download(msg) {
delete msg.method;
return download(msg.url, msg);
},
parseCss({code}) {
return backgroundWorker.parseMozFormat({code});
},
getPrefs: prefs.getAll,
openEditor,
/* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent when the tab is ready,
which is needed in the popup, otherwise another extension could force the tab to open in foreground
thus auto-closing the popup (in Chrome at least) and preventing the sendMessage code from running */
/**
* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent
* when the tab is ready, which is needed in the popup, otherwise another
* extension could force the tab to open in foreground thus auto-closing the
* popup (in Chrome at least) and preventing the sendMessage code from running
* @returns {Promise<chrome.tabs.Tab>}
*/
async openURL(opts) {
const tab = await openURL(opts);
if (opts.message) {
@ -84,259 +134,62 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, {
}
},
optionsCustomizeHotkeys() {
return browserCommands.openOptions()
.then(() => new Promise(resolve => setTimeout(resolve, 500)))
.then(() => msg.broadcastExtension({method: 'optionsCustomizeHotkeys'}));
prefs: {
getValues: () => prefs.__values, // will be deepCopy'd by apiHandler
set: prefs.set,
},
updateSystemPreferDark: colorScheme.updateSystemPreferDark,
syncStart: sync.start,
syncStop: sync.stop,
syncNow: sync.syncNow,
getSyncStatus: sync.getStatus,
syncLogin: sync.login,
openManage
});
// *************************************************************************
// register all listeners
msg.on(onRuntimeMessage);
//#endregion
//#region Events
// tell apply.js to refresh styles for non-committed navigation
navigatorUtil.onUrlChange(({tabId, frameId}, type) => {
if (type !== 'committed') {
msg.sendTab(tabId, {method: 'urlChanged'}, {frameId})
.catch(msg.ignoreError);
}
});
if (FIREFOX) {
// FF misses some about:blank iframes so we inject our content script explicitly
navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, {
url: [
{urlEquals: 'about:blank'},
]
});
}
if (chrome.contextMenus) {
chrome.contextMenus.onClicked.addListener((info, tab) =>
contextMenus[info.menuItemId].click(info, tab));
}
if (chrome.commands) {
// Not available in Firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1240350
chrome.commands.onCommand.addListener(command => browserCommands[command]());
}
// *************************************************************************
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
// save install type: "admin", "development", "normal", "sideload" or "other"
// "normal" = addon installed from webstore
chrome.management.getSelf(info => {
localStorage.installType = info.installType;
if (reason === 'install' && info.installType === 'development' && chrome.contextMenus) {
createContextMenus(['reload']);
}
});
if (reason !== 'update') return;
// translations may change
localStorage.L10N = JSON.stringify({
browserUIlanguage: chrome.i18n.getUILanguage(),
});
// themes may change
delete localStorage.codeMirrorThemes;
// inline search cache for USO is not needed anymore, TODO: remove this by the middle of 2021
if (semverCompare(previousVersion, '1.5.13') <= 0) {
setTimeout(async () => {
const del = Object.keys(await chromeLocal.get())
.filter(key => key.startsWith('usoSearchCache'));
if (del.length) chromeLocal.remove(del);
}, 15e3);
}
});
// *************************************************************************
// context menus
contextMenus = {
'show-badge': {
title: 'menuShowBadge',
click: info => prefs.set(info.menuItemId, info.checked),
const browserCommands = {
openManage: () => API.openManage(),
openOptions: () => API.openManage({options: true}),
reload: () => chrome.runtime.reload(),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
},
'disableAll': {
title: 'disableAllStyles',
click: browserCommands.styleDisableAll,
},
'open-manager': {
title: 'openStylesManager',
click: browserCommands.openManage,
},
'open-options': {
title: 'openOptions',
click: browserCommands.openOptions,
},
'reload': {
presentIf: () => localStorage.installType === 'development',
title: 'reload',
click: browserCommands.reload,
},
'editor.contextDelete': {
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
title: 'editDeleteText',
type: 'normal',
contexts: ['editable'],
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
click: (info, tab) => {
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension');
},
}
};
function createContextMenus(ids) {
for (const id of ids) {
let item = contextMenus[id];
if (item.presentIf && !item.presentIf()) {
continue;
if (chrome.commands) {
chrome.commands.onCommand.addListener(id => browserCommands[id]());
}
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason === 'update') {
const [a, b, c] = (previousVersion || '').split('.');
if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
require(['/background/remove-unused-storage']);
}
item = Object.assign({id}, item);
delete item.presentIf;
item.title = chrome.i18n.getMessage(item.title);
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
item.type = 'checkbox';
item.checked = prefs.get(id);
}
if (!item.contexts) {
item.contexts = ['browser_action'];
}
delete item.click;
chrome.contextMenus.create(item, ignoreChromeError);
}
}
});
if (chrome.contextMenus) {
// circumvent the bug with disabling check marks in Chrome 62-64
const toggleCheckmark = CHROME >= 62 && CHROME <= 64 ?
(id => chrome.contextMenus.remove(id, () => createContextMenus([id]) + ignoreChromeError())) :
((id, checked) => chrome.contextMenus.update(id, {checked}, ignoreChromeError));
const togglePresence = (id, checked) => {
if (checked) {
createContextMenus([id]);
} else {
chrome.contextMenus.remove(id, ignoreChromeError);
}
};
const keys = Object.keys(contextMenus);
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark);
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf), togglePresence);
createContextMenus(keys);
}
// reinject content scripts when the extension is reloaded/updated. Firefox
// would handle this automatically.
if (!FIREFOX) {
setTimeout(contentScripts.injectToAllTabs, 0);
}
// register hotkeys
if (FIREFOX && browser.commands && browser.commands.update) {
const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.'));
prefs.subscribe(hotkeyPrefs, (name, value) => {
try {
name = name.split('.')[1];
if (value.trim()) {
browser.commands.update({name, shortcut: value});
} else {
browser.commands.reset(name);
}
} catch (e) {}
});
}
msg.broadcastTab({method: 'backgroundReady'});
function webNavIframeHelperFF({tabId, frameId}) {
if (!frameId) return;
msg.sendTab(tabId, {method: 'ping'}, {frameId})
.catch(() => false)
.then(pong => {
if (pong) return;
// insert apply.js to iframe
const files = chrome.runtime.getManifest().content_scripts[0].js;
for (const file of files) {
chrome.tabs.executeScript(tabId, {
frameId,
file,
matchAboutBlank: true,
}, ignoreChromeError);
}
});
}
function onRuntimeMessage(msg, sender) {
if (msg.method !== 'invokeAPI') {
return;
msg.on((msg, sender) => {
if (msg.method === 'invokeAPI') {
let res = msg.path.reduce((res, name) => res && res[name], API);
if (!res) throw new Error(`Unknown API.${msg.path.join('.')}`);
res = res.apply({msg, sender}, msg.args);
return res === undefined ? null : res;
}
const fn = window.API_METHODS[msg.name];
if (!fn) {
throw new Error(`unknown API: ${msg.name}`);
}
const context = {msg, sender};
return fn.apply(context, msg.args);
}
});
function openEditor(params) {
/* Open the editor. Activate if it is already opened
//#endregion
params: {
id?: Number,
domain?: String,
'url-prefix'?: String
}
*/
const u = new URL(chrome.runtime.getURL('edit.html'));
u.search = new URLSearchParams(params);
return openURL({
url: `${u}`,
currentWindow: null,
newWindow: prefs.get('openEditInWindow') && Object.assign({},
prefs.get('openEditInWindow.popup') && {type: 'popup'},
prefs.get('windowPosition')),
});
}
function openManage({options = false, search} = {}) {
let url = chrome.runtime.getURL('manage.html');
if (search) {
url += `?search=${encodeURIComponent(search)}`;
}
if (options) {
url += '#stylus-options';
}
return findExistingTab({
url,
currentWindow: null,
ignoreHash: true,
ignoreSearch: true
})
.then(tab => {
if (tab) {
return Promise.all([
activateTab(tab),
(tab.pendingUrl || tab.url) !== url && msg.sendTab(tab.id, {method: 'pushState', url})
.catch(console.error)
]);
}
return getActiveTab().then(tab => {
if (isTabReplaceable(tab, url)) {
return activateTab(tab, {url});
}
return browser.tabs.create({url});
});
});
}
Promise.all([
bgReady.styles,
/* These are loaded conditionally.
Each item uses `require` individually so IDE can jump to the source and track usage. */
FIREFOX &&
require(['/background/style-via-api']),
FIREFOX && ((browser.commands || {}).update) &&
require(['/background/browser-cmd-hotkeys']),
!FIREFOX &&
require(['/background/content-scripts']),
chrome.contextMenus &&
require(['/background/context-menus']),
]).then(() => {
bgReady._resolveAll();
msg.isBgReady = true;
msg.broadcast({method: 'backgroundReady'});
});

View File

@ -0,0 +1,22 @@
/* global prefs */
'use strict';
/*
Registers hotkeys in FF
*/
(() => {
const hotkeyPrefs = prefs.knownKeys.filter(k => k.startsWith('hotkey.'));
prefs.subscribe(hotkeyPrefs, updateHotkey, {runNow: true});
async function updateHotkey(name, value) {
try {
name = name.split('.')[1];
if (value.trim()) {
await browser.commands.update({name, shortcut: value});
} else {
await browser.commands.reset(name);
}
} catch (e) {}
}
})();

View File

@ -36,7 +36,7 @@ const colorScheme = (() => {
}
chrome.alarms.create(key, {
when: date.getTime(),
periodInMinutes: 24 * 60
periodInMinutes: 24 * 60,
});
}

31
background/common.js Normal file
View File

@ -0,0 +1,31 @@
/* global API */// msg.js
'use strict';
/**
* Common stuff that's loaded first so it's immediately available to all background scripts
*/
/* exported
addAPI
bgReady
compareRevision
*/
const bgReady = {};
bgReady.styles = new Promise(r => (bgReady._resolveStyles = r));
bgReady.all = new Promise(r => (bgReady._resolveAll = r));
function addAPI(methods) {
for (const [key, val] of Object.entries(methods)) {
const old = API[key];
if (old && Object.prototype.toString.call(old) === '[object Object]') {
Object.assign(old, val);
} else {
API[key] = val;
}
}
}
function compareRevision(rev1, rev2) {
return rev1 - rev2;
}

View File

@ -1,15 +1,21 @@
/* global msg ignoreChromeError URLS */
/* exported contentScripts */
/* global bgReady */// common.js
/* global msg */
/* global URLS ignoreChromeError */// toolbox.js
'use strict';
const contentScripts = (() => {
/*
Reinject content scripts when the extension is reloaded/updated.
Not used in Firefox as it reinjects automatically.
*/
bgReady.all.then(() => {
const NTP = 'chrome://newtab/';
const ALL_URLS = '<all_urls>';
const SCRIPTS = chrome.runtime.getManifest().content_scripts;
// expand * as .*?
const wildcardAsRegExp = (s, flags) => new RegExp(
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
.replace(/\*/g, '.*?'), flags);
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
.replace(/\*/g, '.*?'), flags);
for (const cs of SCRIPTS) {
cs.matches = cs.matches.map(m => (
m === ALL_URLS ? m : wildcardAsRegExp(m)
@ -18,21 +24,7 @@ const contentScripts = (() => {
const busyTabs = new Set();
let busyTabsTimer;
// expose version on greasyfork/sleazyfork 1) info page and 2) code page
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
chrome.tabs.executeScript(tabId, {
file: '/content/install-hook-greasyfork.js',
runAt: 'document_start',
});
}, {
url: [
{hostEquals: 'greasyfork.org', urlMatches},
{hostEquals: 'sleazyfork.org', urlMatches},
]
});
return {injectToTab, injectToAllTabs};
setTimeout(injectToAllTabs);
function injectToTab({url, tabId, frameId = null}) {
for (const script of SCRIPTS) {
@ -57,7 +49,7 @@ const contentScripts = (() => {
const options = {
runAt: script.run_at,
allFrames: script.all_frames,
matchAboutBlank: script.match_about_blank
matchAboutBlank: script.match_about_blank,
};
if (frameId !== null) {
options.allFrames = false;
@ -80,7 +72,7 @@ const contentScripts = (() => {
} else {
injectToTab({
url: tab.pendingUrl || tab.url,
tabId: tab.id
tabId: tab.id,
});
}
}
@ -122,4 +114,4 @@ const contentScripts = (() => {
function onBusyTabRemoved(tabId) {
trackBusyTab(tabId, false);
}
})();
});

101
background/context-menus.js Normal file
View File

@ -0,0 +1,101 @@
/* global browserCommands */// background.js
/* global msg */
/* global prefs */
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
'use strict';
(() => {
const contextMenus = {
'show-badge': {
title: 'menuShowBadge',
click: info => prefs.set(info.menuItemId, info.checked),
},
'disableAll': {
title: 'disableAllStyles',
click: browserCommands.styleDisableAll,
},
'open-manager': {
title: 'openStylesManager',
click: browserCommands.openManage,
},
'open-options': {
title: 'openOptions',
click: browserCommands.openOptions,
},
'reload': {
presentIf: async () => (await browser.management.getSelf()).installType === 'development',
title: 'reload',
click: browserCommands.reload,
},
'editor.contextDelete': {
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
title: 'editDeleteText',
type: 'normal',
contexts: ['editable'],
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
click: (info, tab) => {
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension')
.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);
chrome.contextMenus.onClicked.addListener((info, tab) =>
contextMenus[info.menuItemId].click(info, tab));
async function createContextMenus(ids) {
for (const id of ids) {
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);
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
item.type = 'checkbox';
item.checked = prefs.get(id);
}
if (!item.contexts) {
item.contexts = ['browser_action'];
}
delete item.click;
chrome.contextMenus.create(item, ignoreChromeError);
}
}
function toggleCheckmark(id, checked) {
chrome.contextMenus.update(id, {checked}, ignoreChromeError);
}
/** Circumvents the bug with disabling check marks in Chrome 62-64 */
async function toggleCheckmarkBugged(id) {
await browser.contextMenus.remove(id).catch(ignoreChromeError);
createContextMenus([id]);
}
function togglePresence(id, checked) {
if (checked) {
createContextMenus([id]);
} else {
chrome.contextMenus.remove(id, ignoreChromeError);
}
}
})();

View File

@ -1,67 +1,66 @@
/* global chromeLocal */
/* exported createChromeStorageDB */
/* global chromeLocal */// storage-util.js
'use strict';
/* exported createChromeStorageDB */
function createChromeStorageDB() {
let INC;
const PREFIX = 'style-';
const METHODS = {
delete(id) {
return chromeLocal.remove(PREFIX + id);
},
// FIXME: we don't use this method at all. Should we remove this?
get: id => chromeLocal.getValue(PREFIX + id),
put: obj =>
// FIXME: should we clone the object?
Promise.resolve(!obj.id && prepareInc().then(() => Object.assign(obj, {id: INC++})))
.then(() => chromeLocal.setValue(PREFIX + obj.id, obj))
.then(() => obj.id),
putMany: items => prepareInc()
.then(() =>
chromeLocal.set(items.reduce((data, item) => {
if (!item.id) item.id = INC++;
data[PREFIX + item.id] = item;
return data;
}, {})))
.then(() => items.map(i => i.id)),
delete: id => chromeLocal.remove(PREFIX + id),
getAll: () => chromeLocal.get()
.then(result => {
const output = [];
for (const key in result) {
if (key.startsWith(PREFIX) && Number(key.slice(PREFIX.length))) {
output.push(result[key]);
}
get(id) {
return chromeLocal.getValue(PREFIX + id);
},
async getAll() {
const all = await chromeLocal.get();
if (!INC) prepareInc(all);
return Object.entries(all)
.map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val)
.filter(Boolean);
},
async put(item) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
await chromeLocal.setValue(PREFIX + item.id, item);
return item.id;
},
async putMany(items) {
const data = {};
for (const item of items) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
return output;
})
data[PREFIX + item.id] = item;
}
await chromeLocal.set(data);
return items.map(_ => _.id);
},
};
return {exec};
function exec(method, ...args) {
if (METHODS[method]) {
return METHODS[method](...args)
.then(result => {
if (method === 'putMany' && result.map) {
return result.map(r => ({target: {result: r}}));
}
return {target: {result}};
});
}
return Promise.reject(new Error(`unknown DB method ${method}`));
}
function prepareInc() {
if (INC) return Promise.resolve();
return chromeLocal.get().then(result => {
INC = 1;
for (const key in result) {
if (key.startsWith(PREFIX)) {
const id = Number(key.slice(PREFIX.length));
if (id >= INC) {
INC = id + 1;
}
async function prepareInc(data) {
INC = 1;
for (const key in data || await chromeLocal.get()) {
if (key.startsWith(PREFIX)) {
const id = Number(key.slice(PREFIX.length));
if (id >= INC) {
INC = id + 1;
}
}
});
}
}
return function dbExecChromeStorage(method, ...args) {
return METHODS[method](...args);
};
}

View File

@ -1,14 +1,15 @@
/* global chromeLocal workerUtil createChromeStorageDB */
/* exported db */
/*
Initialize a database. There are some problems using IndexedDB in Firefox:
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
Some of them are fixed in FF59:
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
*/
/* global chromeLocal */// storage-util.js
/* global cloneError */// worker-util.js
'use strict';
/*
Initialize a database. There are some problems using IndexedDB in Firefox:
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
Some of them are fixed in FF59:
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
*/
/* exported db */
const db = (() => {
const DATABASE = 'stylish';
const STORE = 'styles';
@ -24,52 +25,34 @@ const db = (() => {
async function tryUsingIndexedDB() {
// we use chrome.storage.local fallback if IndexedDB doesn't save data,
// which, once detected on the first run, is remembered in chrome.storage.local
// for reliablility and in localStorage for fast synchronous access
// (FF may block localStorage depending on its privacy options)
// note that it may throw when accessing the variable
// https://github.com/openstyles/stylus/issues/615
// note that accessing indexedDB may throw, https://github.com/openstyles/stylus/issues/615
if (typeof indexedDB === 'undefined') {
throw new Error('indexedDB is undefined');
}
switch (await getFallback()) {
switch (await chromeLocal.getValue(FALLBACK)) {
case true: throw null;
case false: break;
default: await testDB();
}
return useIndexedDB();
}
async function getFallback() {
return localStorage[FALLBACK] === 'true' ? true :
localStorage[FALLBACK] === 'false' ? false :
chromeLocal.getValue(FALLBACK);
chromeLocal.setValue(FALLBACK, false);
return dbExecIndexedDB;
}
async function testDB() {
let e = await dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1);
// throws if result is null
e = e.target.result[0];
const id = `${performance.now()}.${Math.random()}.${Date.now()}`;
await dbExecIndexedDB('put', {id});
e = await dbExecIndexedDB('get', id);
// throws if result or id is null
await dbExecIndexedDB('delete', e.target.result.id);
const e = await dbExecIndexedDB('get', id);
await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null
}
function useChromeStorage(err) {
async function useChromeStorage(err) {
chromeLocal.setValue(FALLBACK, true);
if (err) {
chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err));
chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err));
console.warn('Failed to access indexedDB. Switched to storage API.', err);
}
localStorage[FALLBACK] = 'true';
return createChromeStorageDB().exec;
}
function useIndexedDB() {
chromeLocal.setValue(FALLBACK, false);
localStorage[FALLBACK] = 'false';
return dbExecIndexedDB;
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */
return createChromeStorageDB();
}
async function dbExecIndexedDB(method, ...args) {
@ -81,8 +64,9 @@ const db = (() => {
function storeRequest(store, method, ...args) {
return new Promise((resolve, reject) => {
/** @type {IDBRequest} */
const request = store[method](...args);
request.onsuccess = resolve;
request.onsuccess = () => resolve(request.result);
request.onerror = reject;
});
}

View File

@ -1,48 +1,41 @@
/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API_METHODS */
/* exported iconManager */
/* global API */// msg.js
/* global addAPI bgReady */// common.js
/* global prefs */
/* global tabMan */
/* global CHROME FIREFOX VIVALDI debounce ignoreChromeError */// toolbox.js
'use strict';
const iconManager = (() => {
/* exported iconMan */
const iconMan = (() => {
const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
const staleBadges = new Set();
const imageDataCache = new Map();
const badgeOvr = {color: '', text: ''};
// https://github.com/openstyles/stylus/issues/1287 Fenix can't use custom ImageData
const FIREFOX_ANDROID = FIREFOX && navigator.userAgent.includes('Android');
prefs.subscribe([
'disableAll',
'badgeDisabled',
'badgeNormal',
], () => debounce(refreshIconBadgeColor));
// https://github.com/openstyles/stylus/issues/335
let hasCanvas = FIREFOX_ANDROID ? false : loadImage(`/images/icon/${ICON_SIZES[0]}.png`)
.then(({data}) => (hasCanvas = data.some(b => b !== 255)));
prefs.subscribe([
'show-badge'
], () => debounce(refreshAllIconsBadgeText));
prefs.subscribe([
'disableAll',
'iconset',
], () => debounce(refreshAllIcons));
prefs.initializing.then(() => {
refreshIconBadgeColor();
refreshAllIconsBadgeText();
refreshAllIcons();
});
Object.assign(API_METHODS, {
/** @param {(number|string)[]} styleIds
* @param {boolean} [lazyBadge=false] preventing flicker during page load */
addAPI(/** @namespace API */ {
/**
* @param {(number|string)[]} styleIds
* @param {boolean} [lazyBadge=false] preventing flicker during page load
*/
updateIconBadge(styleIds, {lazyBadge} = {}) {
// FIXME: in some cases, we only have to redraw the badge. is it worth a optimization?
const {frameId, tab: {id: tabId}} = this.sender;
const value = styleIds.length ? styleIds.map(Number) : undefined;
tabManager.set(tabId, 'styleIds', frameId, value);
tabMan.set(tabId, 'styleIds', frameId, value);
debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0);
staleBadges.add(tabId);
if (!frameId) refreshIcon(tabId, true);
},
});
navigatorUtil.onCommitted(({tabId, frameId}) => {
if (!frameId) tabManager.set(tabId, 'styleIds', undefined);
chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
if (!frameId) tabMan.set(tabId, 'styleIds', undefined);
});
chrome.runtime.onConnect.addListener(port => {
@ -51,15 +44,54 @@ const iconManager = (() => {
}
});
bgReady.all.then(() => {
prefs.subscribe([
'disableAll',
'badgeDisabled',
'badgeNormal',
], () => debounce(refreshIconBadgeColor), {runNow: true});
prefs.subscribe([
'show-badge',
], () => debounce(refreshAllIconsBadgeText), {runNow: true});
prefs.subscribe([
'disableAll',
'iconset',
], () => debounce(refreshAllIcons), {runNow: true});
});
return {
/** Calling with no params clears the override */
overrideBadge({text = '', color = '', title = ''} = {}) {
if (badgeOvr.text === text) {
return;
}
badgeOvr.text = text;
badgeOvr.color = color;
refreshIconBadgeColor();
setBadgeText({text});
for (const tabId of tabMan.list()) {
if (text) {
setBadgeText({tabId, text});
} else {
refreshIconBadgeText(tabId);
}
}
chrome.browserAction.setTitle({
title: title && chrome.i18n.getMessage(title) || title || '',
});
},
};
function onPortDisconnected({sender}) {
if (tabManager.get(sender.tab.id, 'styleIds')) {
API_METHODS.updateIconBadge.call({sender}, [], {lazyBadge: true});
if (tabMan.get(sender.tab.id, 'styleIds')) {
API.updateIconBadge.call({sender}, [], {lazyBadge: true});
}
}
function refreshIconBadgeText(tabId) {
if (badgeOvr.text) return;
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
iconUtil.setBadgeText({tabId, text});
setBadgeText({tabId, text});
}
function getIconName(hasStyles = false) {
@ -69,17 +101,17 @@ const iconManager = (() => {
}
function refreshIcon(tabId, force = false) {
const oldIcon = tabManager.get(tabId, 'icon');
const newIcon = getIconName(tabManager.get(tabId, 'styleIds', 0));
const oldIcon = tabMan.get(tabId, 'icon');
const newIcon = getIconName(tabMan.get(tabId, 'styleIds', 0));
// (changing the icon only for the main page, frameId = 0)
if (!force && oldIcon === newIcon) {
return;
}
tabManager.set(tabId, 'icon', newIcon);
iconUtil.setIcon({
tabMan.set(tabId, 'icon', newIcon);
setIcon({
path: getIconPath(newIcon),
tabId
tabId,
});
}
@ -96,33 +128,55 @@ const iconManager = (() => {
/** @return {number | ''} */
function getStyleCount(tabId) {
const allIds = new Set();
const data = tabManager.get(tabId, 'styleIds') || {};
const data = tabMan.get(tabId, 'styleIds') || {};
Object.values(data).forEach(frameIds => frameIds.forEach(id => allIds.add(id)));
return allIds.size || '';
}
// Caches imageData for icon paths
async function loadImage(url) {
const {OffscreenCanvas} = !FIREFOX && self.createImageBitmap && self || {};
const img = OffscreenCanvas
? await createImageBitmap(await (await fetch(url)).blob())
: await new Promise((resolve, reject) =>
Object.assign(new Image(), {
src: url,
onload: e => resolve(e.target),
onerror: reject,
}));
const {width: w, height: h} = img;
const canvas = OffscreenCanvas
? new OffscreenCanvas(w, h)
: Object.assign(document.createElement('canvas'), {width: w, height: h});
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
const result = ctx.getImageData(0, 0, w, h);
imageDataCache.set(url, result);
return result;
}
function refreshGlobalIcon() {
iconUtil.setIcon({
path: getIconPath(getIconName())
setIcon({
path: getIconPath(getIconName()),
});
}
function refreshIconBadgeColor() {
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
iconUtil.setBadgeBackgroundColor({
color
setBadgeBackgroundColor({
color: badgeOvr.color ||
prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal'),
});
}
function refreshAllIcons() {
for (const tabId of tabManager.list()) {
for (const tabId of tabMan.list()) {
refreshIcon(tabId);
}
refreshGlobalIcon();
}
function refreshAllIconsBadgeText() {
for (const tabId of tabManager.list()) {
for (const tabId of tabMan.list()) {
refreshIconBadgeText(tabId);
}
}
@ -133,4 +187,40 @@ const iconManager = (() => {
}
staleBadges.clear();
}
function safeCall(method, data) {
const {browserAction = {}} = chrome;
const fn = browserAction[method];
if (fn) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
fn.call(browserAction, data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
fn.call(browserAction, data);
}
}
}
/** @param {chrome.browserAction.TabIconDetails} data */
async function setIcon(data) {
if (hasCanvas === true || await hasCanvas) {
data.imageData = {};
for (const [key, url] of Object.entries(data.path)) {
data.imageData[key] = imageDataCache.get(url) || await loadImage(url);
}
delete data.path;
}
safeCall('setIcon', data);
}
/** @param {chrome.browserAction.BadgeTextDetails} data */
function setBadgeText(data) {
safeCall('setBadgeText', data);
}
/** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */
function setBadgeBackgroundColor(data) {
safeCall('setBadgeBackgroundColor', data);
}
})();

View File

@ -1,91 +0,0 @@
/* global ignoreChromeError */
/* exported iconUtil */
'use strict';
const iconUtil = (() => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// https://github.com/openstyles/stylus/issues/335
let noCanvas;
const imageDataCache = new Map();
// test if canvas is usable
const canvasReady = loadImage('/images/icon/16.png')
.then(imageData => {
noCanvas = imageData.data.every(b => b === 255);
});
return extendNative({
/*
Cache imageData for paths
*/
setIcon,
setBadgeText
});
function loadImage(url) {
let result = imageDataCache.get(url);
if (!result) {
result = new Promise((resolve, reject) => {
const img = new Image();
img.src = url;
img.onload = () => {
const w = canvas.width = img.width;
const h = canvas.height = img.height;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
resolve(ctx.getImageData(0, 0, w, h));
};
img.onerror = reject;
});
imageDataCache.set(url, result);
}
return result;
}
function setIcon(data) {
canvasReady.then(() => {
if (noCanvas) {
chrome.browserAction.setIcon(data, ignoreChromeError);
return;
}
const pending = [];
data.imageData = {};
for (const [key, url] of Object.entries(data.path)) {
pending.push(loadImage(url)
.then(imageData => {
data.imageData[key] = imageData;
}));
}
Promise.all(pending).then(() => {
delete data.path;
chrome.browserAction.setIcon(data, ignoreChromeError);
});
});
}
function setBadgeText(data) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
chrome.browserAction.setBadgeText(data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
chrome.browserAction.setBadgeText(data);
}
}
function extendNative(target) {
return new Proxy(target, {
get: (target, prop) => {
// FIXME: do we really need this?
if (!chrome.browserAction ||
!['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
return () => {};
}
if (target[prop]) {
return target[prop];
}
return chrome.browserAction[prop].bind(chrome.browserAction);
}
});
}
})();

View File

@ -0,0 +1,103 @@
/* global CHROME FIREFOX URLS deepEqual ignoreChromeError */// toolbox.js
/* global bgReady */// common.js
/* global msg */
'use strict';
/* exported navMan */
const navMan = (() => {
const listeners = new Set();
let prevData = {};
chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed'));
chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history'));
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash'));
return {
/** @param {function(data: Object, type: ('committed'|'history'|'hash'))} fn */
onUrlChange(fn) {
listeners.add(fn);
},
};
/** @this {string} type */
async function onNavigation(data) {
if (CHROME && data.timeStamp === prevData.timeStamp && deepEqual(data, prevData)) {
return; // Chrome bug: listener is called twice with identical data
}
prevData = data;
if (CHROME &&
URLS.chromeProtectsNTP &&
data.url.startsWith('https://www.google.') &&
data.url.includes('/_/chrome/newtab?')) {
// Modern Chrome switched to WebUI NTP so this is obsolete, but there may be exceptions
// TODO: investigate, and maybe use a separate listener for CHROME <= ver
const tab = await browser.tabs.get(data.tabId);
const url = tab.pendingUrl || tab.url;
if (url === 'chrome://newtab/') {
data.url = url;
}
}
listeners.forEach(fn => fn(data, this));
}
/** @this {string} type */
function onFakeNavigation(data) {
const {url, frameId} = data;
onNavigation.call(this, data);
msg.sendTab(data.tabId, {method: 'urlChanged', url}, {frameId})
.catch(msg.ignoreError);
}
})();
bgReady.all.then(() => {
/*
* Expose style version on greasyfork/sleazyfork 1) info page and 2) code page
* Not using manifest.json as adding a content script disables the extension on update.
*/
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
chrome.tabs.executeScript(tabId, {
file: '/content/install-hook-greasyfork.js',
runAt: 'document_start',
});
}, {
url: [
{hostEquals: 'greasyfork.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
*/
if (FIREFOX) {
chrome.webNavigation.onDOMContentLoaded.addListener(async ({tabId, frameId}) => {
if (frameId &&
!await msg.sendTab(tabId, {method: 'ping'}, {frameId}).catch(ignoreChromeError)) {
for (const file of chrome.runtime.getManifest().content_scripts[0].js) {
chrome.tabs.executeScript(tabId, {
frameId,
file,
matchAboutBlank: true,
}, ignoreChromeError);
}
}
}, {
url: [{urlEquals: 'about:blank'}],
});
}
});

View File

@ -1,75 +0,0 @@
/* global CHROME URLS */
/* exported navigatorUtil */
'use strict';
const navigatorUtil = (() => {
const handler = {
urlChange: null
};
return extendNative({onUrlChange});
function onUrlChange(fn) {
initUrlChange();
handler.urlChange.push(fn);
}
function initUrlChange() {
if (handler.urlChange) {
return;
}
handler.urlChange = [];
chrome.webNavigation.onCommitted.addListener(data =>
fixNTPUrl(data)
.then(() => executeCallbacks(handler.urlChange, data, 'committed'))
.catch(console.error)
);
chrome.webNavigation.onHistoryStateUpdated.addListener(data =>
fixNTPUrl(data)
.then(() => executeCallbacks(handler.urlChange, data, 'historyStateUpdated'))
.catch(console.error)
);
chrome.webNavigation.onReferenceFragmentUpdated.addListener(data =>
fixNTPUrl(data)
.then(() => executeCallbacks(handler.urlChange, data, 'referenceFragmentUpdated'))
.catch(console.error)
);
}
function fixNTPUrl(data) {
if (
!CHROME ||
!URLS.chromeProtectsNTP ||
!data.url.startsWith('https://www.google.') ||
!data.url.includes('/_/chrome/newtab?')
) {
return Promise.resolve();
}
return browser.tabs.get(data.tabId)
.then(tab => {
const url = tab.pendingUrl || tab.url;
if (url === 'chrome://newtab/') {
data.url = url;
}
});
}
function executeCallbacks(callbacks, data, type) {
for (const cb of callbacks) {
cb(data, type);
}
}
function extendNative(target) {
return new Proxy(target, {
get: (target, prop) => {
if (target[prop]) {
return target[prop];
}
return chrome.webNavigation[prop].addListener.bind(chrome.webNavigation[prop]);
}
});
}
})();

View File

@ -1,102 +0,0 @@
'use strict';
(() => {
// 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 = ({id}, queryString) => {
const query = gql(queryString);
return fetch(api, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: query({
id
})
})
.then(res => res.json());
};
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
/**
* 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,104 +0,0 @@
/* global API_METHODS styleManager tryRegExp debounce */
'use strict';
(() => {
// toLocaleLowerCase cache, autocleared after 1 minute
const cache = new Map();
// top-level style properties to be searched
const PARTS = {
name: searchText,
url: searchText,
sourceCode: searchText,
sections: searchSections,
};
/**
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
API_METHODS.searchDB = ({query, ids}) => {
let rx, words, icase, matchUrl;
query = query.trim();
if (/^url:/i.test(query)) {
matchUrl = query.slice(query.indexOf(':') + 1).trim();
if (matchUrl) {
return styleManager.getStylesByUrl(matchUrl)
.then(results => results.map(r => r.data.id));
}
}
if (query.startsWith('/') && /^\/(.+?)\/([gimsuy]*)$/.test(query)) {
rx = tryRegExp(RegExp.$1, RegExp.$2);
}
if (!rx) {
words = query
.split(/(".*?")|\s+/)
.filter(Boolean)
.map(w => w.startsWith('"') && w.endsWith('"')
? w.slice(1, -1)
: w)
.filter(w => w.length > 1);
words = words.length ? words : [query];
icase = words.some(w => w === lower(w));
}
return styleManager.getAllStyles().then(styles => {
if (ids) {
const idSet = new Set(ids);
styles = styles.filter(s => idSet.has(s.id));
}
const results = [];
for (const style of styles) {
const id = style.id;
if (!query || words && !words.length) {
results.push(id);
continue;
}
for (const part in PARTS) {
const text = part === 'name' ? style.customName || style.name : style[part];
if (text && PARTS[part](text, rx, words, icase)) {
results.push(id);
break;
}
}
}
if (cache.size) debounce(clearCache, 60e3);
return results;
});
};
function searchText(text, rx, words, icase) {
if (rx) return rx.test(text);
for (let pass = 1; pass <= (icase ? 2 : 1); pass++) {
if (words.every(w => text.includes(w))) return true;
text = lower(text);
}
}
function searchSections(sections, rx, words, icase) {
for (const section of sections) {
for (const prop in section) {
const value = section[prop];
if (typeof value === 'string') {
if (searchText(value, rx, words, icase)) return true;
} else if (Array.isArray(value)) {
if (value.some(str => searchText(str, rx, words, icase))) return true;
}
}
}
}
function lower(text) {
let result = cache.get(text);
if (result) return result;
result = text.toLocaleLowerCase();
cache.set(text, result);
return result;
}
function clearCache() {
cache.clear();
}
})();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
/* global API */// msg.js
/* global RX_META debounce stringAsRegExp tryRegExp */// toolbox.js
/* global addAPI */// common.js
'use strict';
(() => {
// toLocaleLowerCase cache, autocleared after 1 minute
const cache = new Map();
const METAKEYS = ['customName', 'name', 'url', 'installationUrl', 'updateUrl'];
const extractMeta = style =>
style.usercssData
? (style.sourceCode.match(RX_META) || [''])[0]
: null;
const stripMeta = style =>
style.usercssData
? style.sourceCode.replace(RX_META, '')
: null;
const MODES = Object.assign(Object.create(null), {
code: (style, test) =>
style.usercssData
? test(stripMeta(style))
: searchSections(style, test, 'code'),
meta: (style, test, part) =>
METAKEYS.some(key => test(style[key])) ||
test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
searchSections(style, test, 'funcs'),
name: (style, test) =>
test(style.customName) ||
test(style.name),
all: (style, test) =>
MODES.meta(style, test, 'all') ||
!style.usercssData && MODES.code(style, test),
});
addAPI(/** @namespace API */ {
styles: {
/**
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
async searchDB({query, mode = 'all', ids}) {
let res = [];
if (mode === 'url' && query) {
res = (await API.styles.getByUrl(query)).map(r => r.style.id);
} else if (mode in MODES) {
const modeHandler = MODES[mode];
const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query);
const rx = m && tryRegExp(m[1], m[2]);
const test = rx ? rx.test.bind(rx) : createTester(query);
res = (await API.styles.getAll())
.filter(style =>
(!ids || ids.includes(style.id)) &&
(!query || modeHandler(style, test)))
.map(style => style.id);
if (cache.size) debounce(clearCache, 60e3);
}
return res;
},
},
});
function createTester(query) {
const flags = `u${lower(query) === query ? 'i' : ''}`;
const words = query
.split(/(".*?")|\s+/)
.filter(Boolean)
.map(w => w.startsWith('"') && w.endsWith('"')
? w.slice(1, -1)
: w)
.filter(w => w.length > 1);
const rxs = (words.length ? words : [query])
.map(w => stringAsRegExp(w, flags));
return text => rxs.every(rx => rx.test(text));
}
function searchSections({sections}, test, part) {
const inCode = part === 'code' || part === 'all';
const inFuncs = part === 'funcs' || part === 'all';
for (const section of sections) {
for (const prop in section) {
const value = section[prop];
if (inCode && prop === 'code' && test(value) ||
inFuncs && Array.isArray(value) && value.some(str => test(str))) {
return true;
}
}
}
}
function lower(text) {
let result = cache.get(text);
if (!result) cache.set(text, result = text.toLocaleLowerCase());
return result;
}
function clearCache() {
cache.clear();
}
})();

View File

@ -1,7 +1,14 @@
/* global API_METHODS styleManager CHROME prefs */
/* global API */// msg.js
/* global addAPI */// common.js
/* global isEmptyObj */// toolbox.js
/* global prefs */
'use strict';
API_METHODS.styleViaAPI = !CHROME && (() => {
/**
* Uses chrome.tabs.insertCSS
*/
(() => {
const ACTIONS = {
styleApply,
styleDeleted,
@ -11,25 +18,25 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
prefChanged,
updateCount,
};
const NOP = Promise.resolve(new Error('NOP'));
const NOP = new Error('NOP');
const onError = () => {};
/* <tabId>: Object
<frameId>: Object
url: String, non-enumerable
<styleId>: Array of strings
section code */
const cache = new Map();
let observingTabs = false;
return function (request) {
const action = ACTIONS[request.method];
return !action ? NOP :
action(request, this.sender)
.catch(onError)
.then(maybeToggleObserver);
};
addAPI(/** @namespace API */ {
async styleViaAPI(request) {
try {
const fn = ACTIONS[request.method];
return fn ? fn(request, this.sender) : NOP;
} catch (e) {}
maybeToggleObserver();
},
});
function updateCount(request, sender) {
const {tab, frameId} = sender;
@ -37,7 +44,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
throw new Error('we do not count styles for frames');
}
const {frameStyles} = getCachedData(tab.id, frameId);
API_METHODS.updateIconBadge.call({sender}, Object.keys(frameStyles));
API.updateIconBadge.call({sender}, Object.keys(frameStyles));
}
function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) {
@ -48,7 +55,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
if (id === null && !ignoreUrlCheck && frameStyles.url === url) {
return NOP;
}
return styleManager.getSectionsByUrl(url, id).then(sections => {
return API.styles.getSectionsByUrl(url, id).then(sections => {
const tasks = [];
for (const section of Object.values(sections)) {
const styleId = section.id;
@ -125,7 +132,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
}
const {tab, frameId} = sender;
const {tabFrames, frameStyles} = getCachedData(tab.id, frameId);
if (isEmpty(frameStyles)) {
if (isEmptyObj(frameStyles)) {
return NOP;
}
removeFrameIfEmpty(tab.id, frameId, tabFrames, {});
@ -162,7 +169,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
const tabFrames = cache.get(tabId);
if (tabFrames && frameId in tabFrames) {
delete tabFrames[frameId];
if (isEmpty(tabFrames)) {
if (isEmptyObj(tabFrames)) {
onTabRemoved(tabId);
}
}
@ -178,9 +185,9 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
}
function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) {
if (isEmpty(frameStyles)) {
if (isEmptyObj(frameStyles)) {
delete tabFrames[frameId];
if (isEmpty(tabFrames)) {
if (isEmptyObj(tabFrames)) {
cache.delete(tabId);
}
return true;
@ -223,11 +230,4 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
return browser.tabs.removeCSS(tabId, {frameId, code, matchAboutBlank: true})
.catch(onError);
}
function isEmpty(obj) {
for (const k in obj) {
return false;
}
return true;
}
})();

View File

@ -0,0 +1,152 @@
/* global API */// msg.js
/* global CHROME ignoreChromeError */// toolbox.js
/* global prefs */
'use strict';
(() => {
const idCSP = 'patchCsp';
const idOFF = 'disableAll';
const idXHR = 'styleViaXhr';
const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by *
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
/** @type {Object<string,StylesToPass>} */
const stylesToPass = {};
const state = {};
const injectedCode = CHROME && `${data => {
if (self.INJECTED !== 1) { // storing data only if apply.js hasn't run yet
window[Symbol.for('styles')] = data;
}
}}`;
toggle();
prefs.subscribe([idXHR, idOFF, idCSP], toggle);
function toggle() {
const off = prefs.get(idOFF);
const csp = prefs.get(idCSP) && !off;
const xhr = prefs.get(idXHR) && !off;
if (xhr === state.xhr && csp === state.csp && off === state.off) {
return;
}
const reqFilter = {
urls: ['*://*/*'],
types: ['main_frame', 'sub_frame'],
};
chrome.webNavigation.onCommitted.removeListener(injectData);
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders);
if (xhr || csp) {
// We unregistered it above so that the optional EXTRA_HEADERS is properly re-registered
chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [
'blocking',
'responseHeaders',
xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
].filter(Boolean));
}
if (CHROME ? !off : xhr || csp) {
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
}
if (CHROME && !off) {
chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]});
}
state.csp = csp;
state.off = off;
state.xhr = xhr;
}
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
async function prepareStyles(req) {
const sections = await API.styles.getSectionsByUrl(req.url);
stylesToPass[req2key(req)] = /** @namespace StylesToPass */ {
blobId: '',
str: JSON.stringify(sections),
timer: setTimeout(cleanUp, 600e3, req),
};
}
function injectData(req) {
const data = stylesToPass[req2key(req)];
if (data && !data.injected) {
data.injected = true;
chrome.tabs.executeScript(req.tabId, {
frameId: req.frameId,
runAt: 'document_start',
code: `(${injectedCode})(${data.str})`,
}, ignoreChromeError);
if (!state.xhr) cleanUp(req);
}
}
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
function modifyHeaders(req) {
const {responseHeaders} = req;
const data = stylesToPass[req2key(req)];
if (!data || data.str === '{}') {
cleanUp(req);
return;
}
if (state.xhr) {
data.blobId = URL.createObjectURL(new Blob([data.str])).slice(blobUrlPrefix.length);
responseHeaders.push({
name: 'Set-Cookie',
value: `${chrome.runtime.id}=${data.blobId}`,
});
}
const csp = state.csp &&
responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy');
if (csp) {
patchCsp(csp);
}
if (state.xhr || csp) {
return {responseHeaders};
}
}
/** @param {chrome.webRequest.HttpHeader} csp */
function patchCsp(csp) {
const src = {};
for (let p of csp.value.split(';')) {
p = p.trim().split(/\s+/);
src[p[0]] = p.slice(1);
}
// Allow style assets
patchCspSrc(src, 'img-src', 'data:', '*');
patchCspSrc(src, 'font-src', 'data:', '*');
// Allow our DOM styles, allow @import from any URL
patchCspSrc(src, 'style-src', "'unsafe-inline'", '*');
// Allow our XHR cookies in CSP sandbox (known case: raw github urls)
if (src.sandbox && !src.sandbox.includes('allow-same-origin')) {
src.sandbox.push('allow-same-origin');
}
csp.value = Object.entries(src).map(([k, v]) =>
`${k}${v.length ? ' ' : ''}${v.join(' ')}`).join('; ');
}
function patchCspSrc(src, name, ...values) {
let def = src['default-src'];
let list = src[name];
if (def || list) {
if (!def) def = [];
if (!list) list = [...def];
if (values.includes('*')) list = src[name] = list.filter(v => !rxHOST.test(v));
list.push(...values.filter(v => !list.includes(v) && !def.includes(v)));
if (!list.length) delete src[name];
}
}
function cleanUp(req) {
const key = req2key(req);
const data = stylesToPass[key];
if (data) {
delete stylesToPass[key];
clearTimeout(data.timer);
if (data.blobId) {
URL.revokeObjectURL(blobUrlPrefix + data.blobId);
}
}
}
function req2key(req) {
return req.tabId + ':' + req.frameId;
}
})();

View File

@ -1,85 +0,0 @@
/* global API CHROME prefs */
'use strict';
// eslint-disable-next-line no-unused-expressions
CHROME && (async () => {
const prefId = 'styleViaXhr';
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
const stylesToPass = {};
await prefs.initializing;
toggle(prefId, prefs.get(prefId));
prefs.subscribe([prefId], toggle);
function toggle(key, value) {
if (!chrome.declarativeContent) { // not yet granted in options page
value = false;
}
if (value) {
const reqFilter = {
urls: ['<all_urls>'],
types: ['main_frame', 'sub_frame'],
};
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
chrome.webRequest.onHeadersReceived.addListener(passStyles, reqFilter, [
'blocking',
'responseHeaders',
chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
].filter(Boolean));
} else {
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
chrome.webRequest.onHeadersReceived.removeListener(passStyles);
}
if (!chrome.declarativeContent) {
return;
}
chrome.declarativeContent.onPageChanged.removeRules([prefId], async () => {
if (!value) return;
chrome.declarativeContent.onPageChanged.addRules([{
id: prefId,
conditions: [
new chrome.declarativeContent.PageStateMatcher({
pageUrl: {urlContains: ':'},
}),
],
actions: [
new chrome.declarativeContent.RequestContentScript({
allFrames: true,
// This runs earlier than document_start
js: chrome.runtime.getManifest().content_scripts[0].js,
}),
],
}]);
});
}
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
function prepareStyles(req) {
API.getSectionsByUrl(req.url).then(sections => {
const str = JSON.stringify(sections);
if (str !== '{}') {
stylesToPass[req.requestId] = URL.createObjectURL(new Blob([str])).slice(blobUrlPrefix.length);
setTimeout(cleanUp, 600e3, req.requestId);
}
});
}
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
function passStyles(req) {
const blobId = stylesToPass[req.requestId];
if (blobId) {
const {responseHeaders} = req;
responseHeaders.push({
name: 'Set-Cookie',
value: `${chrome.runtime.id}=${prefs.get('disableAll') ? 1 : 0}${blobId}`,
});
return {responseHeaders};
}
}
function cleanUp(key) {
const blobId = stylesToPass[key];
delete stylesToPass[key];
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
}
})();

264
background/sync-manager.js Normal file
View File

@ -0,0 +1,264 @@
/* global API msg */// msg.js
/* global chromeLocal */// storage-util.js
/* global compareRevision */// common.js
/* global iconMan */
/* global prefs */
/* global tokenMan */
'use strict';
const syncMan = (() => {
//#region Init
const SYNC_DELAY = 1; // minutes
const SYNC_INTERVAL = 30; // minutes
const SYNC_LOCK_RETRIES = 10; // number of retries before the error is reported for scheduled sync
const STATES = Object.freeze({
connected: 'connected',
connecting: 'connecting',
disconnected: 'disconnected',
disconnecting: 'disconnecting',
});
const STORAGE_KEY = 'sync/state/';
const status = /** @namespace SyncManager.Status */ {
STATES,
state: STATES.disconnected,
syncing: false,
progress: null,
currentDriveName: null,
errorMessage: null,
login: false,
lockRetries: 0,
};
let lastError = null;
let ctrl;
let currentDrive;
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = prefs.ready.then(() => {
ready = true;
prefs.subscribe('sync.enabled',
(_, val) => val === 'none'
? syncMan.stop()
: syncMan.start(val, true),
{runNow: true});
});
chrome.alarms.onAlarm.addListener(async ({name}) => {
if (name === 'syncNow') {
await syncMan.syncNow({isScheduled: true});
const retrying = status.lockRetries / SYNC_LOCK_RETRIES * Math.random();
schedule(SYNC_DELAY + SYNC_INTERVAL * (retrying || 1));
}
});
//#endregion
//#region Exports
return {
async delete(...args) {
if (ready.then) await ready;
if (!currentDrive) return;
schedule();
return ctrl.delete(...args);
},
/** @returns {Promise<SyncManager.Status>} */
async getStatus() {
return status;
},
async login(name) {
if (ready.then) await ready;
if (!name) name = prefs.get('sync.enabled');
await tokenMan.revokeToken(name);
try {
await tokenMan.getToken(name, true);
status.login = true;
} catch (err) {
status.login = false;
throw err;
} finally {
emitStatusChange();
}
},
async put(...args) {
if (ready.then) await ready;
if (!currentDrive) return;
schedule();
return ctrl.put(...args);
},
async start(name, fromPref = false) {
if (ready.then) await ready;
if (!ctrl) await initController();
if (currentDrive) return;
currentDrive = getDrive(name);
ctrl.use(currentDrive);
status.state = STATES.connecting;
status.currentDriveName = currentDrive.name;
emitStatusChange();
if (fromPref) {
status.login = true;
} else {
try {
await syncMan.login(name);
} catch (err) {
console.error(err);
status.errorMessage = err.message;
lastError = err;
emitStatusChange();
return syncMan.stop();
}
}
await ctrl.init();
await syncMan.syncNow(name);
prefs.set('sync.enabled', name);
status.state = STATES.connected;
schedule(SYNC_INTERVAL);
emitStatusChange();
},
async stop() {
if (ready.then) await ready;
if (!currentDrive) return;
chrome.alarms.clear('syncNow');
status.state = STATES.disconnecting;
emitStatusChange();
try {
await ctrl.uninit();
await tokenMan.revokeToken(currentDrive.name);
await chromeLocal.remove(STORAGE_KEY + currentDrive.name);
} catch (e) {}
currentDrive = null;
prefs.set('sync.enabled', 'none');
status.state = STATES.disconnected;
status.currentDriveName = null;
status.login = false;
emitStatusChange();
},
async syncNow({isScheduled} = {}) {
if (ready.then) await ready;
if (!currentDrive || !status.login) {
console.warn('cannot sync when disconnected');
return;
}
try {
await ctrl.syncNow();
status.errorMessage = null;
lastError = null;
} catch (err) {
status.errorMessage = err.message;
lastError = err;
if (isScheduled &&
err.code === 409 &&
++status.lockRetries <= SYNC_LOCK_RETRIES) {
return;
}
if (isGrantError(err)) {
status.login = false;
}
}
status.lockRetries = 0;
emitStatusChange();
},
};
//#endregion
//#region Utils
async function initController() {
await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */
ctrl = dbToCloud.dbToCloud({
onGet(id) {
return API.styles.getByUUID(id);
},
onPut(doc) {
return API.styles.putByUUID(doc);
},
onDelete(id, rev) {
return API.styles.deleteByUUID(id, rev);
},
async onFirstSync() {
for (const i of await API.styles.getAll()) {
ctrl.put(i._id, i._rev);
}
},
onProgress(e) {
if (e.phase === 'start') {
status.syncing = true;
} else if (e.phase === 'end') {
status.syncing = false;
status.progress = null;
} else {
status.progress = e;
}
emitStatusChange();
},
compareRevision,
getState(drive) {
return chromeLocal.getValue(STORAGE_KEY + drive.name);
},
setState(drive, state) {
return chromeLocal.setValue(STORAGE_KEY + drive.name, state);
},
});
}
function emitStatusChange() {
msg.broadcastExtension({method: 'syncStatusUpdate', status});
iconMan.overrideBadge(getErrorBadge());
}
function isNetworkError(err) {
return (
err.name === 'TypeError' && /networkerror|failed to fetch/i.test(err.message) ||
err.code === 502
);
}
function isGrantError(err) {
if (err.code === 401) return true;
if (err.code === 400 && /invalid_grant/.test(err.message)) return true;
return false;
}
function getErrorBadge() {
if (status.state === STATES.connected &&
(!status.login || lastError && !isNetworkError(lastError))) {
return {
text: 'x',
color: '#F00',
title: !status.login ? 'syncErrorRelogin' : `${
chrome.i18n.getMessage('syncError')
}\n---------------------\n${
// splitting to limit each line length
lastError.message.replace(/.{60,}?\s(?=.{30,})/g, '$&\n')
}`,
};
}
}
function getDrive(name) {
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
return dbToCloud.drive[name]({
getAccessToken: () => tokenMan.getToken(name),
});
}
throw new Error(`unknown cloud name: ${name}`);
}
function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', {
delayInMinutes: delay, // fractional values are supported
});
}
//#endregion
})();

View File

@ -1,239 +0,0 @@
/* global dbToCloud styleManager chromeLocal prefs tokenManager msg */
/* exported sync */
'use strict';
const sync = (() => {
const SYNC_DELAY = 1; // minutes
const SYNC_INTERVAL = 30; // minutes
const status = {
state: 'disconnected',
syncing: false,
progress: null,
currentDriveName: null,
errorMessage: null,
login: false
};
let currentDrive;
const ctrl = dbToCloud.dbToCloud({
onGet(id) {
return styleManager.getByUUID(id);
},
onPut(doc) {
return styleManager.putByUUID(doc);
},
onDelete(id, rev) {
return styleManager.deleteByUUID(id, rev);
},
onFirstSync() {
return styleManager.getAllStyles()
.then(styles => {
styles.forEach(i => ctrl.put(i._id, i._rev));
});
},
onProgress,
compareRevision(a, b) {
return styleManager.compareRevision(a, b);
},
getState(drive) {
const key = `sync/state/${drive.name}`;
return chromeLocal.get(key)
.then(obj => obj[key]);
},
setState(drive, state) {
const key = `sync/state/${drive.name}`;
return chromeLocal.set({
[key]: state
});
}
});
const initializing = prefs.initializing.then(() => {
prefs.subscribe(['sync.enabled'], onPrefChange);
onPrefChange(null, prefs.get('sync.enabled'));
});
chrome.alarms.onAlarm.addListener(info => {
if (info.name === 'syncNow') {
syncNow().catch(console.error);
}
});
return Object.assign({
getStatus: () => status
}, ensurePrepared({
start,
stop,
put: (...args) => {
if (!currentDrive) return;
schedule();
return ctrl.put(...args);
},
delete: (...args) => {
if (!currentDrive) return;
schedule();
return ctrl.delete(...args);
},
syncNow,
login
}));
function ensurePrepared(obj) {
return Object.entries(obj).reduce((o, [key, fn]) => {
o[key] = (...args) =>
initializing.then(() => fn(...args));
return o;
}, {});
}
function onProgress(e) {
if (e.phase === 'start') {
status.syncing = true;
} else if (e.phase === 'end') {
status.syncing = false;
status.progress = null;
} else {
status.progress = e;
}
emitStatusChange();
}
function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', {
delayInMinutes: delay,
periodInMinutes: SYNC_INTERVAL
});
}
function onPrefChange(key, value) {
if (value === 'none') {
stop().catch(console.error);
} else {
start(value, true).catch(console.error);
}
}
function withFinally(p, cleanup) {
return p.then(
result => {
cleanup(undefined, result);
return result;
},
err => {
cleanup(err);
throw err;
}
);
}
function syncNow() {
if (!currentDrive) {
return Promise.reject(new Error('cannot sync when disconnected'));
}
return withFinally(
(ctrl.isInit() ? ctrl.syncNow() : ctrl.start())
.catch(handle401Error),
err => {
status.errorMessage = err ? err.message : null;
emitStatusChange();
}
);
}
function handle401Error(err) {
if (err.code === 401) {
return tokenManager.revokeToken(currentDrive.name)
.catch(console.error)
.then(() => {
status.login = false;
emitStatusChange();
throw err;
});
}
if (/User interaction required|Requires user interaction/i.test(err.message)) {
status.login = false;
emitStatusChange();
}
throw err;
}
function emitStatusChange() {
msg.broadcastExtension({method: 'syncStatusUpdate', status});
}
function login(name = prefs.get('sync.enabled')) {
return tokenManager.getToken(name, true)
.catch(err => {
if (/Authorization page could not be loaded/i.test(err.message)) {
// FIXME: Chrome always fails at the first login so we try again
return tokenManager.getToken(name);
}
throw err;
})
.then(() => {
status.login = true;
emitStatusChange();
});
}
function start(name, fromPref = false) {
if (currentDrive) {
return Promise.resolve();
}
currentDrive = getDrive(name);
ctrl.use(currentDrive);
status.state = 'connecting';
status.currentDriveName = currentDrive.name;
status.login = true;
emitStatusChange();
return withFinally(
(fromPref ? Promise.resolve() : login(name))
.catch(handle401Error)
.then(() => syncNow()),
err => {
status.errorMessage = err ? err.message : null;
// FIXME: should we move this logic to options.js?
if (err && !fromPref) {
console.error(err);
return stop();
}
prefs.set('sync.enabled', name);
schedule(SYNC_INTERVAL);
status.state = 'connected';
emitStatusChange();
}
);
}
function getDrive(name) {
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
return dbToCloud.drive[name]({
getAccessToken: () => tokenManager.getToken(name)
});
}
throw new Error(`unknown cloud name: ${name}`);
}
function stop() {
if (!currentDrive) {
return Promise.resolve();
}
chrome.alarms.clear('syncNow');
status.state = 'disconnecting';
emitStatusChange();
return withFinally(
ctrl.stop()
.then(() => tokenManager.revokeToken(currentDrive.name))
.then(() => chromeLocal.remove(`sync/state/${currentDrive.name}`)),
() => {
currentDrive = null;
prefs.set('sync.enabled', 'none');
status.state = 'disconnected';
status.currentDriveName = null;
status.login = false;
emitStatusChange();
}
);
}
})();

View File

@ -1,32 +1,37 @@
/* global navigatorUtil */
/* exported tabManager */
/* global bgReady */// common.js
/* global navMan */
'use strict';
const tabManager = (() => {
const listeners = [];
const tabMan = (() => {
const listeners = new Set();
const cache = new Map();
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
navigatorUtil.onUrlChange(({tabId, frameId, url}) => {
if (frameId) return;
const oldUrl = tabManager.get(tabId, 'url');
tabManager.set(tabId, 'url', url);
for (const fn of listeners) {
try {
fn({tabId, url, oldUrl});
} catch (err) {
console.error(err);
bgReady.all.then(() => {
navMan.onUrlChange(({tabId, frameId, url}) => {
const oldUrl = !frameId && tabMan.get(tabId, 'url', frameId);
tabMan.set(tabId, 'url', frameId, url);
if (frameId) return;
for (const fn of listeners) {
try {
fn({tabId, url, oldUrl});
} catch (err) {
console.error(err);
}
}
}
});
});
return {
onUpdate(fn) {
listeners.push(fn);
listeners.add(fn);
},
get(tabId, ...keys) {
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId));
},
/**
* number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta
* (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
@ -47,8 +52,11 @@ const tabManager = (() => {
meta[lastKey] = value;
}
},
/** @returns {IterableIterator<number>} */
list() {
return cache.keys();
},
};
})();

View File

@ -1,12 +1,9 @@
/* global chromeLocal promisifyChrome webextLaunchWebAuthFlow FIREFOX */
/* exported tokenManager */
/* global FIREFOX getActiveTab waitForTabUrl URLS */// toolbox.js
/* global chromeLocal */// storage-util.js
'use strict';
const tokenManager = (() => {
promisifyChrome({
'windows': ['create', 'update', 'remove'],
'tabs': ['create', 'update', 'remove']
});
/* exported tokenMan */
const tokenMan = (() => {
const AUTH = {
dropbox: {
flow: 'token',
@ -17,9 +14,9 @@ const tokenManager = (() => {
fetch('https://api.dropboxapi.com/2/auth/token/revoke', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
})
'Authorization': `Bearer ${token}`,
},
}),
},
google: {
flow: 'code',
@ -31,14 +28,14 @@ const tokenManager = (() => {
// tokens for multiple machines.
// https://stackoverflow.com/q/18519185
access_type: 'offline',
prompt: 'consent'
prompt: 'consent',
},
tokenURL: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
revoke: token => {
const params = {token};
return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
}
},
},
onedrive: {
flow: 'code',
@ -49,102 +46,103 @@ const tokenManager = (() => {
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
return {getToken, revokeToken, getClientId, buildKeys};
let alwaysUseTab = FIREFOX ? false : null;
function getClientId(name) {
return AUTH[name].clientId;
}
return {
function buildKeys(name) {
const k = {
TOKEN: `secure/token/${name}/token`,
EXPIRE: `secure/token/${name}/expire`,
REFRESH: `secure/token/${name}/refresh`,
};
k.LIST = Object.values(k);
return k;
}
buildKeys(name, hooks) {
const prefix = `secure/token/${hooks ? hooks.keyName(name) : name}/`;
const k = {
TOKEN: `${prefix}token`,
EXPIRE: `${prefix}expire`,
REFRESH: `${prefix}refresh`,
};
k.LIST = Object.values(k);
return k;
},
function getToken(name, interactive) {
const k = buildKeys(name);
return chromeLocal.get(k.LIST)
.then(obj => {
if (!obj[k.TOKEN]) {
return authUser(name, k, interactive);
}
getClientId(name) {
return AUTH[name].clientId;
},
async getToken(name, interactive, hooks) {
const k = tokenMan.buildKeys(name, hooks);
const obj = await chromeLocal.get(k.LIST);
if (obj[k.TOKEN]) {
if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
return obj[k.TOKEN];
}
if (obj[k.REFRESH]) {
return refreshToken(name, k, obj)
.catch(err => {
if (err.code === 401) {
return authUser(name, k, interactive);
}
throw err;
});
return refreshToken(name, k, obj);
}
return authUser(name, k, interactive);
});
}
function revokeToken(name) {
const provider = AUTH[name];
const k = buildKeys(name);
return revoke()
.then(() => chromeLocal.remove(k.LIST));
function revoke() {
if (!provider.revoke) {
return Promise.resolve();
}
return chromeLocal.get(k.TOKEN)
.then(obj => {
if (obj[k.TOKEN]) {
return provider.revoke(obj[k.TOKEN]);
}
})
.catch(console.error);
}
}
if (!interactive) {
throw new Error(`Invalid token: ${name}`);
}
return authUser(k, name, interactive, hooks);
},
function refreshToken(name, k, obj) {
async revokeToken(name, hooks) {
const provider = AUTH[name];
const k = tokenMan.buildKeys(name, hooks);
if (provider.revoke) {
try {
const token = await chromeLocal.getValue(k.TOKEN);
if (token) await provider.revoke(token);
} catch (e) {
console.error(e);
}
}
await chromeLocal.remove(k.LIST);
},
};
async function refreshToken(name, k, obj) {
if (!obj[k.REFRESH]) {
return Promise.reject(new Error('no refresh token'));
throw new Error('No refresh token');
}
const provider = AUTH[name];
const body = {
client_id: provider.clientId,
refresh_token: obj[k.REFRESH],
grant_type: 'refresh_token',
scope: provider.scopes.join(' ')
scope: provider.scopes.join(' '),
};
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body)
.then(result => {
if (!result.refresh_token) {
// reuse old refresh token
result.refresh_token = obj[k.REFRESH];
}
return handleTokenResult(result, k);
});
const result = await postQuery(provider.tokenURL, body);
if (!result.refresh_token) {
// reuse old refresh token
result.refresh_token = obj[k.REFRESH];
}
return handleTokenResult(result, k);
}
function authUser(name, k, interactive = false) {
async function authUser(keys, name, interactive = false, hooks = null) {
await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
/* global webextLaunchWebAuthFlow */
const provider = AUTH[name];
const state = Math.random().toFixed(8).slice(2);
const query = {
response_type: provider.flow,
client_id: provider.clientId,
redirect_uri: provider.redirect_uri || chrome.identity.getRedirectURL(),
state
state,
};
if (provider.scopes) {
query.scope = provider.scopes.join(' ');
@ -152,71 +150,111 @@ const tokenManager = (() => {
if (provider.authQuery) {
Object.assign(query, provider.authQuery);
}
if (alwaysUseTab == null) {
alwaysUseTab = await detectVivaldiWebRequestBug();
}
if (hooks) hooks.query(query);
const url = `${provider.authURL}?${new URLSearchParams(query)}`;
return webextLaunchWebAuthFlow({
const width = Math.min(screen.availWidth - 100, 800);
const height = Math.min(screen.availHeight - 100, 800);
const wnd = await browser.windows.getLastFocused();
const finalUrl = await webextLaunchWebAuthFlow({
url,
alwaysUseTab,
interactive,
redirect_uri: query.redirect_uri
})
.then(url => {
const params = new URLSearchParams(
provider.flow === 'token' ?
new URL(url).hash.slice(1) :
new URL(url).search.slice(1)
);
if (params.get('state') !== state) {
throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`);
}
if (provider.flow === 'token') {
const obj = {};
for (const [key, value] of params.entries()) {
obj[key] = value;
}
return obj;
}
const code = params.get('code');
const body = {
code,
grant_type: 'authorization_code',
client_id: provider.clientId,
redirect_uri: query.redirect_uri
};
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body);
})
.then(result => handleTokenResult(result, k));
redirect_uri: query.redirect_uri,
windowOptions: Object.assign({
state: 'normal',
width,
height,
}, 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(
provider.flow === 'token' ?
new URL(finalUrl).hash.slice(1) :
new URL(finalUrl).search.slice(1)
);
if (params.get('state') !== state) {
throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`);
}
let result;
if (provider.flow === 'token') {
const obj = {};
for (const [key, value] of params) {
obj[key] = value;
}
result = obj;
} else {
const code = params.get('code');
const body = {
code,
grant_type: 'authorization_code',
client_id: provider.clientId,
redirect_uri: query.redirect_uri,
state,
};
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
result = await postQuery(provider.tokenURL, body);
}
return handleTokenResult(result, keys);
}
function handleTokenResult(result, k) {
return chromeLocal.set({
async function handleTokenResult(result, k) {
await chromeLocal.set({
[k.TOKEN]: result.access_token,
[k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined,
[k.REFRESH]: result.refresh_token
})
.then(() => result.access_token);
[k.EXPIRE]: result.expires_in
? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000
: undefined,
[k.REFRESH]: result.refresh_token,
});
return result.access_token;
}
function postQuery(url, body) {
async function postQuery(url, body) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body ? new URLSearchParams(body) : null,
};
return fetch(url, options)
.then(r => {
if (r.ok) {
return r.json();
}
return r.text()
.then(body => {
const err = new Error(`failed to fetch (${r.status}): ${body}`);
err.code = r.status;
throw err;
});
});
const r = await fetch(url, options);
if (r.ok) {
return r.json();
}
const text = await r.text();
const err = new Error(`Failed to fetch (${r.status}): ${text}`);
err.code = r.status;
throw err;
}
async function detectVivaldiWebRequestBug() {
// 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
const anyTab = await getActiveTab() || (await browser.tabs.query({}))[0];
if (anyTab && !anyTab.extData) {
return false;
}
let bugged = true;
const TEST_URL = chrome.runtime.getURL('manifest.json');
const check = ({url}) => {
bugged = url !== TEST_URL;
};
chrome.webRequest.onBeforeRequest.addListener(check, {urls: [TEST_URL], types: ['main_frame']});
const {tabs: [tab]} = await browser.windows.create({
type: 'popup',
state: 'minimized',
url: TEST_URL,
});
await waitForTabUrl(tab);
chrome.windows.remove(tab.windowId);
chrome.webRequest.onBeforeRequest.removeListener(check);
return bugged;
}
})();

View File

@ -0,0 +1,349 @@
/* global API */// msg.js
/* global RX_META URLS debounce download ignoreChromeError */// toolbox.js
/* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js
/* global chromeLocal */// storage-util.js
/* global compareVersion */// cmpver.js
/* global db */
/* global prefs */
'use strict';
/* exported updateMan */
const updateMan = (() => {
const STATES = /** @namespace UpdaterStates */ {
UPDATED: 'updated',
SKIPPED: 'skipped',
UNREACHABLE: 'server unreachable',
// details for SKIPPED status
EDITED: 'locally edited',
MAYBE_EDITED: 'may be locally edited',
SAME_MD5: 'up-to-date: MD5 is unchanged',
SAME_CODE: 'up-to-date: code sections are unchanged',
SAME_VERSION: 'up-to-date: version is unchanged',
ERROR_MD5: 'error: MD5 is invalid',
ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style',
};
const RH_ETAG = {responseHeaders: ['etag']}; // a hashsum of file contents
const RX_DATE2VER = new RegExp([
/^(\d{4})/,
/(0[1-9]|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])/,
/\.([01][0-9]?|2[0-3]?|[3-9])/,
/\.([0-5][0-9]?|[6-9])$/,
].map(rx => rx.source).join(''));
const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3;
const RETRY_ERRORS = [
503, // service unavailable
429, // too many requests
];
let lastUpdateTime;
let checkingAll = false;
let logQueue = [];
let logLastWriteTime = 0;
chromeLocal.getValue('lastUpdateTime').then(val => {
lastUpdateTime = val || Date.now();
prefs.subscribe('updateInterval', schedule, {runNow: true});
chrome.alarms.onAlarm.addListener(onAlarm);
});
return {
checkAllStyles,
checkStyle,
getStates: () => STATES,
};
async function checkAllStyles({
save = true,
ignoreDigest,
observe,
} = {}) {
resetInterval();
checkingAll = true;
const port = observe && chrome.runtime.connect({name: 'updater'});
const styles = (await API.styles.getAll())
.filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length});
log('');
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
await Promise.all(
styles.map(style =>
checkStyle({style, port, save, ignoreDigest})));
if (port) port.postMessage({done: true});
if (port) port.disconnect();
log('');
checkingAll = false;
}
/**
* @param {{
id?: number
style?: StyleObj
port?: chrome.runtime.Port
save?: boolean = true
ignoreDigest?: boolean
}} opts
* @returns {{
style: StyleObj
updated?: boolean
error?: any
STATES: UpdaterStates
}}
Original style digests are calculated in these cases:
* style is installed or updated from server
* non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
Update check proceeds in these cases:
* style has the original digest and it's equal to the current digest
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
* [ignoreDigest: none/false] style doesn't yet have the original digest
so we compare the code to the server code and if it's the same we save the digest,
otherwise we skip the style and report MAYBE_EDITED status
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
async function checkStyle(opts) {
let {id} = opts;
const {
style = await API.styles.get(id),
ignoreDigest,
port,
save,
} = opts;
if (!id) id = style.id;
const ucd = style.usercssData;
let res, state;
try {
await checkIfEdited();
res = {
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
updated: true,
};
state = STATES.UPDATED;
} catch (err) {
const error = err === 0 && STATES.UNREACHABLE ||
err && err.message ||
err;
res = {error, style, STATES};
state = `${STATES.SKIPPED} (${error})`;
}
log(`${state} #${id} ${style.customName || style.name}`);
if (port) port.postMessage(res);
return res;
async function checkIfEdited() {
if (!ignoreDigest &&
style.originalDigest &&
style.originalDigest !== await calcStyleDigest(style)) {
return Promise.reject(STATES.EDITED);
}
}
async function updateUSO() {
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) {
return Promise.reject(STATES.ERROR_MD5);
}
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5);
}
const json = await tryDownload(style.updateUrl, {responseType: 'json'});
if (!styleJSONseemsValid(json)) {
return Promise.reject(STATES.ERROR_JSON);
}
// USO may not provide a correctly updated originalMd5 (#555)
json.originalMd5 = md5;
return json;
}
async function updateToUSOArchive(url, req) {
const m2 = getUsoEmbeddedMeta(req.response);
if (m2) {
url = (await m2).updateUrl;
req = await tryDownload(url, RH_ETAG);
}
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() {
let oldVer = ucd.version;
let {etag: oldEtag, updateUrl} = style;
let m2 = URLS.extractUsoArchiveId(updateUrl) && getUsoEmbeddedMeta();
if (m2 && (m2 = await m2).updateUrl) {
updateUrl = m2.updateUrl;
oldVer = m2.usercssData.version || '0';
oldEtag = '';
}
if (oldEtag && oldEtag === await downloadEtag()) {
return Promise.reject(STATES.SAME_CODE);
}
// TODO: when sourceCode is > 100kB use http range request(s) for version check
const {headers: {etag}, response} = await tryDownload(updateUrl, RH_ETAG);
const json = await API.usercss.buildMeta({sourceCode: response, etag, updateUrl});
const delta = compareVersion(json.usercssData.version, oldVer);
let err;
if (!delta && !ignoreDigest) {
// re-install is invalid in a soft upgrade
err = response === style.sourceCode ? STATES.SAME_CODE : STATES.SAME_VERSION;
}
if (delta < 0) {
// downgrade is always invalid
err = STATES.ERROR_VERSION;
}
if (err && etag && !style.etag) {
// first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce
style.etag = etag;
await db.exec('put', style);
}
return err
? Promise.reject(err)
: API.usercss.buildCode(json);
}
async function maybeSave(json) {
json.id = id;
// keep current state
delete json.customName;
delete json.enabled;
const newStyle = Object.assign({}, style, json);
newStyle.updateDate = getDateFromVer(newStyle) || Date.now();
// update digest even if save === false as there might be just a space added etc.
if (!ucd && styleSectionsEqual(json, style)) {
style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
return Promise.reject(STATES.SAME_CODE);
}
if (!style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.MAYBE_EDITED);
}
return !save ? newStyle :
(ucd ? API.usercss.install : API.styles.install)(newStyle);
}
async function tryDownload(url, params) {
let {retryDelay = 1000} = opts;
while (true) {
try {
return await download(url, params);
} catch (code) {
if (!RETRY_ERRORS.includes(code) ||
retryDelay > MIN_INTERVAL_MS) {
return Promise.reject(code);
}
}
retryDelay *= 1.25;
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
async function downloadEtag() {
const opts = Object.assign({method: 'head'}, RH_ETAG);
const req = await tryDownload(style.updateUrl, opts);
return req.headers.etag;
}
function getDateFromVer(style) {
const m = URLS.extractUsoArchiveId(style.updateUrl) &&
style.usercssData.version.match(RX_DATE2VER);
if (m) {
m[2]--; // month is 0-based in `Date` constructor
return new Date(...m.slice(1)).getTime();
}
}
/** UserCSS metadata may be embedded in the original USO style so let's use its updateURL */
function getUsoEmbeddedMeta(code = style.sourceCode) {
const m = code.includes('@updateURL') && code.replace(RX_META, '').match(RX_META);
return m && API.usercss.buildMeta({sourceCode: m[0]}).catch(() => null);
}
function getVarOptByName(varDef, name) {
return varDef.options.find(o => o.name === name);
}
}
function schedule() {
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
if (interval > 0) {
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
chrome.alarms.create(ALARM_NAME, {
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
});
} else {
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
}
}
function onAlarm({name}) {
if (name === ALARM_NAME) checkAllStyles();
}
function resetInterval() {
chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
schedule();
}
function log(text) {
logQueue.push({text, time: new Date().toLocaleString()});
debounce(flushQueue, text && checkingAll ? 1000 : 0);
}
async function flushQueue(lines) {
if (!lines) {
flushQueue(await chromeLocal.getValue('updateLog') || []);
return;
}
const time = Date.now() - logLastWriteTime > 11e3 ?
logQueue[0].time + ' ' :
'';
if (logQueue[0] && !logQueue[0].text) {
logQueue.shift();
if (lines[lines.length - 1]) lines.push('');
}
lines.splice(0, lines.length - 1000);
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
lines.push(...logQueue.slice(1).map(item => item.text));
chromeLocal.setValue('updateLog', lines);
logLastWriteTime = Date.now();
logQueue = [];
}
})();

View File

@ -1,276 +0,0 @@
/* global styleSectionsEqual prefs download tryJSONparse ignoreChromeError
calcStyleDigest getStyleWithNoCode debounce chromeLocal
usercss semverCompare styleJSONseemsValid
API_METHODS styleManager */
'use strict';
(() => {
const STATES = {
UPDATED: 'updated',
SKIPPED: 'skipped',
// details for SKIPPED status
EDITED: 'locally edited',
MAYBE_EDITED: 'may be locally edited',
SAME_MD5: 'up-to-date: MD5 is unchanged',
SAME_CODE: 'up-to-date: code sections are unchanged',
SAME_VERSION: 'up-to-date: version is unchanged',
ERROR_MD5: 'error: MD5 is invalid',
ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style',
};
const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3;
let lastUpdateTime = parseInt(localStorage.lastUpdateTime) || Date.now();
let checkingAll = false;
let logQueue = [];
let logLastWriteTime = 0;
const retrying = new Set();
API_METHODS.updateCheckAll = checkAllStyles;
API_METHODS.updateCheck = checkStyle;
API_METHODS.getUpdaterStates = () => STATES;
prefs.subscribe(['updateInterval'], schedule);
schedule();
chrome.alarms.onAlarm.addListener(onAlarm);
return {checkAllStyles, checkStyle, STATES};
function checkAllStyles({
save = true,
ignoreDigest,
observe,
} = {}) {
resetInterval();
checkingAll = true;
retrying.clear();
const port = observe && chrome.runtime.connect({name: 'updater'});
return styleManager.getAllStyles().then(styles => {
styles = styles.filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length});
log('');
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
return Promise.all(
styles.map(style =>
checkStyle({style, port, save, ignoreDigest})));
}).then(() => {
if (port) port.postMessage({done: true});
if (port) port.disconnect();
log('');
checkingAll = false;
retrying.clear();
});
}
function checkStyle({
id,
style,
port,
save = true,
ignoreDigest,
}) {
/*
Original style digests are calculated in these cases:
* style is installed or updated from server
* style is checked for an update and its code is equal to the server code
Update check proceeds in these cases:
* style has the original digest and it's equal to the current digest
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
* [ignoreDigest: none/false] style doesn't yet have the original digest
so we compare the code to the server code and if it's the same we save the digest,
otherwise we skip the style and report MAYBE_EDITED status
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
return fetchStyle()
.then(() => {
if (!ignoreDigest) {
return calcStyleDigest(style)
.then(checkIfEdited);
}
})
.then(() => {
if (style.usercssData) {
return maybeUpdateUsercss();
}
return maybeUpdateUSO();
})
.then(maybeSave)
.then(reportSuccess)
.catch(reportFailure);
function fetchStyle() {
if (style) {
return Promise.resolve();
}
return styleManager.get(id)
.then(style_ => {
style = style_;
});
}
function reportSuccess(saved) {
log(STATES.UPDATED + ` #${style.id} ${style.customName || style.name}`);
const info = {updated: true, style: saved};
if (port) port.postMessage(info);
return info;
}
function reportFailure(error) {
if ((
error === 503 || // Service Unavailable
error === 429 // Too Many Requests
) && !retrying.has(id)) {
retrying.add(id);
return new Promise(resolve => {
setTimeout(() => {
resolve(checkStyle({id, style, port, save, ignoreDigest}));
}, 1000);
});
}
error = error === 0 ? 'server unreachable' : error;
// UserCSS metadata error returns an object; e.g. "Invalid @var color..."
if (typeof error === 'object' && error.message) {
error = error.message;
}
log(STATES.SKIPPED + ` (${error}) #${style.id} ${style.customName || style.name}`);
const info = {error, STATES, style: getStyleWithNoCode(style)};
if (port) port.postMessage(info);
return info;
}
function checkIfEdited(digest) {
if (style.originalDigest && style.originalDigest !== digest) {
return Promise.reject(STATES.EDITED);
}
}
function maybeUpdateUSO() {
return download(style.md5Url).then(md5 => {
if (!md5 || md5.length !== 32) {
return Promise.reject(STATES.ERROR_MD5);
}
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5);
}
// USO can't handle POST requests for style json
return download(style.updateUrl, {body: null})
.then(text => {
const style = tryJSONparse(text);
if (style) {
// USO may not provide a correctly updated originalMd5 (#555)
style.originalMd5 = md5;
}
return style;
});
});
}
function maybeUpdateUsercss() {
// TODO: when sourceCode is > 100kB use http range request(s) for version check
return download(style.updateUrl).then(text =>
usercss.buildMeta(text).then(json => {
const {usercssData: {version}} = style;
const {usercssData: {version: newVersion}} = json;
switch (Math.sign(semverCompare(version, newVersion))) {
case 0:
// re-install is invalid in a soft upgrade
if (!ignoreDigest) {
const sameCode = text === style.sourceCode;
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
}
break;
case 1:
// downgrade is always invalid
return Promise.reject(STATES.ERROR_VERSION);
}
return usercss.buildCode(json);
})
);
}
function maybeSave(json = {}) {
// usercss is already validated while building
if (!json.usercssData && !styleJSONseemsValid(json)) {
return Promise.reject(STATES.ERROR_JSON);
}
json.id = style.id;
json.updateDate = Date.now();
// keep current state
delete json.enabled;
const newStyle = Object.assign({}, style, json);
if (styleSectionsEqual(json, style, {checkSource: true})) {
// update digest even if save === false as there might be just a space added etc.
return styleManager.installStyle(newStyle)
.then(saved => {
style.originalDigest = saved.originalDigest;
return Promise.reject(STATES.SAME_CODE);
});
}
if (!style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.MAYBE_EDITED);
}
return save ?
API_METHODS[json.usercssData ? 'installUsercss' : 'installStyle'](newStyle) :
newStyle;
}
}
function schedule() {
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
if (interval > 0) {
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
chrome.alarms.create(ALARM_NAME, {
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
});
} else {
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
}
}
function onAlarm({name}) {
if (name === ALARM_NAME) checkAllStyles();
}
function resetInterval() {
localStorage.lastUpdateTime = lastUpdateTime = Date.now();
schedule();
}
function log(text) {
logQueue.push({text, time: new Date().toLocaleString()});
debounce(flushQueue, text && checkingAll ? 1000 : 0);
}
function flushQueue(lines) {
if (!lines) {
chromeLocal.getValue('updateLog', []).then(flushQueue);
return;
}
const time = Date.now() - logLastWriteTime > 11e3 ?
logQueue[0].time + ' ' :
'';
if (logQueue[0] && !logQueue[0].text) {
logQueue.shift();
if (lines[lines.length - 1]) lines.push('');
}
lines.splice(0, lines.length - 1000);
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
lines.push(...logQueue.slice(1).map(item => item.text));
chromeLocal.setValue('updateLog', lines);
logLastWriteTime = Date.now();
logQueue = [];
}
})();

View File

@ -1,131 +0,0 @@
/* global API_METHODS usercss styleManager deepCopy */
/* exported usercssHelper */
'use strict';
const usercssHelper = (() => {
API_METHODS.installUsercss = installUsercss;
API_METHODS.editSaveUsercss = editSaveUsercss;
API_METHODS.configUsercssVars = configUsercssVars;
API_METHODS.buildUsercss = build;
API_METHODS.findUsercss = find;
function buildMeta(style) {
if (style.usercssData) {
return Promise.resolve(style);
}
// allow sourceCode to be normalized
const {sourceCode} = style;
delete style.sourceCode;
return usercss.buildMeta(sourceCode)
.then(newStyle => Object.assign(newStyle, style));
}
function assignVars(style) {
return find(style)
.then(dup => {
if (dup) {
style.id = dup.id;
// preserve style.vars during update
return usercss.assignVars(style, dup)
.then(() => style);
}
return style;
});
}
/**
* Parse the source, find the duplication, and build sections with variables
* @param _
* @param {String} _.sourceCode
* @param {Boolean=} _.checkDup
* @param {Boolean=} _.metaOnly
* @param {Object} _.vars
* @param {Boolean=} _.assignVars
* @returns {Promise<{style, dup:Boolean?}>}
*/
function build({
styleId,
sourceCode,
checkDup,
metaOnly,
vars,
assignVars = false,
}) {
return usercss.buildMeta(sourceCode)
.then(style => {
const findDup = checkDup || assignVars ?
find(styleId ? {id: styleId} : style) : Promise.resolve();
return Promise.all([
metaOnly ? style : doBuild(style, findDup),
findDup
]);
})
.then(([style, dup]) => ({style, dup}));
function doBuild(style, findDup) {
if (vars || assignVars) {
const getOld = vars ? Promise.resolve({usercssData: {vars}}) : findDup;
return getOld
.then(oldStyle => usercss.assignVars(style, oldStyle))
.then(() => usercss.buildCode(style));
}
return usercss.buildCode(style);
}
}
// Build the style within aditional properties then inherit variable values
// from the old style.
function parse(style) {
return buildMeta(style)
.then(buildMeta)
.then(assignVars)
.then(usercss.buildCode);
}
// FIXME: simplify this to `installUsercss(sourceCode)`?
function installUsercss(style) {
return parse(style)
.then(styleManager.installStyle);
}
// FIXME: simplify this to `editSaveUsercss({sourceCode, exclusions})`?
function editSaveUsercss(style) {
return parse(style)
.then(styleManager.editSave);
}
function configUsercssVars(id, vars) {
return styleManager.get(id)
.then(style => {
const newStyle = deepCopy(style);
newStyle.usercssData.vars = vars;
return usercss.buildCode(newStyle);
})
.then(style => styleManager.installStyle(style, 'config'))
.then(style => style.usercssData.vars);
}
/**
* @param {Style|{name:string, namespace:string}} styleOrData
* @returns {Style}
*/
function find(styleOrData) {
if (styleOrData.id) {
return styleManager.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
return styleManager.getAllStyles().then(styleList => {
for (const dup of styleList) {
const data = dup.usercssData;
if (!data) continue;
if (data.name === name &&
data.namespace === namespace) {
return dup;
}
}
});
}
})();

View File

@ -1,66 +1,107 @@
/* global API_METHODS openURL download URLS tabManager */
/* global RX_META URLS download openURL */// toolbox.js
/* global addAPI bgReady */// common.js
/* global tabMan */// msg.js
'use strict';
(() => {
bgReady.all.then(() => {
const installCodeCache = {};
const clearInstallCode = url => delete installCodeCache[url];
const isContentTypeText = type => /^text\/(css|plain)(;.*?)?$/i.test(type);
// in Firefox we have to use a content script to read file://
const fileLoader = !chrome.app && (
async tabId =>
(await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
addAPI(/** @namespace API */ {
usercss: {
getInstallCode(url) {
// when the installer tab is reloaded after the cache is expired, this will throw intentionally
const {code, timer} = installCodeCache[url];
clearInstallCode(url);
clearTimeout(timer);
return code;
},
},
});
const urlLoader =
async (tabId, url) => (
url.startsWith('file:') ||
tabManager.get(tabId, isContentTypeText.name) ||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
) && download(url);
API_METHODS.getUsercssInstallCode = url => {
// when the installer tab is reloaded after the cache is expired, this will throw intentionally
const {code, timer} = installCodeCache[url];
clearInstallCode(url);
clearTimeout(timer);
return code;
// `glob`: pathname match pattern for webRequest
// `rx`: pathname regex to verify the URL really looks like a raw usercss
const maybeDistro = {
// https://github.com/StylishThemes/GitHub-Dark/raw/master/github-dark.user.css
'github.com': {
glob: '/*/raw/*',
rx: /^\/[^/]+\/[^/]+\/raw\/[^/]+\/[^/]+?\.user\.(css|styl)$/,
},
// https://raw.githubusercontent.com/StylishThemes/GitHub-Dark/master/github-dark.user.css
'raw.githubusercontent.com': {
glob: '/*',
rx: /^(\/[^/]+?){4}\.user\.(css|styl)$/,
},
};
// Faster installation on known distribution sites to avoid flicker of css text
chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => {
openInstallerPage(tabId, url, {});
// Silently suppressing navigation like it never happened
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
}, {
chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
urls: [
URLS.usoArchiveRaw + 'usercss/*.user.css',
'*://greasyfork.org/scripts/*/code/*.user.css',
'*://sleazyfork.org/scripts/*/code/*.user.css',
URLS.usw + 'api/style/*.user.css',
...URLS.usoArchiveRaw.map(s => s + 'usercss/*.user.css'),
...['greasy', 'sleazy'].map(s => `*://${s}fork.org/scripts/*/code/*.user.css`),
...[].concat(
...Object.entries(maybeDistro)
.map(([host, {glob}]) => makeUsercssGlobs(host, glob))),
],
types: ['main_frame'],
}, ['blocking']);
// Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow
chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
}, {
urls: '%css,%css?*,%styl,%styl?*'.replace(/%/g, '*://*/*.user.').split(','),
chrome.webRequest.onHeadersReceived.addListener(rememberContentType, {
urls: makeUsercssGlobs('*', '/*'),
types: ['main_frame'],
}, ['responseHeaders']);
tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => {
tabMan.onUpdate(maybeInstall);
function clearInstallCode(url) {
return delete installCodeCache[url];
}
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
function isContentTypeText(type) {
return /^text\/(?!html)/i.test(type);
}
// in Firefox we have to use a content script to read file://
async function loadFromFile(tabId) {
return (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0];
}
async function loadFromUrl(tabId, url) {
return (
url.startsWith('file:') ||
tabMan.get(tabId, isContentTypeText.name) ||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
) && download(url);
}
function makeUsercssGlobs(host, path) {
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
}
async function maybeInstall({tabId, url, oldUrl = ''}) {
if (url.includes('.user.') &&
/^(https?|file|ftps?):/.test(url) &&
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
!oldUrl.startsWith(URLS.installUsercss)) {
const inTab = url.startsWith('file:') && Boolean(fileLoader);
const code = await (inTab ? fileLoader : urlLoader)(tabId, url);
if (/==userstyle==/i.test(code)) {
const inTab = url.startsWith('file:') && !chrome.app;
const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
if (!/^\s*</.test(code) && RX_META.test(code)) {
openInstallerPage(tabId, url, {code, inTab});
}
}
});
}
/** Faster installation on known distribution sites to avoid flicker of css text */
function maybeInstallFromDistro({tabId, url}) {
const u = new URL(url);
const m = maybeDistro[u.hostname];
if (!m || m.rx.test(u.pathname)) {
openInstallerPage(tabId, url, {});
// Silently suppress navigation.
// Don't redirect to the install URL as it'll flash the text!
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
}
}
function openInstallerPage(tabId, url, {code, inTab} = {}) {
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
@ -79,4 +120,10 @@
chrome.tabs.update(tabId, {url: newUrl});
}
}
})();
/** Remember Content-Type to avoid wasting time to re-fetch in loadFromUrl **/
function rememberContentType({tabId, responseHeaders}) {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabMan.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
}
});

View File

@ -0,0 +1,160 @@
/* global API */// msg.js
/* global RX_META deepCopy download */// toolbox.js
'use strict';
const usercssMan = {
GLOBAL_META: Object.entries({
author: null,
description: null,
homepageURL: 'url',
updateURL: 'updateUrl',
name: null,
}),
async assignVars(style, oldStyle) {
const meta = style.usercssData;
const vars = meta.vars;
const oldVars = (oldStyle.usercssData || {}).vars;
if (vars && oldVars) {
// 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)) {
const old = oldVars[key] && oldVars[key].value;
if (old) v.value = old;
}
meta.vars = await API.worker.nullifyInvalidVars(vars);
}
},
async build({
styleId,
sourceCode,
vars,
checkDup,
metaOnly,
assignVars,
initialUrl,
}) {
// downloading here while install-usercss page is loading to avoid the wait
if (initialUrl) sourceCode = await download(initialUrl);
const style = await usercssMan.buildMeta({sourceCode});
const dup = (checkDup || assignVars) &&
await usercssMan.find(styleId ? {id: styleId} : style);
let log;
if (!metaOnly) {
if (vars || assignVars) {
await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup);
}
await usercssMan.buildCode(style);
log = style.log; // extracting the non-enumerable prop, otherwise it won't survive messaging
}
return {style, dup, log};
},
async buildCode(style) {
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
const match = code.match(RX_META);
const i = match.index;
const j = i + match[0].length;
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 recoverable = errors.every(e => e.recoverable);
if (!sections.length || !recoverable) {
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
}
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;
},
async buildMeta(style) {
if (style.usercssData) {
return style;
}
// remember normalized sourceCode
let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n');
style = Object.assign({
enabled: true,
sections: [],
}, style);
const match = code.match(RX_META);
if (!match) {
return Promise.reject(new Error('Could not find metadata.'));
}
try {
code = blankOut(code, 0, match.index) + match[0];
const {metadata} = await API.worker.parseUsercssMeta(code);
style.usercssData = metadata;
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
for (const [key, globalKey] of usercssMan.GLOBAL_META) {
const val = metadata[key];
if (val !== undefined) {
style[globalKey || key] = val;
}
}
return style;
} catch (err) {
if (err.code) {
const args = err.code === 'missingMandatory' || err.code === 'missingChar'
? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ')
: err.args;
const msg = chrome.i18n.getMessage(`meta_${(err.code)}`, args);
if (msg) err.message = msg;
}
return Promise.reject(err);
}
},
async configVars(id, vars) {
const style = deepCopy(await API.styles.get(id));
style.usercssData.vars = vars;
await usercssMan.buildCode(style);
return (await API.styles.install(style, 'config'))
.usercssData.vars;
},
async editSave(style) {
style = 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) {
if (styleOrData.id) {
return API.styles.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
for (const dup of await API.styles.getAll()) {
const data = dup.usercssData;
if (data &&
data.name === name &&
data.namespace === namespace) {
return dup;
}
}
},
async install(style) {
return API.styles.install(await usercssMan.parse(style));
},
async parse(style) {
style = await usercssMan.buildMeta(style);
// preserve style.vars during update
const dup = await usercssMan.find(style);
if (dup) {
style.id = dup.id;
await usercssMan.assignVars(style, dup);
}
return usercssMan.buildCode(style);
},
};
/** Replaces everything with spaces to keep the original length,
* but preserves the line breaks to keep the original line/col relation */
function blankOut(str, start = 0, end = str.length) {
return str.slice(start, end).replace(/[^\r\n]/g, ' ');
}

119
background/usw-api.js Normal file
View File

@ -0,0 +1,119 @@
/* 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]) => `${k}${' '.repeat(maxKeyLen - k.length + 2)}${v || ''}`),
'==/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 data = (style._usw || {}).token
? style._usw
: await linkStyle(style, sourceCode);
const header = style.usercssData ? '' : fakeUsercssHeader(style);
return uswFetch(`style/${data.id}`, data.token, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: header + sourceCode}),
});
},
/**
* @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

@ -1,34 +1,28 @@
/* global msg API prefs createStyleInjector */
/* global API msg */// msg.js
/* global StyleInjector */
/* global prefs */
'use strict';
// Chrome reruns content script when documentElement is replaced.
// Note, we're checking against a literal `1`, not just `if (truthy)`,
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`.
(() => {
if (window.INJECTED === 1) return;
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
self.INJECTED = 1;
let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html';
const IS_FRAME = window !== parent;
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
const styleInjector = createStyleInjector({
/** true -> when the page styles are received,
* false -> when disableAll mode is on at start, the styles won't be sent
* so while disableAll lasts we can ignore messages about style updates because
* the tab will explicitly ask for all styles in bulk when disableAll mode ends */
let hasStyles = false;
let isDisabled = false;
let isTab = !chrome.tabs || location.pathname !== '/popup.html';
const isFrame = window !== parent;
const isFrameAboutBlank = isFrame && location.href === 'about:blank';
const isUnstylable = !chrome.app && document instanceof XMLDocument;
const styleInjector = StyleInjector({
compare: (a, b) => a.id - b.id,
onUpdate: onInjectorUpdate,
});
const initializing = init();
/** @type chrome.runtime.Port */
let port;
let lazyBadge = IS_FRAME;
let parentDomain;
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
if (!IS_TAB) {
chrome.tabs.getCurrent(tab => {
IS_TAB = Boolean(tab);
if (tab && styleInjector.list.length) updateCount();
});
}
// dynamic iframes don't have a URL yet so we'll use their parent's URL (hash isn't inherited)
let matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href.split('#')[0]) ||
location.href;
// save it now because chrome.runtime will be unavailable in the orphaned script
const orphanEventId = chrome.runtime.id;
@ -36,6 +30,37 @@ self.INJECTED !== 1 && (() => {
// firefox doesn't orphanize content scripts so the old elements stay
if (!chrome.app) styleInjector.clearOrphans();
/** @type chrome.runtime.Port */
let port;
let lazyBadge = isFrame;
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 {IntersectionObserver} = window;
const xoEventId = `${Math.random()}`;
/** @type IntersectionObserver */
let xo;
if (IntersectionObserver) {
window[Symbol.for('xo')] = (el, cb) => {
if (!xo) xo = new IntersectionObserver(onIntersect, {rootMargin: '100%'});
el.addEventListener(xoEventId, cb, {once: true});
xo.observe(el);
};
}
// Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let
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
if (!isTab) {
chrome.tabs.getCurrent(tab => {
isTab = Boolean(tab);
if (tab && styleInjector.list.length) updateCount();
});
}
msg.onTab(applyOnMessage);
if (!chrome.tabs) {
@ -54,116 +79,107 @@ self.INJECTED !== 1 && (() => {
if (!isOrphaned) {
updateCount();
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
onOff(['disableAll'], updateDisableAll);
if (IS_FRAME) {
onOff('disableAll', updateDisableAll);
if (isFrame) {
updateExposeIframes();
onOff(['exposeIframes'], updateExposeIframes);
onOff('exposeIframes', updateExposeIframes);
}
}
}
async function init() {
if (STYLE_VIA_API) {
if (isUnstylable) {
await API.styleViaAPI({method: 'styleApply'});
} else {
const styles = chrome.app && getStylesViaXhr() ||
await API.getSectionsByUrl(getMatchUrl(), null, true);
if (styles.disableAll) {
delete styles.disableAll;
styleInjector.toggle(false);
const SYM_ID = 'styles';
const SYM = Symbol.for(SYM_ID);
const parentStyles = isFrameAboutBlank &&
tryCatch(() => parent[parent.Symbol.for(SYM_ID)]);
const styles =
window[SYM] ||
parentStyles && await new Promise(onFrameElementInView) && parentStyles ||
!isFrameAboutBlank && chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr) ||
await API.styles.getSectionsByUrl(matchUrl, null, true);
isDisabled = styles.disableAll;
hasStyles = !isDisabled;
if (hasStyles) {
window[SYM] = styles;
await styleInjector.apply(styles);
} else {
delete window[SYM];
prefs.subscribe('disableAll', updateDisableAll);
}
await styleInjector.apply(styles);
styleInjector.toggle(hasStyles);
}
}
/** Must be executed inside try/catch */
function getStylesViaXhr() {
if (new RegExp(`(^|\\s|;)${chrome.runtime.id}=\\s*([-\\w]+)\\s*(;|$)`).test(document.cookie)) {
const data = RegExp.$2;
const disableAll = data[0] === '1';
const url = 'blob:' + chrome.runtime.getURL(data.slice(1));
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
let res;
try {
if (!disableAll) { // will get the styles asynchronously
const xhr = new XMLHttpRequest();
xhr.open('GET', url, false); // synchronous
xhr.send();
res = JSON.parse(xhr.response);
}
URL.revokeObjectURL(url);
} catch (e) {}
return res;
}
}
function getMatchUrl() {
let matchUrl = location.href;
if (!chrome.tabs && !matchUrl.match(/^(http|file|chrome|ftp)/)) {
// dynamic about: and javascript: iframes don't have an URL yet
// so we'll try the parent frame which is guaranteed to have a real URL
try {
if (IS_FRAME) {
matchUrl = parent.location.href;
}
} catch (e) {}
}
return matchUrl;
const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
const url = 'blob:' + chrome.runtime.getURL(blobId);
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
const xhr = new XMLHttpRequest();
xhr.open('GET', url, false); // synchronous
xhr.send();
URL.revokeObjectURL(url);
return JSON.parse(xhr.response);
}
function applyOnMessage(request) {
if (STYLE_VIA_API) {
if (request.method === 'urlChanged') {
const {method} = request;
if (isUnstylable) {
if (method === 'urlChanged') {
request.method = 'styleReplaceAll';
}
if (/^(style|updateCount)/.test(request.method)) {
if (/^(style|updateCount)/.test(method)) {
API.styleViaAPI(request);
return;
}
}
switch (request.method) {
const {style} = request;
switch (method) {
case 'ping':
return true;
case 'styleDeleted':
styleInjector.remove(request.style.id);
styleInjector.remove(style.id);
break;
case 'styleUpdated':
if (request.style.enabled) {
API.getSectionsByUrl(getMatchUrl(), request.style.id)
.then(sections => {
if (!sections[request.style.id]) {
styleInjector.remove(request.style.id);
} else {
styleInjector.apply(sections);
}
});
if (!hasStyles && isDisabled) break;
if (style.enabled) {
API.styles.getSectionsByUrl(matchUrl, style.id).then(sections =>
sections[style.id]
? styleInjector.apply(sections)
: styleInjector.remove(style.id));
} else {
styleInjector.remove(request.style.id);
styleInjector.remove(style.id);
}
break;
case 'styleAdded':
if (request.style.enabled) {
API.getSectionsByUrl(getMatchUrl(), request.style.id)
if (!hasStyles && isDisabled) break;
if (style.enabled) {
API.styles.getSectionsByUrl(matchUrl, style.id)
.then(styleInjector.apply);
}
break;
case 'urlChanged':
API.getSectionsByUrl(getMatchUrl())
.then(styleInjector.replace);
if (!hasStyles && isDisabled || matchUrl === request.url) break;
matchUrl = request.url;
API.styles.getSectionsByUrl(matchUrl).then(sections => {
hasStyles = true;
styleInjector.replace(sections);
});
break;
case 'backgroundReady':
initializing
.catch(err => {
if (msg.RX_NO_RECEIVER.test(err.message)) {
return init();
}
})
.catch(console.error);
ready.catch(err =>
msg.isIgnorableError(err)
? init()
: console.error(err));
break;
case 'updateCount':
@ -173,8 +189,11 @@ self.INJECTED !== 1 && (() => {
}
function updateDisableAll(key, disableAll) {
if (STYLE_VIA_API) {
isDisabled = disableAll;
if (isUnstylable) {
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
} else if (!hasStyles && !disableAll) {
init();
} else {
styleInjector.toggle(!disableAll);
}
@ -196,8 +215,8 @@ self.INJECTED !== 1 && (() => {
}
function updateCount() {
if (!IS_TAB) return;
if (IS_FRAME) {
if (!isTab) return;
if (isFrame) {
if (!port && styleInjector.list.length) {
port = chrome.runtime.connect({name: 'iframe'});
} else if (port && !styleInjector.list.length) {
@ -205,23 +224,43 @@ self.INJECTED !== 1 && (() => {
}
if (lazyBadge && performance.now() > 1000) lazyBadge = false;
}
(STYLE_VIA_API ?
(isUnstylable ?
API.styleViaAPI({method: 'updateCount'}) :
API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
).catch(msg.ignoreError);
}
function orphanCheck() {
function onFrameElementInView(cb) {
if (IntersectionObserver) {
parent[parent.Symbol.for('xo')](frameElement, cb);
} else {
requestAnimationFrame(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) {
try {
if (chrome.i18n.getUILanguage()) return;
return func(...args);
} catch (e) {}
}
function orphanCheck() {
if (tryCatch(() => chrome.i18n.getUILanguage())) return;
// In Chrome content script is orphaned on an extension update/reload
// so we need to detach event listeners
window.removeEventListener(orphanEventId, orphanCheck, true);
isOrphaned = true;
styleInjector.clear();
try {
msg.off(applyOnMessage);
} catch (e) {}
setTimeout(styleInjector.clear, 1000); // avoiding FOUC
tryCatch(msg.off, applyOnMessage);
}
})();

View File

@ -1,4 +1,4 @@
/* global API */
/* global API */// msg.js
'use strict';
// onCommitted may fire twice
@ -13,7 +13,7 @@ if (window.INJECTED_GREASYFORK !== 1) {
e.data.name &&
e.data.type === 'style-version-query') {
removeEventListener('message', onMessage);
const style = await API.findUsercss(e.data) || {};
const style = await API.usercss.find(e.data) || {};
const {version} = style.usercssData || {};
postMessage({type: 'style-version', version}, '*');
}

View File

@ -1,174 +0,0 @@
/* global API */
'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.findUsercss({
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 = () => {
// 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();
}
};
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.installUsercss({
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,19 +1,21 @@
'use strict';
// preventing reregistration if reinjected by tabs.executeScript for whatever reason, just in case
if (typeof self.oldCode !== 'string') {
self.oldCode = (document.querySelector('body > pre') || document.body).textContent;
if (typeof window.oldCode !== 'string') {
window.oldCode = (document.querySelector('body > pre') || document.body).textContent;
chrome.runtime.onConnect.addListener(port => {
if (port.name !== 'downloadSelf') return;
port.onMessage.addListener(({id, force}) => {
fetch(location.href, {mode: 'same-origin'})
.then(r => r.text())
.then(code => ({id, code: force || code !== self.oldCode ? code : null}))
.catch(error => ({id, error: error.message || `${error}`}))
.then(msg => {
port.postMessage(msg);
if (msg.code != null) self.oldCode = msg.code;
});
port.onMessage.addListener(async ({id, force}) => {
const msg = {id};
try {
const code = await (await fetch(location.href, {mode: 'same-origin'})).text();
if (code !== window.oldCode || force) {
msg.code = window.oldCode = code;
}
} catch (error) {
msg.error = error.message || `${error}`;
}
port.postMessage(msg);
});
// FF keeps content scripts connected on navigation https://github.com/openstyles/stylus/issues/864
addEventListener('pagehide', () => port.disconnect(), {once: true});
@ -21,4 +23,4 @@ if (typeof self.oldCode !== 'string') {
}
// passing the result to tabs.executeScript
self.oldCode; // eslint-disable-line no-unused-expressions
window.oldCode; // eslint-disable-line no-unused-expressions

View File

@ -1,4 +1,4 @@
/* global cloneInto msg API */
/* global API msg */// msg.js
'use strict';
// eslint-disable-next-line no-unused-expressions
@ -14,17 +14,10 @@
msg.on(onMessage);
onDOMready().then(() => {
window.postMessage({
direction: 'from-content-script',
message: 'StylishInstalled',
}, '*');
});
let currentMd5;
const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
Promise.all([
API.findStyle({md5Url}),
API.styles.find({md5Url}),
getResource(md5Url),
onDOMready(),
]).then(checkUpdatability);
@ -85,7 +78,7 @@
const observer = new MutationObserver(check);
observer.observe(document.documentElement, {
childList: true,
subtree: true
subtree: true,
});
check();
@ -105,7 +98,7 @@
? 'styleCanBeUpdatedChrome'
: 'styleAlreadyInstalledChrome',
detail: {
updateUrl: installedStyle.updateUrl
updateUrl: installedStyle.updateUrl,
},
});
});
@ -119,13 +112,11 @@
if (typeof cloneInto !== 'undefined') {
// 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);
detail = cloneInto({detail}, document); /* global cloneInto */
} else {
detail = {detail};
}
onDOMready().then(() => {
document.dispatchEvent(new CustomEvent(type, detail));
});
document.dispatchEvent(new CustomEvent(type, detail));
}
function onClick(event) {
@ -154,9 +145,9 @@
function doInstall() {
let oldStyle;
return API.findStyle({
md5Url: getMeta('stylish-md5-url') || location.href
}, true)
return API.styles.find({
md5Url: getMeta('stylish-md5-url') || location.href,
})
.then(_oldStyle => {
oldStyle = _oldStyle;
return oldStyle ?
@ -172,7 +163,7 @@
});
}
function saveStyleCode(message, name, addProps = {}) {
async function saveStyleCode(message, name, addProps = {}) {
const isNew = message === 'styleInstall';
const needsConfirmation = isNew || !saveStyleCode.confirmed;
if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
@ -180,22 +171,19 @@
}
saveStyleCode.confirmed = true;
enableUpdateButton(false);
return getStyleJson().then(json => {
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
return API.installStyle(Object.assign(json, addProps, {originalMd5: currentMd5}))
.then(style => {
if (!isNew && style.updateUrl.includes('?')) {
enableUpdateButton(true);
} else {
sendEvent({type: 'styleInstalledChrome'});
}
});
});
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 enableUpdateButton(state) {
const important = s => s.replace(/;/g, '!important;');
@ -218,86 +206,59 @@
return e ? e.getAttribute('href') : null;
}
function getResource(url, options) {
if (url.startsWith('#')) {
return Promise.resolve(document.getElementById(url.slice(1)).textContent);
async function getResource(url, opts) {
try {
return url.startsWith('#')
? document.getElementById(url.slice(1)).textContent
: await API.download(url, opts);
} catch (error) {
alert('Error\n' + error.message);
return Promise.reject(error);
}
return API.download(Object.assign({
url,
timeout: 60e3,
// USO can't handle POST requests for style json
body: null,
}, options))
.catch(error => {
alert('Error' + (error ? '\n' + error : ''));
throw error;
});
}
// USO providing md5Url as "https://update.update.userstyles.org/#####.md5"
// instead of "https://update.userstyles.org/#####.md5"
function tryFixMd5(style) {
if (style && style.md5Url && style.md5Url.includes('update.update')) {
style.md5Url = style.md5Url.replace('update.update', 'update');
}
return style;
}
function getStyleJson() {
return getResource(getStyleURL(), {responseType: 'json'})
.then(style => {
if (!style || !Array.isArray(style.sections) || style.sections.length) {
return style;
}
const codeElement = document.getElementById('stylish-code');
if (codeElement && !codeElement.textContent.trim()) {
return style;
}
return getResource(getMeta('stylish-update-url'))
.then(code => API.parseCss({code}))
.then(result => {
style.sections = result.sections;
return style;
});
})
.then(tryFixMd5)
.catch(() => null);
}
function styleSectionsEqual({sections: a}, {sections: b}) {
if (!a || !b) {
return undefined;
}
if (a.length !== b.length) {
return false;
}
// order of sections should be identical to account for the case of multiple
// sections matching the same URL because the order of rules is part of cascading
return a.every((sectionA, index) => propertiesEqual(sectionA, b[index]));
function propertiesEqual(secA, secB) {
for (const name of ['urlPrefixes', 'urls', 'domains', 'regexps']) {
if (!equalOrEmpty(secA[name], secB[name], 'every', arrayMirrors)) {
return false;
}
async function getStyleJson() {
try {
const style = await getResource(getStyleURL(), {responseType: 'json'});
const codeElement = document.getElementById('stylish-code');
if (!style || !Array.isArray(style.sections) || style.sections.length ||
codeElement && !codeElement.textContent.trim()) {
return style;
}
return equalOrEmpty(secA.code, secB.code, 'substr', (a, b) => a === b);
}
const code = await getResource(getMeta('stylish-update-url'));
style.sections = (await API.worker.parseMozFormat({code})).sections;
if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update');
return style;
} catch (e) {}
}
function equalOrEmpty(a, b, telltale, comparator) {
const typeA = a && typeof a[telltale] === 'function';
const typeB = b && typeof b[telltale] === 'function';
return (
(a === null || a === undefined || (typeA && !a.length)) &&
(b === null || b === undefined || (typeB && !b.length))
) || typeA && typeB && a.length === b.length && comparator(a, b);
/**
* 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 arrayMirrors(array1, array2) {
return (
array1.every(el => array2.includes(el)) &&
array2.every(el => array1.includes(el))
);
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));
}
}
@ -343,6 +304,7 @@
function inPageContext(eventId) {
document.currentScript.remove();
window.isInstalled = true;
const origMethods = {
json: Response.prototype.json,
byId: document.getElementById,
@ -365,13 +327,33 @@ function inPageContext(eventId) {
Response.prototype.json = origMethods.json;
const images = new Map();
for (const ss of json.style_settings) {
const value = vars.get('ik-' + ss.install_key);
if (value && ss.setting_type === 'image' && ss.style_setting_options) {
let value = vars.get('ik-' + ss.install_key);
if (!value || !(ss.style_setting_options || [])[0]) {
continue;
}
if (value.startsWith('ik-')) {
value = value.replace(/^ik-/, '');
const def = ss.style_setting_options.find(item => item.default);
if (!def || def.install_key !== value) {
if (def) def.default = false;
for (const item of ss.style_setting_options) {
if (item.install_key === 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;
}
}
}
if (images.size) {

View File

@ -0,0 +1,43 @@
/* 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

@ -1,6 +1,7 @@
'use strict';
self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
/** @type {function(opts):StyleInjector} */
window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
compare,
onUpdate = () => {},
}) => {
@ -8,8 +9,6 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
const PATCH_ID = 'transition-patch';
// styles are out of order if any of these elements is injected between them
const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']);
// detect Chrome 65 via a feature it added since browser version can be spoofed
const isChromePre65 = chrome.app && typeof Worklet !== 'function';
const docRewriteObserver = RewriteObserver(_sort);
const docRootObserver = RootObserver(_sortIfNeeded);
const list = [];
@ -19,22 +18,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
// will store the original method refs because the page can override them
let creationDoc, createElement, createElementNS;
return {
return /** @namespace StyleInjector */ {
list,
apply(styleMap) {
async apply(styleMap) {
const styles = _styleMapToArray(styleMap);
return (
!styles.length ?
Promise.resolve([]) :
docRootObserver.evade(() => {
if (!isTransitionPatched && isEnabled) {
_applyTransitionPatch(styles);
}
return styles.map(_addUpdate);
})
).then(_emitUpdate);
const value = !styles.length
? []
: await docRootObserver.evade(() => {
if (!isTransitionPatched && isEnabled) {
_applyTransitionPatch(styles);
}
return styles.map(_addUpdate);
});
_emitUpdate();
return value;
},
clear() {
@ -157,10 +156,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
docRootObserver[onOff]();
}
function _emitUpdate(value) {
function _emitUpdate() {
_toggleObservers(list.length);
onUpdate();
return value;
}
/*
@ -232,17 +230,8 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
function _update({id, code}) {
const style = table.get(id);
if (style.code === code) return;
style.code = code;
// workaround for Chrome devtools bug fixed in v65
if (isChromePre65) {
const oldEl = style.el;
style.el = _createStyle(id, code);
if (isEnabled) {
oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling);
oldEl.remove();
}
} else {
if (style.code !== code) {
style.code = code;
style.el.textContent = code;
}
}

394
edit.html
View File

@ -1,112 +1,67 @@
<!DOCTYPE html>
<html id="stylus">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="global.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
<link rel="stylesheet" href="msgbox/msgbox.css">
<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/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/toolbox.js"></script>
<script src="js/msg.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="edit/edit.js"></script> <!-- run it ASAP to send a request for the style -->
<script src="js/sections-util.js"></script>
<script src="edit/codemirror-themes.js"></script> <!-- must precede base.js -->
<script src="edit/base.js"></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<script src="vendor/codemirror/lib/codemirror.js"></script>
<script src="vendor/codemirror/mode/css/css.js"></script>
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<script src="vendor/codemirror/mode/stylus/stylus.js"></script>
<script src="vendor/codemirror/addon/dialog/dialog.js"></script>
<script src="vendor/codemirror/addon/edit/closebrackets.js"></script>
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
<script src="vendor/codemirror/addon/scroll/annotatescrollbar.js"></script>
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
<script src="vendor/codemirror/addon/comment/comment.js"></script>
<script src="vendor/codemirror/addon/selection/active-line.js"></script>
<script src="vendor/codemirror/addon/edit/matchbrackets.js"></script>
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
<script src="vendor/codemirror/addon/fold/comment-fold.js"></script>
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/lint/lint.js"></script>
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/hint/show-hint.js"></script>
<script src="vendor/codemirror/addon/hint/css-hint.js"></script>
<script src="vendor/codemirror/keymap/sublime.js"></script>
<script src="vendor/codemirror/keymap/emacs.js"></script>
<script src="vendor/codemirror/keymap/vim.js"></script>
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
<script src="vendor-overwrites/colorpicker/colorview.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="msgbox/msgbox.js" async></script>
<script src="js/color/color-converter.js"></script>
<script src="js/color/color-mimicry.js"></script>
<script src="js/color/color-picker.js"></script>
<script src="js/color/color-view.js"></script>
<script src="js/storage-util.js"></script>
<script src="js/worker-util.js"></script>
<link href="edit/codemirror-default.css" rel="stylesheet">
<script src="edit/util.js"></script>
<script src="edit/codemirror-default.js"></script>
<script src="edit/codemirror-factory.js"></script>
<script src="edit/util.js"></script>
<script src="edit/regexp-tester.js"></script>
<script src="edit/live-preview.js"></script>
<script src="edit/applies-to-line-widget.js"></script>
<script src="edit/reroute-hotkeys.js"></script>
<link href="edit/global-search.css" rel="stylesheet">
<script src="edit/global-search.js"></script>
<script src="edit/colorpicker-helper.js"></script>
<script src="edit/moz-section-finder.js"></script>
<script src="edit/moz-section-widget.js"></script>
<script src="edit/linter-manager.js"></script>
<script src="edit/beautify.js"></script>
<script src="edit/show-keymap-help.js"></script>
<script src="edit/codemirror-themes.js"></script>
<script src="edit/source-editor.js"></script>
<script src="edit/sections-editor-section.js"></script>
<script src="edit/sections-editor.js"></script>
<script src="js/worker-util.js"></script>
<script src="edit/linter.js"></script>
<script src="edit/linter-defaults.js"></script>
<script src="edit/linter-engines.js"></script>
<script src="edit/linter-meta.js"></script>
<script src="edit/linter-help-dialog.js"></script>
<script src="edit/linter-report.js"></script>
<script src="edit/linter-config-dialog.js"></script>
<script src="edit/usw-integration.js"></script>
<script src="edit/edit.js"></script>
<template data-id="appliesTo">
<li class="applies-to-item">
@ -121,10 +76,10 @@
</div>
<div class="applies-value-wrapper">
<input name="applies-value" class="applies-value style-contributor" spellcheck="false">
<a class="remove-applies-to" href="#" i18n-text="appliesRemove" i18n-title="appliesRemove">
<a class="remove-applies-to" i18n-text="appliesRemove" i18n-title="appliesRemove" tabindex="0">
<svg class="svg-icon remove"><use xlink:href="#svg-icon-minus"/></svg>
</a>
<a class="add-applies-to" href="#" i18n-text="appliesAdd" i18n-title="appliesAdd">
<a class="add-applies-to" i18n-text="appliesAdd" i18n-title="appliesAdd" tabindex="0">
<svg class="svg-icon add"><use xlink:href="#svg-icon-plus"/></svg>
</a>
</div>
@ -133,7 +88,7 @@
<template data-id="appliesToEverything">
<li class="applies-to-everything" i18n-text="appliesToEverything">
<a class="add-applies-to" i18n-text="appliesAdd" i18n-title="appliesAdd" href="#">
<a class="add-applies-to" i18n-text="appliesAdd" i18n-title="appliesAdd" tabindex="0">
<svg class="svg-icon add"><use xlink:href="#svg-icon-plus"/></svg>
</a>
</li>
@ -148,7 +103,7 @@
<label i18n-text="sectionCode" class="code-label"></label>
<div class="applies-to">
<label i18n-text="appliesLabel">
<a href="#" class="svg-inline-wrapper applies-to-help" tabindex="0">
<a class="svg-inline-wrapper applies-to-help" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</label>
@ -171,14 +126,14 @@
<div data-type="main">
<div data-type="content"></div>
<div data-type="actions">
<a data-action="case" i18n-title="searchCaseSensitive" href="#" tabindex="0">Aa</a>
<a data-action="prev" i18n-title="genericPrevious" href="#" data-hotkey-tooltip="findPrev" tabindex="0">
<a data-action="case" i18n-title="searchCaseSensitive" tabindex="0">Aa</a>
<a data-action="prev" i18n-title="genericPrevious" data-hotkey-tooltip="findPrev" tabindex="0">
<svg class="svg-icon" style="transform: rotate(180deg)"><use xlink:href="#svg-icon-v"/></svg>
</a>
<a data-action="next" i18n-title="genericNext" href="#" data-hotkey-tooltip="findNext" tabindex="0">
<a data-action="next" i18n-title="genericNext" data-hotkey-tooltip="findNext" tabindex="0">
<svg class="svg-icon"><use xlink:href="#svg-icon-v"/></svg>
</a>
<a data-action="close" i18n-title="confirmClose" href="#" data-hotkey-tooltip="=Esc" tabindex="0">
<a data-action="close" i18n-title="confirmClose" data-hotkey-tooltip="=Esc" tabindex="0">
<svg class="svg-icon dismiss"><use xlink:href="#svg-icon-close"/></svg>
</a>
</div>
@ -274,15 +229,25 @@
</tbody>
</table>
</template>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet">
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet">
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet">
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<link href="js/color/color-picker.css" rel="stylesheet">
<link href="edit/codemirror-default.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
</head>
<body id="stylus-edit">
<div id="header">
<h1 id="heading">&nbsp;</h1> <!-- nbsp allocates the actual height which prevents page shift -->
<h1 id="heading" i18n-data-edit="editStyleHeading" i18n-data-add="addStyleTitle"></h1>
<section id="basic-info">
<div id="basic-info-name">
<input id="name" class="style-contributor" spellcheck="false">
<a id="reset-name" href="#" i18n-title="customNameResetHint" tabindex="0" hidden>
<a id="reset-name" i18n-title="customNameResetHint" tabindex="0" hidden>
<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
5.5,16.2 10,11.7 14.5,16.2 16.2,14.5 11.7,10 "/>
@ -298,7 +263,7 @@
<input type="checkbox" id="enabled" class="style-contributor">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
<label id="preview-label" i18n-text="previewLabel" i18n-title="previewTooltip" class="hidden">
<label id="preview-label" i18n-text="previewLabel" i18n-title="previewTooltip">
<input type="checkbox" id="editor.livePreview">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
@ -311,154 +276,164 @@
<button id="beautify" i18n-text="styleBeautify"></button>
<a href="manage.html" tabindex="-1"><button id="cancel-button" i18n-text="styleCancelEditLabel"></button></a>
</div>
<div id="mozilla-format-container">
<h2 id="mozilla-format-heading" i18n-text="styleMozillaFormatHeading">
<a id="to-mozilla-help" class="svg-inline-wrapper" href="#" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</h2>
<div id="mozilla-format-buttons">
<button id="from-mozilla" i18n-text="importLabel"></button>
<button id="to-mozilla" i18n-text="exportLabel"></button>
</div>
<div id="mozilla-format-buttons" class="sectioned-only">
<button id="from-mozilla" i18n-text="importLabel"></button>
<button id="to-mozilla" i18n-text="exportLabel"></button>
<a id="to-mozilla-help" class="svg-inline-wrapper" tabindex="0"
i18n-title="styleMozillaFormatHeading">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</div>
</section>
<details id="options" data-pref="editor.options.expanded">
<summary><h2 id="options-heading" i18n-text="optionsHeading"></h2></summary>
<div id="options-wrapper">
<div class="options-column">
<div class="option">
<label id="lineWrapping-label" i18n-text="cm_lineWrapping">
<input id="editor.lineWrapping" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label id="smartIndent-label" i18n-text="cm_smartIndent">
<input id="editor.smartIndent" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label id="indentWithTabs-label" i18n-text="cm_indentWithTabs">
<input id="editor.indentWithTabs" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_autoCloseBrackets" i18n-title="cm_autoCloseBracketsTooltip">
<input id="editor.autoCloseBrackets" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_autocompleteOnTyping">
<input id="editor.autocompleteOnTyping" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_selectByTokens"
i18n-title="cm_selectByTokensTooltip">
<input id="editor.selectByTokens" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_colorpicker">
<input id="editor.colorpicker" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
<a id="colorpicker-settings" href="#" class="svg-inline-wrapper" i18n-title="shortcutsNote" tabindex="0">
<svg class="svg-icon settings"><use xlink:href="#svg-icon-settings"/></svg>
</a>
</div>
<div class="option usercss-only">
<label i18n-text="appliesLineWidgetLabel" i18n-title="appliesLineWidgetWarning">
<input id="editor.appliesToLineWidget" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
</div>
<div class="options-column">
<div class="option aligned">
<label id="tabSize-label" for="editor.tabSize" i18n-text="cm_tabSize"></label>
<input id="editor.tabSize" type="number" min="0">
</div>
<div class="option aligned">
<label id="keyMap-label" for="editor.keyMap" i18n-text="cm_keyMap"></label>
<div class="select-resizer">
<select id="editor.keyMap"></select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
<div id="details-wrapper">
<details id="options" data-pref="editor.options.expanded" class="ignore-pref-if-compact">
<summary><h2 id="options-heading" i18n-text="optionsHeading"></h2></summary>
<div id="options-wrapper">
<div class="options-column">
<div class="option">
<label id="lineWrapping-label" i18n-text="cm_lineWrapping">
<input id="editor.lineWrapping" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<a id="keyMap-help" href="#" class="svg-inline-wrapper" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</div>
<div class="option aligned">
<label id="theme-label" for="editor.theme" i18n-text="cm_theme"></label>
<div class="select-resizer">
<select id="editor.theme"></select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
<div class="option">
<label id="smartIndent-label" i18n-text="cm_smartIndent">
<input id="editor.smartIndent" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label id="indentWithTabs-label" i18n-text="cm_indentWithTabs">
<input id="editor.indentWithTabs" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_autoCloseBrackets" i18n-title="cm_autoCloseBracketsTooltip">
<input id="editor.autoCloseBrackets" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_autocompleteOnTyping">
<input id="editor.autocompleteOnTyping" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_selectByTokens"
i18n-title="cm_selectByTokensTooltip">
<input id="editor.selectByTokens" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
<div class="option">
<label i18n-text="cm_colorpicker">
<input id="editor.colorpicker" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
<a id="colorpicker-settings" class="svg-inline-wrapper" i18n-title="shortcutsNote" tabindex="0">
<svg class="svg-icon settings"><use xlink:href="#svg-icon-config"/></svg>
</a>
</div>
<div class="option usercss-only">
<label i18n-text="appliesLineWidgetLabel" i18n-title="appliesLineWidgetWarning">
<input id="editor.appliesToLineWidget" type="checkbox">
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
</label>
</div>
</div>
<div class="option aligned">
<label id="highlight-label" for="editor.matchHighlight" i18n-text="cm_matchHighlight"></label>
<div class="select-resizer">
<select id="editor.matchHighlight">
<option i18n-text="cm_matchHighlightToken" value="token">
<option i18n-text="cm_matchHighlightSelection" value="selection">
<option i18n-text="genericDisabledLabel" value="">
</select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
<div class="options-column">
<div class="option aligned">
<label id="tabSize-label" for="editor.tabSize" i18n-text="cm_tabSize"></label>
<input id="editor.tabSize" type="number" min="0">
</div>
</div>
<div class="option aligned">
<label id="linter-label" for="editor.linter" i18n-text="cm_linter"></label>
<div class="option aligned">
<label id="keyMap-label" for="editor.keyMap" i18n-text="cm_keyMap"></label>
<div class="select-resizer">
<select id="editor.linter">
<option value="csslint" selected>CSSLint</option>
<option value="stylelint">Stylelint</option>
<option value="" i18n-text="genericDisabledLabel"></option>
<select id="editor.keyMap"></select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div>
<a id="keyMap-help" class="svg-inline-wrapper" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</div>
<div class="option aligned">
<label id="theme-label" for="editor.theme" i18n-text="cm_theme"></label>
<div class="select-resizer">
<select id="editor.theme"></select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div>
</div>
<div class="option aligned">
<label id="highlight-label" for="editor.matchHighlight" i18n-text="cm_matchHighlight"></label>
<div class="select-resizer">
<select id="editor.matchHighlight">
<option i18n-text="cm_matchHighlightToken" value="token">
<option i18n-text="cm_matchHighlightSelection" value="selection">
<option i18n-text="genericDisabledLabel" value="">
</select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div>
<a id="linter-settings" href="#" class="svg-inline-wrapper" i18n-title="linterConfigTooltip" tabindex="0">
<svg class="svg-icon settings"><use xlink:href="#svg-icon-settings"/></svg>
</a>
</div>
<div class="option aligned">
<label id="linter-label" for="editor.linter" i18n-text="cm_linter"></label>
<div class="select-resizer">
<select id="editor.linter">
<option value="csslint" selected>CSSLint</option>
<option value="stylelint">Stylelint</option>
<option value="" i18n-text="genericDisabledLabel"></option>
</select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div>
<a id="linter-settings" class="svg-inline-wrapper" i18n-title="linterConfigTooltip" tabindex="0">
<svg class="svg-icon settings"><use xlink:href="#svg-icon-config"/></svg>
</a>
</div>
</div>
</div>
</div>
</details>
<details id="lint" class="hidden-unless-compact" data-pref="editor.lint.expanded">
<summary>
<h2 i18n-text="linterIssues">: <span id="issue-count"></span>
<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>
</a>
</h2>
</summary>
<div class="lint-scroll-container">
<div class="lint-report-container"></div>
</div>
</details>
</details>
<details id="publish" data-pref="editor.publish.expanded" class="ignore-pref-if-compact">
<summary><h2 i18n-text="publish"></h2></summary>
<div>
<a id="usw-url" href="https://userstyles.world" target="_blank">&nbsp;</a>
<div id="usw-link-info">
<dl><dt i18n-text="styleName"></dt><dd data-usw="name"></dd></dl>
<dl><dt i18n-text="genericDescription"></dt><dd data-usw="description"></dd></dl>
</div>
<div>
<button id="usw-publish-style"
i18n-data-publish="publishStyle"
i18n-data-push="publishPush"></button>
<button id="usw-disconnect" i18n-text="optionsSyncDisconnect"></button>
<span id="usw-progress"></span>
</div>
</div>
</details>
<details id="sections-list" data-pref="editor.toc.expanded" class="ignore-pref-if-compact">
<summary><h2 i18n-text="sections"></h2></summary>
<ol id="toc"></ol>
</details>
<details id="lint" data-pref="editor.lint.expanded" class="hidden-unless-compact ignore-pref-if-compact">
<summary>
<h2 i18n-text="linterIssues">: <span id="issue-count"></span>
<a id="lint-help" class="svg-inline-wrapper intercepts-click" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</h2>
</summary>
<div class="lint-scroll-container">
<div class="lint-report-container"></div>
</div>
</details>
</div>
<div id="footer" class="hidden">
<a href="https://github.com/openstyles/stylus/wiki/Usercss"
i18n-text="externalUsercssDocument"
target="_blank"></a>
</div>
</div>
<section id="sections">
<!--
It seems that we don't use these anymore
https://github.com/openstyles/stylus/blob/5cbe8a8d780a6eb9fce11d5846e92bf244c3a3f3/edit/sections.js#L18
-->
<!-- <h2><span id="sections-heading" i18n-text="styleSectionsTitle"></span>
<a id="sections-help" href="#" class="svg-inline-wrapper" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>
</h2> -->
</section>
<section id="sections"></section>
<div id="help-popup">
<div class="title"></div><svg id="sections-help" class="svg-icon dismiss"><use xlink:href="#svg-icon-close"/></svg>
<div class="contents"></div>
@ -482,8 +457,8 @@
<path d="M8,11.5L2.8,6.3l1.5-1.5L8,8.6l3.7-3.7l1.5,1.5L8,11.5z"/>
</symbol>
<symbol id="svg-icon-settings" viewBox="0 0 16 16">
<path d="M8,0C7.6,0,7.3,0,6.9,0.1v2.2C6.1,2.5,5.4,2.8,4.8,3.2L3.2,1.6c-0.6,0.4-1.1,1-1.6,1.6l1.6,1.6C2.8,5.4,2.5,6.1,2.3,6.9H0.1C0,7.3,0,7.6,0,8c0,0.4,0,0.7,0.1,1.1h2.2c0.1,0.8,0.4,1.5,0.9,2.1l-1.6,1.6c0.4,0.6,1,1.1,1.6,1.6l1.6-1.6c0.6,0.4,1.4,0.7,2.1,0.9v2.2C7.3,16,7.6,16,8,16c0.4,0,0.7,0,1.1-0.1v-2.2c0.8-0.1,1.5-0.4,2.1-0.9l1.6,1.6c0.6-0.4,1.1-1,1.6-1.6l-1.6-1.6c0.4-0.6,0.7-1.4,0.9-2.1h2.2C16,8.7,16,8.4,16,8c0-0.4,0-0.7-0.1-1.1h-2.2c-0.1-0.8-0.4-1.5-0.9-2.1l1.6-1.6c-0.4-0.6-1-1.1-1.6-1.6l-1.6,1.6c-0.6-0.4-1.4-0.7-2.1-0.9V0.1C8.7,0,8.4,0,8,0z M8,4.3c2.1,0,3.7,1.7,3.7,3.7c0,0,0,0,0,0c0,2.1-1.7,3.7-3.7,3.7c0,0,0,0,0,0c-2.1,0-3.7-1.7-3.7-3.7c0,0,0,0,0,0C4.3,5.9,5.9,4.3,8,4.3C8,4.3,8,4.3,8,4.3z"/>
<symbol id="svg-icon-config" viewBox="0 0 16 16">
<path d="M13.3,12.8l1.5-2.6l-2.2-1.5c0-0.2,0.1-0.5,0.1-0.7c0-0.2,0-0.5-0.1-0.7l2.2-1.5l-1.5-2.6l-2.4,1.2 c-0.4-0.3-0.8-0.5-1.2-0.7L9.5,1h-3L6.3,3.7C5.9,3.8,5.5,4.1,5.1,4.4L2.7,3.2L1.2,5.8l2.2,1.5c0,0.2-0.1,0.5-0.1,0.7 c0,0.2,0,0.5,0.1,0.7l-2.2,1.5l1.5,2.6l2.4-1.2c0.4,0.3,0.8,0.5,1.2,0.7L6.5,15h3l0.2-2.7c0.4-0.2,0.8-0.4,1.2-0.7L13.3,12.8z M8,10.3c-1.3,0-2.3-1-2.3-2.3c0-1.3,1-2.3,2.3-2.3c1.3,0,2.3,1,2.3,2.3C10.3,9.3,9.3,10.3,8,10.3z"/>
</symbol>
<symbol id="svg-icon-select-arrow" viewBox="0 0 1792 1792">
@ -503,6 +478,5 @@
</symbol>
</svg>
</body>
</html>

View File

@ -1,590 +0,0 @@
/* global regExpTester debounce messageBox CodeMirror template colorMimicry msg
$ $create t prefs tryCatch deepEqual */
/* exported createAppliesToLineWidget */
'use strict';
function createAppliesToLineWidget(cm) {
const THROTTLE_DELAY = 400;
const RX_SPACE = /(?:\s+|\/\*)+/y;
let TPL, EVENTS, CLICK_ROUTE;
let widgets = [];
let fromLine, toLine, actualStyle;
let initialized = false;
return {toggle};
function toggle(newState = !initialized) {
newState = Boolean(newState);
if (newState !== initialized) {
if (newState) {
init();
} else {
uninit();
}
}
}
function init() {
initialized = true;
TPL = {
container:
$create('div.applies-to', [
$create('label', t('appliesLabel')),
$create('ul.applies-to-list'),
]),
listItem: template.appliesTo.cloneNode(true),
appliesToEverything:
$create('li.applies-to-everything', t('appliesToEverything')),
};
$('.applies-value', TPL.listItem).insertAdjacentElement('afterend',
$create('button.test-regexp', t('styleRegexpTestButton')));
CLICK_ROUTE = {
'.test-regexp': showRegExpTester,
'.remove-applies-to': (item, apply, event) => {
event.preventDefault();
const applies = item.closest('.applies-to').__applies;
const i = applies.indexOf(apply);
let repl;
let from;
let to;
if (applies.length < 2) {
messageBox({
contents: t('appliesRemoveError'),
buttons: [t('confirmClose')]
});
return;
}
if (i === 0) {
from = apply.mark.find().from;
to = applies[i + 1].mark.find().from;
repl = '';
} else if (i === applies.length - 1) {
from = applies[i - 1].mark.find().to;
to = apply.mark.find().to;
repl = '';
} else {
from = applies[i - 1].mark.find().to;
to = applies[i + 1].mark.find().from;
repl = ', ';
}
cm.replaceRange(repl, from, to, 'appliesTo');
clearApply(apply);
item.remove();
applies.splice(i, 1);
},
'.add-applies-to': (item, apply, event) => {
event.preventDefault();
const applies = item.closest('.applies-to').__applies;
const i = applies.indexOf(apply);
const pos = apply.mark.find().to;
const text = `, ${apply.type.text}("")`;
cm.replaceRange(text, pos, pos, 'appliesTo');
const newApply = createApply(
cm.indexFromPos(pos) + 2,
apply.type.text,
'',
true
);
setupApplyMarkers(newApply);
applies.splice(i + 1, 0, newApply);
item.insertAdjacentElement('afterend', buildChildren(applies, newApply));
},
};
EVENTS = {
onchange({target}) {
const typeElement = target.closest('.applies-type');
if (typeElement) {
const item = target.closest('.applies-to-item');
const apply = item.__apply;
changeItem(item, apply, 'type', typeElement.value);
item.dataset.type = apply.type.text;
} else {
return EVENTS.oninput.apply(this, arguments);
}
},
oninput({target}) {
if (target.matches('.applies-value')) {
const item = target.closest('.applies-to-item');
const apply = item.__apply;
changeItem(item, apply, 'value', target.value);
}
},
onclick(event) {
const {target} = event;
for (const selector in CLICK_ROUTE) {
const routed = target.closest(selector);
if (routed) {
const item = routed.closest('.applies-to-item');
CLICK_ROUTE[selector].call(routed, item, item.__apply, event);
return;
}
}
}
};
actualStyle = $create('style');
fromLine = 0;
toLine = cm.doc.size;
cm.on('change', onChange);
cm.on('optionChange', onOptionChange);
msg.onExtension(onRuntimeMessage);
requestAnimationFrame(updateWidgetStyle);
update();
}
function uninit() {
initialized = false;
widgets.forEach(clearWidget);
widgets.length = 0;
cm.off('change', onChange);
cm.off('optionChange', onOptionChange);
msg.off(onRuntimeMessage);
actualStyle.remove();
}
function onChange(cm, event) {
const {from, to, origin} = event;
if (origin === 'appliesTo') {
return;
}
const lastChanged = CodeMirror.changeEnd(event).line;
fromLine = Math.min(fromLine === null ? from.line : fromLine, from.line);
toLine = Math.max(toLine === null ? lastChanged : toLine, to.line);
if (origin === 'setValue') {
update();
} else {
debounce(update, THROTTLE_DELAY);
}
}
function onOptionChange(cm, option) {
if (option === 'theme') {
updateWidgetStyle();
}
}
function onRuntimeMessage(msg) {
if (msg.reason === 'editPreview' && !$(`#stylus-${msg.style.id}`)) {
// no style element with this id means the style doesn't apply to the editor URL
return;
}
if (msg.style || msg.styles ||
msg.prefs && 'disableAll' in msg.prefs ||
msg.method === 'styleDeleted') {
requestAnimationFrame(updateWidgetStyle);
}
}
function update() {
const changed = {fromLine, toLine};
fromLine = Math.max(fromLine || 0, cm.display.viewFrom);
toLine = Math.min(toLine === null ? cm.doc.size : toLine, cm.display.viewTo || toLine);
const visible = {fromLine, toLine};
const {curOp} = cm;
if (fromLine >= cm.display.viewFrom && toLine <= (cm.display.viewTo || toLine)) {
if (!curOp) cm.startOperation();
doUpdate();
if (!curOp) cm.endOperation();
}
if (changed.fromLine !== visible.fromLine || changed.toLine !== visible.toLine) {
setTimeout(updateInvisible, 0, changed, visible);
}
}
function updateInvisible(changed, visible) {
let inOp = false;
if (changed.fromLine < visible.fromLine) {
fromLine = Math.min(fromLine, changed.fromLine);
toLine = Math.min(changed.toLine, visible.fromLine);
inOp = true;
cm.startOperation();
doUpdate();
}
if (changed.toLine > visible.toLine) {
fromLine = Math.max(fromLine, changed.toLine);
toLine = Math.max(changed.toLine, visible.toLine);
if (!inOp) {
inOp = true;
cm.startOperation();
}
doUpdate();
}
if (inOp) {
cm.endOperation();
}
}
function updateWidgetStyle() {
if (prefs.get('editor.theme') !== 'default' &&
!tryCatch(() => $('#cm-theme').sheet.cssRules)) {
requestAnimationFrame(updateWidgetStyle);
return;
}
const MIN_LUMA = .05;
const MIN_LUMA_DIFF = .4;
const color = {
wrapper: colorMimicry.get(cm.display.wrapper),
gutter: colorMimicry.get(cm.display.gutters, {
bg: 'backgroundColor',
border: 'borderRightColor',
}),
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
};
const hasBorder =
color.gutter.style.borderRightWidth !== '0px' &&
!/transparent|\b0\)/g.test(color.gutter.style.borderRightColor);
const diff = {
wrapper: Math.abs(color.gutter.bgLuma - color.wrapper.foreLuma),
border: hasBorder ? Math.abs(color.gutter.bgLuma - color.gutter.borderLuma) : 0,
line: Math.abs(color.gutter.bgLuma - color.line.foreLuma),
};
const preferLine = diff.line > diff.wrapper || diff.line > MIN_LUMA_DIFF;
const fore = preferLine ? color.line.fore : color.wrapper.fore;
const border = fore.replace(/[\d.]+(?=\))/, MIN_LUMA_DIFF / 2);
const borderStyleForced = `1px ${hasBorder ? color.gutter.style.borderRightStyle : 'solid'} ${border}`;
actualStyle.textContent = `
.applies-to {
background-color: ${color.gutter.bg};
border-top: ${borderStyleForced};
border-bottom: ${borderStyleForced};
}
.applies-to label {
color: ${fore};
}
.applies-to input,
.applies-to button,
.applies-to select {
background: rgba(255, 255, 255, ${
Math.max(MIN_LUMA, Math.pow(Math.max(0, color.gutter.bgLuma - MIN_LUMA * 2), 2)).toFixed(2)
});
border: ${borderStyleForced};
transition: none;
color: ${fore};
}
.applies-to .svg-icon.select-arrow {
fill: ${fore};
transition: none;
}
`;
document.documentElement.appendChild(actualStyle);
}
function doUpdate() {
// find which widgets needs to be update
// some widgets (lines) might be deleted
widgets = widgets.filter(w => w.line.lineNo() !== null);
let i = widgets.findIndex(w => w.line.lineNo() > fromLine) - 1;
let j = widgets.findIndex(w => w.line.lineNo() > toLine);
if (i === -2) {
i = widgets.length - 1;
}
if (j < 0) {
j = widgets.length;
}
// decide search range
const fromPos = {line: widgets[i] ? widgets[i].line.lineNo() : 0, ch: 0};
const toPos = {line: widgets[j] ? widgets[j].line.lineNo() : toLine + 1, ch: 0};
// calc index->pos lookup table
let index = 0;
const lineIndexes = [0];
cm.doc.iter(0, toPos.line + 1, ({text}) => {
lineIndexes.push((index += text.length + 1));
});
// splice
i = Math.max(0, i);
widgets.splice(i, 0, ...createWidgets(fromPos, toPos, widgets.splice(i, j - i), lineIndexes));
fromLine = null;
toLine = null;
}
function *createWidgets(start, end, removed, lineIndexes) {
let i = 0;
let itemHeight;
for (const section of findAppliesTo(start, end, lineIndexes)) {
let removedWidget = removed[i];
while (removedWidget && removedWidget.line.lineNo() < section.pos.line) {
clearWidget(removed[i]);
removedWidget = removed[++i];
}
if (removedWidget && deepEqual(removedWidget.node.__applies, section.applies, ['mark'])) {
yield removedWidget;
i++;
continue;
}
for (const a of section.applies) {
setupApplyMarkers(a, lineIndexes);
}
if (removedWidget && removedWidget.line.lineNo() === section.pos.line) {
// reuse old widget
removedWidget.section.applies.forEach(apply => {
apply.type.mark.clear();
apply.value.mark.clear();
});
removedWidget.section = section;
const newNode = buildElement(section);
const removedNode = removedWidget.node;
if (removedNode.parentNode) {
removedNode.parentNode.replaceChild(newNode, removedNode);
}
removedWidget.node = newNode;
removedWidget.changed();
yield removedWidget;
i++;
continue;
}
// new widget
const widget = cm.addLineWidget(section.pos.line, buildElement(section), {
coverGutter: true,
noHScroll: true,
above: true,
height: itemHeight ? section.applies.length * itemHeight : undefined,
});
widget.section = section;
itemHeight = itemHeight || widget.node.offsetHeight / (section.applies.length || 1);
yield widget;
}
removed.slice(i).forEach(clearWidget);
}
function clearWidget(widget) {
widget.clear();
widget.section.applies.forEach(clearApply);
}
function clearApply(apply) {
apply.type.mark.clear();
apply.value.mark.clear();
apply.mark.clear();
}
function setupApplyMarkers(apply, lineIndexes) {
apply.type.mark = cm.markText(
posFromIndex(cm, apply.type.start, lineIndexes),
posFromIndex(cm, apply.type.end, lineIndexes),
{clearWhenEmpty: false}
);
apply.value.mark = cm.markText(
posFromIndex(cm, apply.value.start, lineIndexes),
posFromIndex(cm, apply.value.end, lineIndexes),
{clearWhenEmpty: false}
);
apply.mark = cm.markText(
posFromIndex(cm, apply.start, lineIndexes),
posFromIndex(cm, apply.end, lineIndexes),
{clearWhenEmpty: false}
);
}
function posFromIndex(cm, index, lineIndexes) {
if (!lineIndexes) {
return cm.posFromIndex(index);
}
let line = lineIndexes.prev || 0;
const prev = lineIndexes[line];
const next = lineIndexes[line + 1];
if (prev <= index && index < next) {
return {line, ch: index - prev};
}
let a = index < prev ? 0 : line;
let b = index < next ? line + 1 : lineIndexes.length - 1;
while (a < b - 1) {
const mid = (a + b) >> 1;
if (lineIndexes[mid] < index) {
a = mid;
} else {
b = mid;
}
}
line = lineIndexes[b] > index ? a : b;
Object.defineProperty(lineIndexes, 'prev', {value: line, configurable: true});
return {line, ch: index - lineIndexes[line]};
}
function buildElement({applies}) {
const container = TPL.container.cloneNode(true);
const list = $('.applies-to-list', container);
for (const apply of applies) {
list.appendChild(buildChildren(applies, apply));
}
if (!list.children[0]) {
list.appendChild(TPL.appliesToEverything.cloneNode(true));
}
return Object.assign(container, EVENTS, {__applies: applies});
}
function buildChildren(applies, apply) {
const el = TPL.listItem.cloneNode(true);
el.dataset.type = apply.type.text;
el.__apply = apply;
$('.applies-type', el).value = apply.type.text;
$('.applies-value', el).value = apply.value.text;
return el;
}
function changeItem(itemElement, apply, part, newText) {
if (!apply) {
return;
}
part = apply[part];
const range = part.mark.find();
part.mark.clear();
newText = unescapeDoubleslash(newText).replace(/\\/g, '\\\\');
cm.replaceRange(newText, range.from, range.to, 'appliesTo');
part.mark = cm.markText(
range.from,
cm.findPosH(range.from, newText.length, 'char'),
{clearWhenEmpty: false}
);
part.text = newText;
if (part === apply.type) {
const range = apply.mark.find();
apply.mark.clear();
apply.mark = cm.markText(
part.mark.find().from,
range.to,
{clearWhenEmpty: false}
);
}
if (apply.type.text === 'regexp' && apply.value.text.trim()) {
showRegExpTester(itemElement);
}
}
function createApply(pos, typeText, valueText, isQuoted = false) {
typeText = typeText.toLowerCase();
const start = pos;
const typeStart = start;
const typeEnd = typeStart + typeText.length;
const valueStart = typeEnd + 1 + Number(isQuoted);
const valueEnd = valueStart + valueText.length;
const end = valueEnd + Number(isQuoted) + 1;
return {
start,
type: {
text: typeText,
start: typeStart,
end: typeEnd,
},
value: {
text: unescapeDoubleslash(valueText),
start: valueStart,
end: valueEnd,
},
end
};
}
function *findAppliesTo(posStart, posEnd, lineIndexes) {
const funcRe = /^(url|url-prefix|domain|regexp)$/i;
let pos;
const eatToken = sticky => {
if (!sticky) skipSpace(pos, posEnd);
pos.ch++;
const token = cm.getTokenAt(pos, true);
pos.ch = token.end;
return CodeMirror.cmpPos(pos, posEnd) <= 0 ? token : {};
};
const docCur = cm.getSearchCursor('@-moz-document', posStart);
while (docCur.findNext() &&
CodeMirror.cmpPos(docCur.pos.to, posEnd) <= 0) {
// CM can be nitpicky at token boundary so we'll check the next character
const safePos = {line: docCur.pos.from.line, ch: docCur.pos.from.ch + 1};
if (/\b(string|comment)\b/.test(cm.getTokenTypeAt(safePos))) continue;
const applies = [];
pos = docCur.pos.to;
do {
skipSpace(pos, posEnd);
const funcIndex = lineIndexes[pos.line] + pos.ch;
const func = eatToken().string;
// no space allowed before the opening parenthesis
if (!funcRe.test(func) || eatToken(true).string !== '(') break;
const url = eatToken();
if (url.type !== 'string' || eatToken().string !== ')') break;
const unquotedUrl = unquote(url.string);
const apply = createApply(
funcIndex,
func,
unquotedUrl,
unquotedUrl !== url.string
);
applies.push(apply);
} while (eatToken().string === ',');
yield {
pos: docCur.pos.from,
applies
};
}
}
function skipSpace(pos, posEnd) {
let {ch, line} = pos;
let lookForEnd;
line--;
cm.doc.iter(pos.line, posEnd.line + 1, ({text}) => {
line++;
while (true) {
if (lookForEnd) {
ch = text.indexOf('*/', ch) + 1;
if (!ch) {
return;
}
ch++;
lookForEnd = false;
}
// EOL is a whitespace so we'll check the next line
if (ch >= text.length) {
ch = 0;
return;
}
RX_SPACE.lastIndex = ch;
const m = RX_SPACE.exec(text);
if (!m) {
return true;
}
ch += m[0].length;
lookForEnd = m[0].includes('/*');
if (ch < text.length && !lookForEnd) {
return true;
}
}
});
pos.line = line;
pos.ch = ch;
}
function unquote(s) {
const first = s.charAt(0);
return (first === '"' || first === "'") && s.endsWith(first) ? s.slice(1, -1) : s;
}
function unescapeDoubleslash(s) {
const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/.test(s);
return hasSingleEscapes ? s : s.replace(/\\\\/g, '\\');
}
function showRegExpTester(item) {
regExpTester.toggle(true);
regExpTester.update(
item.closest('.applies-to').__applies
.filter(a => a.type.text === 'regexp')
.map(a => unescapeDoubleslash(a.value.text)));
}
}

269
edit/autocomplete.js Normal file
View File

@ -0,0 +1,269 @@
/* global CodeMirror */
/* global cmFactory */
/* global debounce */// toolbox.js
/* global editor */
/* global linterMan */
/* global prefs */
'use strict';
/* Registers 'hint' helper and 'autocompleteOnTyping' option in CodeMirror */
(() => {
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const rxPROP = /^(prop(erty)?|variable-2)\b/;
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
const cssMime = CodeMirror.mimeModes['text/css'];
const docFuncs = addSuffix(cssMime.documentTypes, '(');
const {tokenHooks} = cssMime;
const originalCommentHook = tokenHooks['/'];
const originalHelper = CodeMirror.hint.css || (() => {});
let cssMedia, cssProps, cssValues;
const AOT_ID = 'autocompleteOnTyping';
const AOT_PREF_ID = 'editor.' + AOT_ID;
const aot = prefs.get(AOT_PREF_ID);
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', 'stylus', helper);
tokenHooks['/'] = tokenizeUsoVariables;
async function helper(cm) {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
const isStylusLang = cm.doc.mode.name === 'stylus';
const type = style && style.split(' ', 1)[0] || 'prop?';
if (!type || type === 'comment' || type === 'string') {
return originalHelper(cm);
}
// not using getTokenAt until the need is unavoidable because it reparses text
// and runs a whole lot of complex calc inside which is slow on long lines
// especially if autocomplete is auto-shown on each keystroke
let prev, end, state;
let i = index;
while (
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
(prev = i > 2 ? styles[i - 2] : 0) &&
isSameToken(text, style, prev)
) i -= 2;
i = index;
while (
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
(end = styles[i]) &&
isSameToken(text, style, end)
) i += 2;
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
const str = text.slice(prev, end);
const left = text.slice(prev, ch).trim();
let leftLC = left.toLowerCase();
let list;
switch (leftLC[0]) {
case '!':
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
break;
case '@':
list = [
'@-moz-document',
'@charset',
'@font-face',
'@import',
'@keyframes',
'@media',
'@namespace',
'@page',
'@supports',
'@viewport',
];
break;
case '#': // prevents autocomplete for #hex colors
break;
case '-': // --variable
case '(': // var(
list = str.startsWith('--') || testAt(rxVAR, ch - 5, text)
? findAllCssVars(cm, left)
: [];
if (str.startsWith('(')) {
prev++;
leftLC = left.slice(1);
} else {
leftLC = left;
}
break;
case '/': // USO vars
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
prev += 4;
end -= 4;
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
leftLC = left.slice(4);
}
break;
case 'u': // url(), url-prefix()
case 'd': // domain()
case 'r': // regexp()
if (/^(variable|tag|error)/.test(type) &&
docFuncs.some(s => s.startsWith(leftLC)) &&
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
end++;
list = docFuncs;
break;
}
// fallthrough to `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 (!cssValues) cssValues = await linterMan.worker.getCssPropsValues();
list = [...new Set([...cssValues.own[prop] || [], ...cssValues.global])];
end = prev + execAt(/(\s*[-a-z(]+)?/y, prev, text)[0].length;
}
}
// properties and media features
if (!list &&
/^(prop(erty|\?)|atom|error)/.test(type) &&
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
if (!cssProps) initCssProps();
if (type === 'prop?') {
prev += leftLC.length;
leftLC = '';
}
list = state === 'atBlock_parens' ? cssMedia : cssProps;
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
end += execAt(rxCONSUME, end, text)[0].length;
}
if (!list) {
return isStylusLang
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
: originalHelper(cm);
}
}
return {
list: (list || []).filter(s => s.startsWith(leftLC)),
from: {line, ch: prev + str.match(/^\s*/)[0].length},
to: {line, ch: end},
};
}
function initCssProps() {
cssProps = addSuffix(cssMime.propertyKeywords);
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
}
function addSuffix(obj, suffix = ': ') {
// Sorting first, otherwise "foo-bar:" would precede "foo:"
return Object.keys(obj).sort().map(k => k + suffix);
}
function getMediaKeys([k, v]) {
return k === 'mediaFeatures' && addSuffix(v) ||
k.startsWith('media') && Object.keys(v);
}
/** makes sure we don't process a different adjacent comment */
function isSameToken(text, style, i) {
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
}
function findAllCssVars(cm, leftPart) {
// simplified regex without CSS escapes
const rx = new RegExp(
'(?:^|[\\s/;{])(' +
(leftPart.startsWith('--') ? leftPart : '--') +
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
'g');
const list = new Set();
cm.eachLine(({text}) => {
for (let m; (m = rx.exec(text));) {
list.add(m[1]);
}
});
return [...list].sort();
}
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] === 'comment') {
const {string, start, pos} = stream;
if (testAt(/\/\*\[\[/y, start, string) &&
testAt(/]]\*\//y, pos - 4, string)) {
const vars = (editor.style.usercssData || {}).vars;
token[0] =
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
? USO_VALID_VAR
: USO_INVALID_VAR;
}
}
return token;
}
function execAt(rx, index, text) {
rx.lastIndex = index;
return rx.exec(text);
}
function testAt(rx, index, text) {
rx.lastIndex = Math.max(0, index);
return rx.test(text);
}
function autocompleteOnTyping(cm, [info], debounced) {
const lastLine = info.text[info.text.length - 1];
if (cm.state.completionActive ||
info.origin && !info.origin.includes('input') ||
!lastLine) {
return;
}
if (cm.state.autocompletePicked) {
cm.state.autocompletePicked = false;
return;
}
if (!debounced) {
debounce(autocompleteOnTyping, 100, cm, [info], true);
return;
}
if (lastLine.match(/[-a-z!]+$/i)) {
cm.state.autocompletePicked = false;
cm.options.hintOptions.completeSingle = false;
cm.execCommand('autocomplete');
setTimeout(() => {
cm.options.hintOptions.completeSingle = true;
});
}
}
function autocompletePicked(cm) {
cm.state.autocompletePicked = true;
}
})();

418
edit/base.js Normal file
View File

@ -0,0 +1,418 @@
/* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
/* global API */// msg.js
/* global CODEMIRROR_THEMES */
/* global CodeMirror */
/* global MozDocMapper */// sections-util.js
/* global initBeautifyButton */// beautify.js
/* global prefs */
/* global t */// localization.js
/* global
FIREFOX
debounce
getOwnTab
sessionStore
tryJSONparse
tryURL
*/// toolbox.js
'use strict';
/**
* @type Editor
* @namespace Editor
*/
const editor = {
style: null,
dirty: DirtyReporter(),
isUsercss: false,
isWindowed: false,
lazyKeymaps: {
emacs: '/vendor/codemirror/keymap/emacs',
vim: '/vendor/codemirror/keymap/vim',
},
livePreview: null,
/** @type {'customName'|'name'} */
nameTarget: 'name',
previewDelay: 200, // Chrome devtools uses 200
scrollInfo: null,
onStyleUpdated() {
document.documentElement.classList.toggle('is-new-style', !editor.style.id);
},
updateTitle(isDirty = editor.dirty.isDirty()) {
const {customName, name} = editor.style;
document.title = `${
isDirty ? '* ' : ''
}${
customName || name || t('styleMissingName')
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
},
};
//#region pre-init
const baseInit = (() => {
const domReady = waitForSelector('#sections');
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([editor.lazyKeymaps.emacs]) ||
/vim/i.test(km) && require([editor.lazyKeymaps.vim]);
}
async function loadStyle() {
const params = new URLSearchParams(location.search);
const id = Number(params.get('id'));
const style = id && await API.styles.get(id) || {
name: params.get('domain') ||
tryURL(params.get('url-prefix')).hostname ||
'',
enabled: true,
sections: [
MozDocMapper.toSection([...params], {code: ''}),
],
};
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
editor.style = style;
editor.onStyleUpdated();
editor.updateTitle(false);
document.documentElement.classList.toggle('usercss', editor.isUsercss);
sessionStore.justEditedStyleId = style.id || '';
// no such style so let's clear the invalid URL parameters
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 (!CODEMIRROR_THEMES.includes(theme)) {
prefs.set('editor.theme', 'default');
return;
}
if (theme !== 'default') {
const el = $('#cm-theme');
const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
el2.id = el.id;
el.remove();
// FF containers take more time to load CSS
for (let retry = 0; !el2.sheet && ++retry <= 10;) {
await new Promise(requestAnimationFrame);
}
}
}
})();
//#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 el of $$('details[data-pref]')) {
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
//#region init header
baseInit.ready.then(() => {
initBeautifyButton($('#beautify'));
initKeymapElement();
initNameArea();
initThemeElement();
setupLivePrefs();
require(Object.values(editor.lazyKeymaps), () => {
initKeymapElement();
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
});
function findKeyForCommand(command, map) {
if (typeof map === 'string') map = CodeMirror.keyMap[map];
let key = Object.keys(map).find(k => map[k] === command);
if (key) {
return key;
}
for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
key = ft && findKeyForCommand(command, ft);
if (key) {
return key;
}
}
return '';
}
function initNameArea() {
const nameEl = $('#name');
const resetEl = $('#reset-name');
const isCustomName = editor.style.updateUrl || editor.isUsercss;
editor.nameTarget = isCustomName ? 'customName' : 'name';
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.on('input', () => {
editor.updateName(true);
resetEl.hidden = false;
});
resetEl.hidden = !editor.style.customName;
resetEl.onclick = () => {
const {style} = editor;
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;
};
const enabledEl = $('#enabled');
enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
}
function initThemeElement() {
$('#editor.theme').append(...[
$create('option', {value: 'default'}, t('defaultTheme')),
...CODEMIRROR_THEMES.map(s => $create('option', s)),
]);
// move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme'));
}
function initKeymapElement() {
// move 'pc' or 'mac' prefix to the end of the displayed label
const maps = Object.keys(CodeMirror.keyMap)
.map(name => ({
value: name,
name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
}))
.sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
const fragment = document.createDocumentFragment();
let bin = fragment;
let groupName;
// group suffixed maps in <optgroup>
maps.forEach(({value, name}, i) => {
groupName = !name.includes('-') ? name : groupName;
const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
if (groupWithNext) {
if (bin === fragment) {
bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
}
}
const el = bin.appendChild($create('option', {value}, name));
if (value === prefs.defaults['editor.keyMap']) {
el.dataset.default = '';
el.title = t('defaultTheme');
}
if (!groupWithNext) bin = fragment;
});
const selector = $('#editor.keyMap');
selector.textContent = '';
selector.appendChild(fragment);
selector.value = prefs.get('editor.keyMap');
}
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
const extraKeys = CodeMirror.defaults.extraKeys;
for (const el of $$('[data-hotkey-tooltip]')) {
if (el._hotkeyTooltipKeyMap !== mapName) {
el._hotkeyTooltipKeyMap = mapName;
const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
const cmd = el.dataset.hotkeyTooltip;
const key = cmd[0] === '=' ? cmd.slice(1) :
findKeyForCommand(cmd, mapName) ||
extraKeys && findKeyForCommand(cmd, extraKeys);
const newTitle = title + (title && key ? '\n' : '') + (key || '');
if (el.title !== newTitle) el.title = newTitle;
}
}
}
});
//#endregion
//#region init windowed mode
(() => {
let ownTabId;
if (chrome.windows) {
initWindowedMode();
const pos = tryJSONparse(sessionStore.windowPos);
delete sessionStore.windowPos;
// resize the window on 'undo close'
if (pos && pos.left != null) {
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
}
}
getOwnTab().then(async tab => {
ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
await baseInit.domReady;
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
}
});
async function initWindowedMode() {
chrome.tabs.onAttached.addListener(onTabAttached);
const isSimple = (await browser.windows.getCurrent()).type === 'popup';
if (isSimple) require(['/edit/embedded-popup']);
editor.isWindowed = isSimple || (
history.length === 1 &&
await prefs.ready && prefs.get('openEditInWindow') &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
}
async function onTabAttached(tabId, info) {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
const win = await browser.windows.get(info.newWindowId, {populate: true});
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
// FF-only because Chrome retardedly resets the size during dragging
if (openEditInWindow && FIREFOX) {
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
}
})();
//#endregion
//#region internals
/** @returns DirtyReporter */
function DirtyReporter() {
const data = new Map();
const listeners = new Set();
const notifyChange = wasDirty => {
if (wasDirty !== (data.size > 0)) {
listeners.forEach(cb => cb());
}
};
/** @namespace DirtyReporter */
return {
add(obj, value) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
data.set(obj, {type: 'add', newValue: value});
} else if (saved.type === 'remove') {
if (saved.savedValue === value) {
data.delete(obj);
} else {
saved.newValue = value;
saved.type = 'modify';
}
}
notifyChange(wasDirty);
},
clear(obj) {
const wasDirty = data.size > 0;
if (obj === undefined) {
data.clear();
} else {
data.delete(obj);
}
notifyChange(wasDirty);
},
has(key) {
return data.has(key);
},
isDirty() {
return data.size > 0;
},
modify(obj, oldValue, newValue) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
if (oldValue !== newValue) {
data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
data.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
notifyChange(wasDirty);
},
onChange(cb, add = true) {
listeners[add ? 'add' : 'delete'](cb);
},
remove(obj, value) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
data.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
data.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
notifyChange(wasDirty);
},
};
}
//#endregion

View File

@ -1,20 +1,18 @@
/* global loadScript css_beautify showHelp prefs t $ $create */
/* global editor createHotkeyInput moveFocus CodeMirror */
/* exported initBeautifyButton */
/* global $ $create moveFocus */// dom.js
/* global CodeMirror */
/* global createHotkeyInput helpPopup */// util.js
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
const HOTKEY_ID = 'editor.beautify.hotkey';
CodeMirror.commands.beautify = cm => {
// using per-section mode when code editor or applies-to block is focused
const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement);
beautify(isPerSection ? [cm] : editor.getEditors(), false);
};
prefs.initializing.then(() => {
CodeMirror.defaults.extraKeys[prefs.get(HOTKEY_ID) || ''] = 'beautify';
CodeMirror.commands.beautify = cm => {
// using per-section mode when code editor or applies-to block is focused
const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement);
beautify(isPerSection ? [cm] : editor.getEditors(), false);
};
});
prefs.subscribe([HOTKEY_ID], (key, value) => {
prefs.subscribe('editor.beautify.hotkey', (key, value) => {
const {extraKeys} = CodeMirror.defaults;
for (const [key, cmd] of Object.entries(extraKeys)) {
if (cmd === 'beautify') {
@ -25,164 +23,151 @@ prefs.subscribe([HOTKEY_ID], (key, value) => {
if (value) {
extraKeys[value] = 'beautify';
}
});
/**
* @param {HTMLElement} btn - the button element shown in the UI
* @param {function():CodeMirror[]} getScope
*/
function initBeautifyButton(btn, getScope) {
btn.addEventListener('click', () => beautify(getScope()));
btn.addEventListener('contextmenu', e => {
e.preventDefault();
beautify(getScope(), false);
});
}
}, {runNow: true});
/**
* @name beautify
* @param {CodeMirror[]} scope
* @param {?boolean} ui
* @param {boolean} [ui=true]
*/
function beautify(scope, ui = true) {
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
.then(() => {
if (!window.css_beautify && window.exports) {
window.css_beautify = window.exports.css_beautify;
}
})
.then(doBeautify);
async function beautify(scope, ui = true) {
await require(['/vendor-overwrites/beautify/beautify-css-mod']); /* global css_beautify */
const tabs = prefs.get('editor.indentWithTabs');
const options = Object.assign(prefs.defaults['editor.beautify'], prefs.get('editor.beautify'));
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
options.indent_char = tabs ? '\t' : ' ';
if (ui) {
createBeautifyUI(scope, options);
}
for (const cm of scope) {
setTimeout(beautifyEditor, 0, cm, options, ui);
}
}
function doBeautify() {
const tabs = prefs.get('editor.indentWithTabs');
const options = Object.assign({}, prefs.get('editor.beautify'));
for (const k of Object.keys(prefs.defaults['editor.beautify'])) {
if (!(k in options)) options[k] = prefs.defaults['editor.beautify'][k];
function beautifyEditor(cm, options, ui) {
const pos = options.translate_positions =
[].concat.apply([], cm.doc.sel.ranges.map(r =>
[Object.assign({}, r.anchor), Object.assign({}, r.head)]));
const text = cm.getValue();
const newText = css_beautify(text, options);
if (newText !== text) {
if (!cm.beautifyChange || !cm.beautifyChange[cm.changeGeneration()]) {
// clear the list if last change wasn't a css-beautify
cm.beautifyChange = {};
}
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
options.indent_char = tabs ? '\t' : ' ';
cm.setValue(newText);
const selections = [];
for (let i = 0; i < pos.length; i += 2) {
selections.push({anchor: pos[i], head: pos[i + 1]});
}
const {scrollX, scrollY} = window;
cm.setSelections(selections);
window.scrollTo(scrollX, scrollY);
cm.beautifyChange[cm.changeGeneration()] = true;
if (ui) {
createBeautifyUI(scope, options);
}
for (const cm of scope) {
setTimeout(doBeautifyEditor, 0, cm, options);
}
}
function doBeautifyEditor(cm, options) {
const pos = options.translate_positions =
[].concat.apply([], cm.doc.sel.ranges.map(r =>
[Object.assign({}, r.anchor), Object.assign({}, r.head)]));
const text = cm.getValue();
const newText = css_beautify(text, options);
if (newText !== text) {
if (!cm.beautifyChange || !cm.beautifyChange[cm.changeGeneration()]) {
// clear the list if last change wasn't a css-beautify
cm.beautifyChange = {};
}
cm.setValue(newText);
const selections = [];
for (let i = 0; i < pos.length; i += 2) {
selections.push({anchor: pos[i], head: pos[i + 1]});
}
const {scrollX, scrollY} = window;
cm.setSelections(selections);
window.scrollTo(scrollX, scrollY);
cm.beautifyChange[cm.changeGeneration()] = true;
if (ui) {
$('#help-popup button[role="close"]').disabled = false;
}
}
}
function createBeautifyUI(scope, options) {
showHelp(t('styleBeautify'),
$create([
$create('.beautify-options', [
$createOption('.selector1,', 'selector_separator_newline'),
$createOption('.selector2', 'newline_before_open_brace'),
$createOption('{', 'newline_after_open_brace'),
$createOption('border: none;', 'newline_between_properties', true),
$createOption('display: block;', 'newline_before_close_brace', true),
$createOption('}', 'newline_between_rules'),
$createLabeledCheckbox('preserve_newlines', 'styleBeautifyPreserveNewlines'),
$createLabeledCheckbox('indent_conditional', 'styleBeautifyIndentConditional'),
]),
$create('p.beautify-hint', [
$create('span', t('styleBeautifyHint') + '\u00A0'),
createHotkeyInput(HOTKEY_ID, () => moveFocus($('#help-popup'), 1)),
]),
$create('.buttons', [
$create('button', {
attributes: {role: 'close'},
// showHelp.close will be defined after showHelp() is invoked
onclick: () => showHelp.close(),
}, t('confirmClose')),
$create('button', {
attributes: {role: 'undo'},
onclick() {
let undoable = false;
for (const cm of scope) {
const data = cm.beautifyChange;
if (!data || !data[cm.changeGeneration()]) continue;
delete data[cm.changeGeneration()];
const {scrollX, scrollY} = window;
cm.undo();
cm.scrollIntoView(cm.getCursor());
window.scrollTo(scrollX, scrollY);
undoable |= data[cm.changeGeneration()];
}
this.disabled = !undoable;
},
}, t(scope.length === 1 ? 'undo' : 'undoGlobal')),
]),
]));
$('#help-popup').className = 'wide';
$('.beautify-options').onchange = ({target}) => {
const value = target.type === 'checkbox' ? target.checked : target.selectedIndex > 0;
prefs.set('editor.beautify', Object.assign(options, {[target.dataset.option]: value}));
if (target.parentNode.hasAttribute('newline')) {
target.parentNode.setAttribute('newline', value.toString());
}
doBeautify();
};
function $createOption(label, optionName, indent) {
const value = options[optionName];
return (
$create('div', {attributes: {newline: value}}, [
$create('span', indent ? {attributes: {indent: ''}} : {}, label),
$create('div.select-resizer', [
$create('select', {dataset: {option: optionName}}, [
$create('option', {selected: !value}, '\xA0'),
$create('option', {selected: value}, '\\n'),
]),
$create('SVG:svg.svg-icon.select-arrow', {viewBox: '0 0 1792 1792'}, [
$create('SVG:path', {
'fill-rule': 'evenodd',
'd': 'M1408 704q0 26-19 45l-448 448q-19 19-45 ' +
'19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z'
}),
]),
]),
])
);
}
function $createLabeledCheckbox(optionName, i18nKey) {
return (
$create('label', {style: 'display: block; clear: both;'}, [
$create('input', {
type: 'checkbox',
dataset: {option: optionName},
checked: options[optionName] !== false
}),
$create('SVG:svg.svg-icon.checked',
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
t(i18nKey),
])
);
$('#help-popup button[role="close"]').disabled = false;
}
}
}
function createBeautifyUI(scope, options) {
helpPopup.show(t('styleBeautify'),
$create([
$create('.beautify-options', [
$createOption('.selector1,', 'selector_separator_newline'),
$createOption('.selector2', 'newline_before_open_brace'),
$createOption('{', 'newline_after_open_brace'),
$createOption('border: none;', 'newline_between_properties', true),
$createOption('display: block;', 'newline_before_close_brace', true),
$createOption('}', 'newline_between_rules'),
$createLabeledCheckbox('preserve_newlines', 'styleBeautifyPreserveNewlines'),
$createLabeledCheckbox('indent_conditional', 'styleBeautifyIndentConditional'),
]),
$create('p.beautify-hint', [
$create('span', t('styleBeautifyHint') + '\u00A0'),
createHotkeyInput('editor.beautify.hotkey', {
buttons: false,
onDone: () => moveFocus($('#help-popup'), 0),
}),
]),
$create('.buttons', [
$create('button', {
attributes: {role: 'close'},
onclick: helpPopup.close,
}, t('confirmClose')),
$create('button', {
attributes: {role: 'undo'},
onclick() {
let undoable = false;
for (const cm of scope) {
const data = cm.beautifyChange;
if (!data || !data[cm.changeGeneration()]) continue;
delete data[cm.changeGeneration()];
const {scrollX, scrollY} = window;
cm.undo();
cm.scrollIntoView(cm.getCursor());
window.scrollTo(scrollX, scrollY);
undoable |= data[cm.changeGeneration()];
}
this.disabled = !undoable;
},
}, t(scope.length === 1 ? 'undo' : 'undoGlobal')),
]),
]));
$('#help-popup').className = 'wide';
$('.beautify-options').onchange = ({target}) => {
const value = target.type === 'checkbox' ? target.checked : target.selectedIndex > 0;
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);
};
function $createOption(label, optionName, indent) {
const value = options[optionName];
return (
$create('div', {attributes: {newline: value}}, [
$create('span', indent ? {attributes: {indent: ''}} : {}, label),
$create('div.select-resizer', [
$create('select', {dataset: {option: optionName}}, [
$create('option', {selected: !value}, '\xA0'),
$create('option', {selected: value}, '\\n'),
]),
$create('SVG:svg.svg-icon.select-arrow', {viewBox: '0 0 1792 1792'}, [
$create('SVG:path', {
'fill-rule': 'evenodd',
'd': 'M1408 704q0 26-19 45l-448 448q-19 19-45 ' +
'19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z',
}),
]),
]),
])
);
}
function $createLabeledCheckbox(optionName, i18nKey) {
return (
$create('label', {style: 'display: block; clear: both;'}, [
$create('input', {
type: 'checkbox',
dataset: {option: optionName},
checked: options[optionName] !== false,
}),
$create('SVG:svg.svg-icon.checked',
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
t(i18nKey),
])
);
}
}
/* exported initBeautifyButton */
function initBeautifyButton(btn, scope) {
btn.onclick = btn.oncontextmenu = e => {
e.preventDefault();
beautify(scope || editor.getEditors(), e.type === 'click');
};
}

View File

@ -1,3 +1,5 @@
/* Built-in CodeMirror and addon customization */
.CodeMirror-hints {
z-index: 999;
}
@ -14,18 +16,9 @@
/* Not using the ring-color hack as it became ugly in new Chrome */
outline: none !important;
}
.CodeMirror-lint-mark-warning {
background: none;
}
.CodeMirror-dialog {
animation: highlight 3s cubic-bezier(.18, .02, 0, .94);
}
.CodeMirror-bookmark {
background: linear-gradient(to right, currentColor, transparent);
position: absolute;
width: 2em;
opacity: .5;
}
.CodeMirror-search-field {
width: 10em;
}
@ -35,10 +28,6 @@
.CodeMirror-search-hint {
color: #888;
}
.cm-uso-variable {
font-weight: bold;
}
.CodeMirror-activeline .applies-to:before {
background-color: hsla(214, 100%, 90%, 0.15);
content: "";
@ -49,11 +38,9 @@
position: absolute;
pointer-events: none;
}
.CodeMirror-activeline .applies-to ul {
z-index: 2;
}
.CodeMirror-foldgutter-open::after,
.CodeMirror-foldgutter-folded::after {
top: 5px;
@ -65,15 +52,25 @@
opacity: .5;
left: 1px;
}
.CodeMirror-foldgutter-open::after {
border-width: 5px 3px 0 3px;
border-color: currentColor transparent transparent transparent;
}
.CodeMirror-foldgutter-folded::after {
margin-top: -2px;
margin-left: 1px;
border-width: 4px 0 4px 5px;
border-color: transparent transparent transparent currentColor;
}
.CodeMirror-linenumber {
cursor: pointer; /* for bookmarking */
}
/* Custom stuff we add to CodeMirror */
.cm-uso-variable {
font-weight: bold;
}
.gutter-bookmark {
background: linear-gradient(0deg, hsla(180, 100%, 30%, .75) 2px, hsla(180, 100%, 30%, .2) 2px);
}

View File

@ -1,15 +1,16 @@
/* global CodeMirror prefs loadScript editor $ template */
/* global $ */// dom.js
/* global CodeMirror */
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
(function () {
(() => {
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
if (!prefs.get('editor.keyMap')) {
prefs.reset('editor.keyMap');
}
const CM_BOOKMARK = 'CodeMirror-bookmark';
const CM_BOOKMARK_GUTTER = CM_BOOKMARK + 'gutter';
const defaults = {
autoCloseBrackets: prefs.get('editor.autoCloseBrackets'),
mode: 'css',
@ -17,7 +18,6 @@
lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true,
gutters: [
CM_BOOKMARK_GUTTER,
'CodeMirror-linenumbers',
'CodeMirror-foldgutter',
...(prefs.get('editor.linter') ? ['CodeMirror-lint-markers'] : []),
@ -29,7 +29,7 @@
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'),
extraKeys: Object.assign(CodeMirror.defaults.extraKeys || {}, {
// independent of current keyMap
// independent of current keyMap; some are implemented only for the edit page
'Alt-Enter': 'toggleStyle',
'Alt-PageDown': 'nextEditor',
'Alt-PageUp': 'prevEditor',
@ -40,323 +40,107 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// 'basic' keymap only has basic keys by design, so we skip it
const extraKeysCommands = {};
Object.keys(CodeMirror.defaults.extraKeys).forEach(key => {
extraKeysCommands[CodeMirror.defaults.extraKeys[key]] = true;
});
if (!extraKeysCommands.jumpToLine) {
CodeMirror.keyMap.sublime['Ctrl-G'] = 'jumpToLine';
CodeMirror.keyMap.emacsy['Ctrl-G'] = 'jumpToLine';
CodeMirror.keyMap.pcDefault['Ctrl-J'] = 'jumpToLine';
CodeMirror.keyMap.macDefault['Cmd-J'] = 'jumpToLine';
}
if (!extraKeysCommands.autocomplete) {
// will be used by 'sublime' on PC via fallthrough
CodeMirror.keyMap.pcDefault['Ctrl-Space'] = 'autocomplete';
// OSX uses Ctrl-Space and Cmd-Space for something else
CodeMirror.keyMap.macDefault['Alt-Space'] = 'autocomplete';
// copied from 'emacs' keymap
CodeMirror.keyMap.emacsy['Alt-/'] = 'autocomplete';
// 'vim' and 'emacs' define their own autocomplete hotkeys
}
if (!extraKeysCommands.blockComment) {
CodeMirror.keyMap.sublime['Shift-Ctrl-/'] = 'commentSelection';
}
if (navigator.appVersion.includes('Windows')) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
if (!extraKeysCommands.findNext) {
CodeMirror.keyMap.pcDefault['F3'] = 'findNext';
// 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 extras = Object.values(CodeMirror.defaults.extraKeys);
if (!extras.includes('jumpToLine')) {
KM.sublime['Ctrl-G'] = 'jumpToLine';
KM.emacsy['Ctrl-G'] = 'jumpToLine';
KM.pcDefault['Ctrl-J'] = 'jumpToLine';
KM.macDefault['Cmd-J'] = 'jumpToLine';
}
if (!extraKeysCommands.findPrev) {
CodeMirror.keyMap.pcDefault['Shift-F3'] = 'findPrev';
if (!extras.includes('autocomplete')) {
// will be used by 'sublime' on PC via fallthrough
KM.pcDefault['Ctrl-Space'] = 'autocomplete';
// OSX uses Ctrl-Space and Cmd-Space for something else
KM.macDefault['Alt-Space'] = 'autocomplete';
// copied from 'emacs' keymap
KM.emacsy['Alt-/'] = 'autocomplete';
// 'vim' and 'emacs' define their own autocomplete hotkeys
}
if (!extraKeysCommands.replace) {
CodeMirror.keyMap.pcDefault['Ctrl-R'] = 'replace';
if (!extras.includes('blockComment')) {
KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
}
// try to remap non-interceptable Ctrl-(Shift-)N/T/W hotkeys
['N', 'T', 'W'].forEach(char => {
[
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
// Note: modifier order in CodeMirror is S-C-A
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']}
].forEach(remap => {
const oldKey = remap.from + char;
Object.keys(CodeMirror.keyMap).forEach(keyMapName => {
const keyMap = CodeMirror.keyMap[keyMapName];
const command = keyMap[oldKey];
if (!command) {
return;
}
remap.to.some(newMod => {
const newKey = newMod + char;
if (!(newKey in keyMap)) {
delete keyMap[oldKey];
keyMap[newKey] = command;
return true;
if (navigator.appVersion.includes('Windows')) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
// Note: modifier order in CodeMirror is S-C-A
for (const char of ['N', 'T', 'W']) {
for (const remap of [
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
]) {
const oldKey = remap.from + char;
for (const km of Object.values(KM)) {
const command = km[oldKey];
if (!command) continue;
for (const newMod of remap.to) {
const newKey = newMod + char;
if (newKey in km) continue;
km[newKey] = command;
delete km[oldKey];
break;
}
});
});
});
});
}
Object.assign(CodeMirror.mimeModes['text/css'].propertyKeywords, {
'content-visibility': true,
'overflow-anchor': true,
'overscroll-behavior': true,
});
Object.assign(CodeMirror.mimeModes['text/css'].colorKeywords, {
'darkgrey': true,
'darkslategrey': true,
'dimgrey': true,
'lightgrey': true,
'lightslategrey': true,
'slategrey': true,
});
const MODE = {
less: {
family: 'css',
value: 'text/x-less',
isActive: cm =>
cm.doc.mode &&
cm.doc.mode.name === 'css' &&
cm.doc.mode.helperType === 'less',
},
stylus: 'stylus',
uso: 'css'
};
CodeMirror.defineExtension('setPreprocessor', function (preprocessor, force = false) {
const mode = MODE[preprocessor] || 'css';
const isActive = mode.isActive || (
cm => cm.doc.mode === mode ||
cm.doc.mode && (cm.doc.mode.name + (cm.doc.mode.helperType || '') === mode)
);
if (!force && isActive(this)) {
return Promise.resolve();
}
if ((mode.family || mode) === 'css') {
// css.js is always loaded via html
this.setOption('mode', mode.value || mode);
return Promise.resolve();
}
return loadScript(`/vendor/codemirror/mode/${mode}/${mode}.js`).then(() => {
this.setOption('mode', mode);
});
});
CodeMirror.defineExtension('isBlank', function () {
// superfast checking as it runs only until the first non-blank line
let isBlank = true;
this.doc.eachLine(line => {
if (line.text && line.text.trim()) {
isBlank = false;
return true;
}
}
}
});
return isBlank;
}
});
// editor commands
for (const name of ['save', 'toggleStyle', 'nextEditor', 'prevEditor']) {
CodeMirror.commands[name] = (...args) => editor[name](...args);
}
const elBookmark = document.createElement('div');
elBookmark.className = CM_BOOKMARK;
elBookmark.textContent = '\u00A0';
const clearMarker = function () {
const line = this.lines[0];
CodeMirror.TextMarker.prototype.clear.apply(this);
if (!line.markedSpans.some(span => span.marker.sublimeBookmark)) {
this.doc.setGutterMarker(line, CM_BOOKMARK_GUTTER, null);
}
};
const {markText} = CodeMirror.prototype;
Object.assign(CodeMirror.prototype, {
markText() {
const marker = markText.apply(this, arguments);
if (marker.sublimeBookmark) {
this.doc.setGutterMarker(marker.lines[0], CM_BOOKMARK_GUTTER, elBookmark.cloneNode(true));
marker.clear = clearMarker;
/**
* @param {'less' | 'stylus' | ?} [pp] - any value besides `less` or `stylus` sets `css` mode
* @param {boolean} [force]
*/
setPreprocessor(pp, force) {
const name = pp === 'less' ? 'text/x-less' : pp === 'stylus' ? pp : 'css';
const m = this.doc.mode;
if (force || (m.helperType ? m.helperType !== pp : m.name !== name)) {
this.setOption('mode', name);
}
return marker;
},
/** Superfast GC-friendly check that runs until the first non-space line */
isBlank() {
let filled;
this.eachLine(({text}) => (filled = text && /\S/.test(text)));
return !filled;
},
/**
* Sets cursor and centers it in view if `pos` was out of view
* @param {CodeMirror.Pos} pos
* @param {CodeMirror.Pos} [end] - will set a selection from `pos` to `end`
*/
jumpToPos(pos, end = pos) {
const {curOp} = this;
if (!curOp) this.startOperation();
const y = this.cursorCoords(pos, 'window').top;
const rect = this.display.wrapper.getBoundingClientRect();
// case 1) outside of CM viewport or too close to edge so tell CM to render a new viewport
if (y < rect.top + 50 || y > rect.bottom - 100) {
this.scrollIntoView(pos, rect.height / 2);
// case 2) inside CM viewport but outside of window viewport so just scroll the window
} else if (y < 0 || y > innerHeight) {
editor.scrollToEditor(this);
}
// Using prototype since our bookmark patch sets cm.setSelection to jumpToPos
CodeMirror.prototype.setSelection.call(this, pos, end);
if (!curOp) this.endOperation();
},
});
// CodeMirror convenience commands
Object.assign(CodeMirror.commands, {
toggleEditorFocus,
jumpToLine,
commentSelection,
jumpToLine(cm) {
const cur = cm.getCursor();
const oldDialog = $('.CodeMirror-dialog', cm.display.wrapper);
if (oldDialog) cm.focus(); // close the currently opened minidialog
cm.openDialog(t.template.jumpToLine.cloneNode(true), str => {
const [line, ch] = str.match(/^\s*(\d+)(?:\s*:\s*(\d+))?\s*$|$/);
if (line) cm.setCursor(line - 1, ch ? ch - 1 : cur.ch);
}, {value: cur.line + 1});
},
});
function jumpToLine(cm) {
const cur = cm.getCursor();
const oldDialog = $('.CodeMirror-dialog', cm.display.wrapper);
if (oldDialog) {
// close the currently opened minidialog
cm.focus();
}
// make sure to focus the input in newly opened minidialog
// setTimeout(() => {
// $('.CodeMirror-dialog', section).focus();
// });
cm.openDialog(template.jumpToLine.cloneNode(true), str => {
const m = str.match(/^\s*(\d+)(?:\s*:\s*(\d+))?\s*$/);
if (m) {
cm.setCursor(m[1] - 1, m[2] ? m[2] - 1 : cur.ch);
}
}, {value: cur.line + 1});
}
function commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
}
function toggleEditorFocus(cm) {
if (!cm) return;
if (cm.hasFocus()) {
setTimeout(() => cm.display.input.blur());
} else {
cm.focus();
}
}
})();
// eslint-disable-next-line no-unused-expressions
CodeMirror.hint && (() => {
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const RX_IMPORTANT = /(i(m(p(o(r(t(a(nt?)?)?)?)?)?)?)?)?(?=\b|\W|$)/iy;
const RX_VAR_KEYWORD = /(^|[^-\w\u0080-\uFFFF])var\(/iy;
const RX_END_OF_VAR = /[\s,)]|$/g;
const RX_CONSUME_PROP = /[-\w]*\s*:\s?|$/y;
const originalHelper = CodeMirror.hint.css || (() => {});
const helper = cm => {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
if (!styles) return originalHelper(cm);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
if (style && (style.startsWith('comment') || style.startsWith('string'))) {
return originalHelper(cm);
}
// !important
if (text[ch - 1] === '!' && /i|\W|^$/i.test(text[ch] || '')) {
RX_IMPORTANT.lastIndex = ch;
return {
list: ['important'],
from: pos,
to: {line, ch: ch + RX_IMPORTANT.exec(text)[0].length},
};
}
let prev = index > 2 ? styles[index - 2] : 0;
let end = styles[index];
// #hex colors
if (text[prev] === '#') {
return {list: [], from: pos, to: pos};
}
// adjust cursor position for /*[[ and ]]*/
const adjust = text[prev] === '/' ? 4 : 0;
prev += adjust;
end -= adjust;
const leftPart = text.slice(prev, ch);
// --css-variables
const startsWithDoubleDash = text[prev] === '-' && text[prev + 1] === '-';
if (startsWithDoubleDash ||
leftPart === '(' && testAt(RX_VAR_KEYWORD, Math.max(0, prev - 4), text)) {
// simplified regex without CSS escapes
const RX_CSS_VAR = new RegExp(
'(?:^|[\\s/;{])(' +
(leftPart.startsWith('--') ? leftPart : '--') +
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
'gm');
const cursor = cm.getSearchCursor(RX_CSS_VAR, null, {caseFold: false, multiline: false});
const list = new Set();
while (cursor.findNext()) {
list.add(cursor.pos.match[1]);
}
if (!startsWithDoubleDash) {
prev++;
}
RX_END_OF_VAR.lastIndex = prev;
end = RX_END_OF_VAR.exec(text).index;
return {
list: [...list.keys()].sort(),
from: {line, ch: prev},
to: {line, ch: end},
};
}
if (!editor || !style || !style.includes(USO_VAR)) {
// add ":" after a property name
const res = originalHelper(cm);
const state = res && cm.getTokenAt(pos).state.state;
if (state === 'block' || state === 'maybeprop') {
res.list = res.list.map(str => str + ': ');
RX_CONSUME_PROP.lastIndex = res.to.ch;
res.to.ch += RX_CONSUME_PROP.exec(text)[0].length;
}
return res;
}
// USO vars in usercss mode editor
const vars = editor.style.usercssData.vars;
const list = vars ?
Object.keys(vars).filter(name => name.startsWith(leftPart)) : [];
return {
list,
from: {line, ch: prev},
to: {line, ch: end},
};
};
CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper);
const hooks = CodeMirror.mimeModes['text/css'].tokenHooks;
const originalCommentHook = hooks['/'];
hooks['/'] = tokenizeUsoVariables;
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] !== 'comment') {
return token;
}
const {string, start, pos} = stream;
// /*[[install-key]]*/
// 01234 43210
if (string[start + 2] === '[' &&
string[start + 3] === '[' &&
string[pos - 3] === ']' &&
string[pos - 4] === ']') {
const vars = typeof editor !== 'undefined' && (editor.style.usercssData || {}).vars;
const name = vars && string.slice(start + 4, pos - 4);
if (vars && Object.hasOwnProperty.call(vars, name.endsWith('-rgb') ? name.slice(0, -4) : name)) {
token[0] = USO_VALID_VAR;
} else {
token[0] = USO_INVALID_VAR;
}
}
return token;
}
function testAt(rx, index, text) {
if (!rx) return false;
rx.lastIndex = index;
return rx.test(text);
}
})();

View File

@ -1,86 +1,201 @@
/* global CodeMirror loadScript rerouteHotkeys prefs $ debounce $create */
/* exported cmFactory */
/* global $ */// dom.js
/* global CodeMirror */
/* global editor */
/* global prefs */
/* global rerouteHotkeys */// util.js
'use strict';
/*
All cm instances created by this module are collected so we can broadcast prefs
settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
when the instance is not used anymore.
All cm instances created by this module are collected so we can broadcast prefs
settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
when the instance is not used anymore.
*/
const cmFactory = (() => {
const editors = new Set();
// used by `indentWithTabs` option
const INSERT_TAB_COMMAND = CodeMirror.commands.insertTab;
const INSERT_SOFT_TAB_COMMAND = CodeMirror.commands.insertSoftTab;
CodeMirror.defineOption('tabSize', prefs.get('editor.tabSize'), (cm, value) => {
cm.setOption('indentUnit', Number(value));
});
(() => {
//#region Factory
CodeMirror.defineOption('indentWithTabs', prefs.get('editor.indentWithTabs'), (cm, value) => {
CodeMirror.commands.insertTab = value ?
INSERT_TAB_COMMAND :
INSERT_SOFT_TAB_COMMAND;
});
const cms = new Set();
let lazyOpt;
CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'), (cm, value) => {
const onOff = value ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked);
});
const cmFactory = window.cmFactory = {
CodeMirror.defineOption('matchHighlight', prefs.get('editor.matchHighlight'), (cm, value) => {
if (value === 'token') {
cm.setOption('highlightSelectionMatches', {
showToken: /[#.\-\w]/,
annotateScrollbar: true,
onUpdate: updateMatchHighlightCount
});
} else if (value === 'selection') {
cm.setOption('highlightSelectionMatches', {
showToken: false,
annotateScrollbar: true,
onUpdate: updateMatchHighlightCount
});
} else {
cm.setOption('highlightSelectionMatches', null);
}
});
create(place, options) {
const cm = CodeMirror(place, options);
cm.lastActive = 0;
cms.add(cm);
return cm;
},
CodeMirror.defineOption('selectByTokens', prefs.get('editor.selectByTokens'), (cm, value) => {
cm.setOption('configureMouse', value ? configureMouseFn : null);
});
destroy(cm) {
cms.delete(cm);
},
prefs.subscribe(null, (key, value) => {
const option = key.replace(/^editor\./, '');
if (!option) {
console.error('no "cm_option"', key);
return;
}
// FIXME: this is implemented in `colorpicker-helper.js`.
if (option === 'colorpicker') {
return;
}
if (option === 'theme') {
const themeLink = $('#cm-theme');
// use non-localized 'default' internally
if (value === 'default') {
themeLink.href = '';
globalSetOption(key, value) {
CodeMirror.defaults[key] = value;
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
lazyOpt.set(key, value);
} else {
const url = chrome.runtime.getURL('vendor/codemirror/theme/' + value + '.css');
if (themeLink.href !== url) {
// avoid flicker: wait for the second stylesheet to load, then apply the theme
return loadScript(url, true).then(([newThemeLink]) => {
setOption(option, value);
themeLink.remove();
newThemeLink.id = 'cm-theme';
});
cms.forEach(cm => cm.setOption(key, value));
}
},
};
// focus and blur
const onCmFocus = cm => {
rerouteHotkeys.toggle(false);
cm.display.wrapper.classList.add('CodeMirror-active');
cm.lastActive = Date.now();
};
const onCmBlur = cm => {
rerouteHotkeys.toggle(true);
setTimeout(() => {
const {wrapper} = cm.display;
wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement));
});
};
CodeMirror.defineInitHook(cm => {
cm.on('focus', onCmFocus);
cm.on('blur', onCmBlur);
});
// propagated preferences
const prefToCmOpt = k =>
k.startsWith('editor.') &&
k.slice('editor.'.length);
const prefKeys = prefs.knownKeys.filter(k =>
k !== 'editor.colorpicker' && // handled in colorpicker-helper.js
prefToCmOpt(k) in CodeMirror.defaults);
const {insertTab, insertSoftTab} = CodeMirror.commands;
for (const [key, fn] of Object.entries({
'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,
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) => {
const name = prefToCmOpt(key);
if (name === 'theme') {
loadCmTheme(val);
} else {
cmFactory.globalSetOption(name, val);
}
});
// lazy propagation
lazyOpt = window.IntersectionObserver && {
names: ['theme', 'lineWrapping'],
set(key, value) {
const {observer, queue} = lazyOpt;
for (const cm of cms) {
let opts = queue.get(cm);
if (!opts) queue.set(cm, opts = {});
opts[key] = value;
observer.observe(cm.display.wrapper);
}
},
setNow({cm, data}) {
cm.operation(() => data.forEach(kv => cm.setOption(...kv)));
},
onView(entries) {
const {queue, observer} = lazyOpt;
const delayed = [];
for (const e of entries) {
const r = e.isIntersecting && e.intersectionRect;
if (!r) continue;
const cm = e.target.CodeMirror;
const data = Object.entries(queue.get(cm) || {});
queue.delete(cm);
observer.unobserve(e.target);
if (!data.every(([key, val]) => cm.getOption(key) === val)) {
if (r.bottom > 0 && r.top < window.innerHeight) {
lazyOpt.setNow({cm, data});
} else {
delayed.push({cm, data});
}
}
}
}
// broadcast option
setOption(option, value);
if (delayed.length) {
setTimeout(() => delayed.forEach(lazyOpt.setNow));
}
},
get observer() {
if (!lazyOpt._observer) {
// must exceed refreshOnView's 100%
lazyOpt._observer = new IntersectionObserver(lazyOpt.onView, {rootMargin: '150%'});
lazyOpt.queue = new WeakMap();
}
return lazyOpt._observer;
},
};
//#endregion
//#region Commands
Object.assign(CodeMirror.commands, {
commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
},
toggleEditorFocus(cm) {
if (!cm) return;
if (cm.hasFocus()) {
setTimeout(() => cm.display.input.blur());
} else {
cm.focus();
}
},
});
return {create, destroy, setOption};
for (const cmd of [
'nextEditor',
'prevEditor',
'save',
'toggleStyle',
]) {
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
}
//#endregion
//#region CM option handlers
async function loadCmTheme(name) {
let el2;
const el = $('#cm-theme');
if (name === 'default') {
el.href = '';
} else {
const path = `/vendor/codemirror/theme/${name}.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', name);
if (el2) {
el.remove();
el2.id = el.id;
}
}
function updateMatchHighlightCount(cm, state) {
cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length;
@ -143,151 +258,76 @@ const cmFactory = (() => {
};
}
function autocompleteOnTyping(cm, [info], debounced) {
const lastLine = info.text[info.text.length - 1];
if (
cm.state.completionActive ||
info.origin && !info.origin.includes('input') ||
!lastLine
) {
return;
}
if (cm.state.autocompletePicked) {
cm.state.autocompletePicked = false;
return;
}
if (!debounced) {
debounce(autocompleteOnTyping, 100, cm, [info], true);
return;
}
if (lastLine.match(/[-a-z!]+$/i)) {
cm.state.autocompletePicked = false;
cm.options.hintOptions.completeSingle = false;
cm.execCommand('autocomplete');
setTimeout(() => {
cm.options.hintOptions.completeSingle = true;
});
}
//#endregion
//#region Bookmarks
const BM_CLS = 'gutter-bookmark';
const BM_BRAND = 'sublimeBookmark';
const BM_CLICKER = 'CodeMirror-linenumbers';
const BM_DATA = Symbol('data');
// 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();
};
}
function autocompletePicked(cm) {
cm.state.autocompletePicked = true;
for (const name of ['prevBookmark', 'nextBookmark']) {
const cmdFn = CodeMirror.commands[name];
CodeMirror.commands[name] = cm => {
cm.setSelection = cm.jumpToPos;
cmdFn(cm);
delete cm.setSelection;
};
}
function destroy(cm) {
editors.delete(cm);
}
function create(init, options) {
const cm = CodeMirror(init, options);
cm.lastActive = 0;
const wrapper = cm.display.wrapper;
cm.on('blur', () => {
rerouteHotkeys(true);
setTimeout(() => {
wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement));
});
});
cm.on('focus', () => {
rerouteHotkeys(false);
wrapper.classList.add('CodeMirror-active');
cm.lastActive = Date.now();
});
editors.add(cm);
return cm;
}
function getLastActivated() {
let result;
for (const cm of editors) {
if (!result || result.lastActive < cm.lastActive) {
result = cm;
}
}
return result;
}
function setOption(key, value) {
CodeMirror.defaults[key] = value;
if (editors.size > 4 && (key === 'theme' || key === 'lineWrapping')) {
throttleSetOption({key, value, index: 0});
return;
}
for (const cm of editors) {
cm.setOption(key, value);
}
}
function throttleSetOption({
key,
value,
index,
timeStart = performance.now(),
editorsCopy = [...editors],
cmStart = getLastActivated(),
progress,
}) {
if (index === 0) {
if (!cmStart) {
return;
}
cmStart.setOption(key, value);
}
const THROTTLE_AFTER_MS = 100;
const THROTTLE_SHOW_PROGRESS_AFTER_MS = 100;
const t0 = performance.now();
const total = editorsCopy.length;
while (index < total) {
const cm = editorsCopy[index++];
if (cm === cmStart || !editors.has(cm)) {
continue;
}
cm.setOption(key, value);
if (performance.now() - t0 > THROTTLE_AFTER_MS) {
CodeMirror.defineInitHook(cm => {
cm.on('gutterClick', onGutterClick);
cm.on('gutterContextMenu', onGutterContextMenu);
cm.on('markerAdded', onMarkAdded);
});
// TODO: reimplement bookmarking so next/prev order is decided solely by the line numbers
function onGutterClick(cm, line, name, e) {
switch (name === BM_CLICKER && e.button) {
case 0: {
// main button: toggle
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BM_BRAND]);
cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
cm.execCommand('toggleBookmark');
break;
}
case 1:
// middle button: select all marks
cm.execCommand('selectBookmarks');
break;
}
if (index >= total) {
$.remove(progress);
return;
}
if (!progress &&
index < total / 2 &&
t0 - timeStart > THROTTLE_SHOW_PROGRESS_AFTER_MS) {
let option = $('#editor.' + key);
if (option) {
if (option.type === 'checkbox') {
option = (option.labels || [])[0] || option.nextElementSibling || option;
}
progress = document.body.appendChild(
$create('.set-option-progress', {targetElement: option}));
}
}
if (progress) {
const optionBounds = progress.targetElement.getBoundingClientRect();
const bounds = {
top: optionBounds.top + window.scrollY + 1,
left: optionBounds.left + window.scrollX + 1,
width: (optionBounds.width - 2) * index / total | 0,
height: optionBounds.height - 2,
};
const style = progress.style;
for (const prop in bounds) {
if (bounds[prop] !== parseFloat(style[prop])) {
style[prop] = bounds[prop] + 'px';
}
}
}
setTimeout(throttleSetOption, 0, {
key,
value,
index,
timeStart,
cmStart,
editorsCopy,
progress,
});
}
function onGutterContextMenu(cm, line, name, e) {
if (name === BM_CLICKER) {
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
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
})();

View File

@ -1,10 +1,11 @@
/* exported CODEMIRROR_THEMES */
// this file is generated by update-codemirror-themes.js
/* Do not edit. This file is auto-generated by build-vendor.js */
'use strict';
/* exported CODEMIRROR_THEMES */
const CODEMIRROR_THEMES = [
'3024-day',
'3024-night',
'abbott',
'abcdef',
'ambiance',
'ambiance-mobile',
@ -28,6 +29,7 @@ const CODEMIRROR_THEMES = [
'icecoder',
'idea',
'isotope',
'juejin',
'lesser-dark',
'liquibyte',
'lucario',
@ -65,5 +67,5 @@ const CODEMIRROR_THEMES = [
'xq-light',
'yeti',
'yonce',
'zenburn'
'zenburn',
];

View File

@ -1,75 +0,0 @@
/* global CodeMirror showHelp cmFactory onDOMready $ prefs t createHotkeyInput */
'use strict';
(() => {
onDOMready().then(() => {
$('#colorpicker-settings').onclick = configureColorpicker;
});
prefs.subscribe(['editor.colorpicker.hotkey'], registerHotkey);
prefs.subscribe(['editor.colorpicker'], setColorpickerOption);
setColorpickerOption(null, prefs.get('editor.colorpicker'));
function setColorpickerOption(id, enabled) {
const defaults = CodeMirror.defaults;
const keyName = prefs.get('editor.colorpicker.hotkey');
defaults.colorpicker = enabled;
if (enabled) {
if (keyName) {
CodeMirror.commands.colorpicker = invokeColorpicker;
defaults.extraKeys = defaults.extraKeys || {};
defaults.extraKeys[keyName] = 'colorpicker';
}
defaults.colorpicker = {
tooltip: t('colorpickerTooltip'),
popup: {
tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'),
hexUppercase: prefs.get('editor.colorpicker.hexUppercase'),
hideDelay: 30e3,
embedderCallback: state => {
['hexUppercase', 'color']
.filter(name => state[name] !== prefs.get('editor.colorpicker.' + name))
.forEach(name => prefs.set('editor.colorpicker.' + name, state[name]));
},
},
};
} else {
if (defaults.extraKeys) {
delete defaults.extraKeys[keyName];
}
}
cmFactory.setOption('colorpicker', defaults.colorpicker);
}
function registerHotkey(id, hotkey) {
CodeMirror.commands.colorpicker = invokeColorpicker;
const extraKeys = CodeMirror.defaults.extraKeys;
for (const key in extraKeys) {
if (extraKeys[key] === 'colorpicker') {
delete extraKeys[key];
break;
}
}
if (hotkey) {
extraKeys[hotkey] = 'colorpicker';
}
}
function invokeColorpicker(cm) {
cm.state.colorpicker.openPopup(prefs.get('editor.colorpicker.color'));
}
function configureColorpicker(event) {
event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', () => {
$('#help-popup .dismiss').onclick();
});
const popup = showHelp(t('helpKeyMapHotkey'), input);
if (this instanceof Element) {
const bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
}
input.focus();
}
})();

View File

@ -1,12 +1,21 @@
:root {
--header-narrow-min-height: 12em;
--fixed-padding: unset;
}
body {
margin: 0;
height: 100vh;
font: 12px arial,sans-serif;
}
a {
color: #000;
transition: color .5s;
}
a:hover {
color: #666;
}
#global-progress {
position: fixed;
height: 4px;
@ -18,15 +27,52 @@ body {
z-index: 2147483647;
opacity: 0;
transition: opacity 2s;
contain: strict;
}
#global-progress[title] {
opacity: 1;
}
html.is-new-style #preview-label,
html.is-new-style #publish,
.hidden {
display: none !important;
}
html.is-new-style #heading::after {
content: attr(data-add);
}
html:not(.is-new-style) #heading::after {
content: attr(data-edit);
}
/************ embedded popup for simple-window editor ************/
#popup-iframe {
max-height: 600px;
position: fixed;
right: 0;
top: 0;
z-index: 1001;
border: none;
background: #fff;
box-shadow: 0 0 30px #000;
}
#popup-iframe:not([data-loaded]) {
opacity: 0;
}
#popup-button {
position: fixed;
right: 7px;
top: 11px;
z-index: 1000;
cursor: pointer;
transition: filter .25s;
}
#popup-button:hover {
filter: drop-shadow(0 0 3px hsl(180, 70%, 50%));
}
.usercss body:not(.compact-layout) #popup-button {
right: 24px;
}
/************ checkbox & select************/
.options-column > div[class="option"] {
margin-bottom: 4px;
@ -135,9 +181,6 @@ label {
}
.svg-icon {
cursor: pointer;
vertical-align: middle;
transition: fill .5s;
width: 16px;
height: 16px;
}
@ -146,9 +189,6 @@ label {
display: inline-block;
vertical-align: middle;
}
#mozilla-format-heading .svg-inline-wrapper {
margin-left: 0;
}
#colorpicker-settings.svg-inline-wrapper {
margin: -2px 0 0 .1rem;
}
@ -190,10 +230,10 @@ input:invalid {
align-items: center;
margin-left: -13px;
cursor: pointer;
margin-top: .5rem;
margin-bottom: .5rem;
}
#header summary + * {
padding: .5rem 0;
}
#header summary h2 {
display: inline-block;
border-bottom: 1px dotted transparent;
@ -211,18 +251,25 @@ input:invalid {
margin-top: -3px;
}
#details-wrapper {
display: flex;
flex-direction: column;
flex-grow: 1;
}
#header details[open] + details[open] {
margin-top: .5rem;
}
#actions > * {
display: inline-flex;
flex-wrap: wrap;
}
#mozilla-format-container {
flex-direction: column;
}
#mozilla-format-buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
}
#actions > div > a {
@ -244,6 +291,81 @@ input:invalid {
#lint:not([open]) h2 {
margin-bottom: 0;
}
#publish > div > * {
margin-top: .75em;
}
#publish a:visited {
margin-top: .75em;
}
#publish[data-connected] summary::marker,
#publish[data-connected] h2 {
color: hsl(180, 100%, 20%);
}
#publish:not([data-connected]) #usw-link-info,
#publish:not([data-connected]) #usw-disconnect {
display: none;
}
#publish[data-connected] #usw-publish-style::after {
content: attr(data-push);
}
#publish:not([data-connected]) #usw-publish-style::after {
content: attr(data-publish);
}
#usw-link-info dl {
margin: 0;
display: flex;
}
#usw-link-info dt {
flex-shrink: 0;
}
#usw-link-info dt::after {
content: ":"
}
#usw-link-info dt,
#usw-link-info dd {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#usw-link-info dd {
margin-left: .5em;
}
#usw-link-info dd[data-usw="name"] {
font-weight: bold;
}
#usw-progress {
position: relative;
vertical-align: top;
}
#usw-progress .success,
#usw-progress .unchanged {
font-size: 150%;
font-weight: bold;
position: absolute;
margin-left: .25em;
}
#usw-progress .success {
margin-top: -.25em;
}
#usw-progress .success::after {
content: '\2713'; /* checkmark */
}
#usw-progress .unchanged::after {
content: '=';
}
#usw-progress .error {
display: block;
margin-top: .5em;
color: red;
}
#usw-progress .error + div {
font-size: smaller;
}
#usw-progress .lds-spinner {
transform: scale(0.125);
transform-origin: 0 10px;
}
/* options */
#options [type="number"] {
width: 3.5em;
@ -254,12 +376,6 @@ input:invalid {
padding: .1rem .25rem 0 0;
vertical-align: middle;
}
.set-option-progress {
position: absolute;
background-color: currentColor;
content: "";
opacity: .15;
}
/* footer */
.usercss #footer {
display: block;
@ -272,6 +388,7 @@ input:invalid {
/************ section editor ***********/
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar {
box-shadow: none !important;
pointer-events: auto !important; /* FF bug */
}
.section-editor .section {
@ -304,7 +421,10 @@ input:invalid {
#sections {
counter-reset: codebox;
}
#sections > .section > label {
#sections > .section:not(.removed) > label {
padding: 0 0 4px 0;
display: inline-block;
font-size: 13px;
animation: 2s highlight;
animation-play-state: paused;
animation-direction: reverse;
@ -312,9 +432,41 @@ input:invalid {
}
#sections > .section > label::after {
counter-increment: codebox;
content: counter(codebox);
content: counter(codebox) ": " attr(data-text);
margin-left: 0.25rem;
}
.single-editor .applies-to > label::before {
content: attr(data-index) ":";
margin-right: 0.25rem;
}
.code-label[data-text] {
font-weight: bold;
}
#toc {
counter-reset: codelabel;
margin: 0;
padding: .5rem 0;
}
#toc li {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
#toc li.current:not(:only-child) {
font-weight: bold;
}
#toc li[tabindex="-1"] {
opacity: .25;
pointer-events: none;
}
#toc li:hover {
background-color: hsla(180, 50%, 36%, .2);
}
#toc li[tabindex="0"]::before {
counter-increment: codelabel;
content: counter(codelabel) ": ";
}
.section:only-of-type .move-section-up,
.section:only-of-type .move-section-down {
display: none;
@ -384,7 +536,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .cm-matchhighlight,
body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-highlight-scrollbar {
animation: none;
}
@-webkit-keyframes highlight {
@keyframes highlight {
from {
background-color: #ff9;
}
@ -437,6 +589,13 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
align-items: flex-start;
min-height: 30px;
flex-wrap: wrap;
background-color: #f7f7f7; /* .CodeMirror-gutters */
border: solid #bbb;
border-width: 1px 0;
}
.applies-to.error {
background-color: #f002;
border-color: #f008;
}
.applies-to label {
display: flex;
@ -520,7 +679,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
margin-left: 1rem;
word-break: break-all;
}
.regexp-report details:not(:last-child) {
.regexp-report details {
margin-bottom: 1rem;
}
.regexp-report summary {
@ -543,6 +702,10 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
margin-left: 2rem;
margin-top: .5rem;
}
.regexp-report details div {
max-height: calc(100vh - 15rem);
overflow-y: auto;
}
.regexp-report .svg-icon {
position: absolute;
margin-top: -1px;
@ -559,14 +722,13 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
.regexp-report details a img {
width: 16px;
max-height: 16px;
position: absolute;
margin-left: -20px;
margin-top: -1px;
vertical-align: middle;
margin-right: .5em;
}
.regexp-report-note {
color: #999;
position: absolute;
margin: 0 0.5rem 0 0;
bottom: 0;
hyphens: auto;
}
/************ help popup ************/
@ -617,9 +779,12 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
#help-popup .CodeMirror {
margin: 3px;
}
#help-popup .keymap-list input[type="search"] {
margin: 0 0 2px;
}
.keymap-list {
font-size: 12px;
padding: 0 3px 0 0;
border-spacing: 0;
word-break: break-all;
}
@ -637,6 +802,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
#help-popup .buttons {
text-align: center;
margin-top: .75em;
}
.non-windows #help-popup .buttons {
direction: rtl;
@ -656,15 +822,21 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
#help-popup .rules {
padding: 0 15px;
}
#help-popup button {
margin-right: 3px;
#help-popup .rules li {
padding-top: .5em;
}
#help-popup .rules p {
margin: .25em 0;
}
#help-popup .buttons button:nth-child(n + 2) {
margin-left: .5em;
}
/************ lint ************/
#lint {
overflow: hidden;
margin: .5rem -1rem 0;
min-height: 30px;
margin-left: -1rem;
margin-right: -1rem;
padding: 0;
box-sizing: border-box;
display: flex;
@ -677,13 +849,13 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
padding-left: 4px;
}
#lint[open]:not(.hidden-unless-compact) {
min-height: 130px;
min-height: 102px;
}
#lint summary h2 {
margin-left: -16px;
text-indent: -2px;
}
#lint > .lint-scroll-container {
margin: 42px 1rem 0;
margin: 1rem 10px 0;
position: absolute;
top: 0;
bottom: 0;
@ -721,7 +893,10 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
cursor: pointer;
}
#lint tr:hover {
background-color: rgba(0, 0, 0, 0.1);
background-color: hsla(180, 50%, 36%, .2);
}
#lint td {
padding: 0;
}
#lint td[role="severity"] {
font-size: 0;
@ -729,7 +904,6 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
}
#lint td[role="line"], #lint td[role="sep"] {
text-align: right;
padding-right: 0;
}
#lint td[role="col"] {
text-align: left;
@ -742,6 +916,11 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
#message-box.center.lint-config #message-box-contents {
text-align: left;
}
#help-popup .active-linter-rule {
font-weight: bold;
text-decoration: underline;
background-color: rgba(128, 128, 128, .2);
}
/************ CSS beautifier ************/
.beautify-options {
@ -787,20 +966,12 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
}
/************ single editor **************/
.usercss body {
display: flex;
height: 100vh;
flex-direction: column;
justify-items: normal;
}
.usercss .CodeMirror-focused {
box-shadow: none;
}
html:not(.usercss) .usercss-only,
.usercss #mozilla-format-container,
.usercss #sections > h2 {
.usercss .sectioned-only {
display: none !important; /* hide during page init */
}
@ -817,13 +988,9 @@ body.linter-disabled .hidden-unless-compact {
margin-top: .75rem;
}
.single-editor {
height: 100%;
}
.single-editor .CodeMirror {
width: 100%;
height: auto;
height: 100vh; /* WARNING! Don't use 100% as it's dead slow */
border: none;
outline: none;
}
@ -837,26 +1004,16 @@ body.linter-disabled .hidden-unless-compact {
color: #666;
}
.usercss.firefox #sections,
.usercss.firefox .CodeMirror {
height: 100%;
}
/************ line widget *************/
.CodeMirror-linewidget .applies-to {
margin: 1em 0;
padding: .75rem .75rem .25rem;
padding-right: calc(1em + 20px);
padding: .75rem calc(.25rem + var(--cm-bar-width, 0)) .25rem .75rem;
}
.CodeMirror-linewidget .applies-to li {
margin: 0;
}
.CodeMirror-linewidget .applies-to li + li {
margin-top: 0.35rem;
}
.CodeMirror-linewidget .applies-to li[data-type="regexp"] .test-regexp {
display: inline;
}
@ -878,10 +1035,10 @@ body.linter-disabled .hidden-unless-compact {
position: inherit;
border-right: none;
border-bottom: 1px dashed #AAA;
padding: 0;
padding: .5rem 1rem .5rem .5rem;
}
.fixed-header {
padding-top: 40px;
padding-top: var(--fixed-padding);
}
.fixed-header #header {
min-height: 40px;
@ -889,30 +1046,37 @@ body.linter-disabled .hidden-unless-compact {
top: 0;
left: 0;
right: 0;
padding: 8px 0 0;
padding: 0;
background-color: #fff;
}
.fixed-header #header > *:not(#lint) {
.fixed-header #header > *:not(#details-wrapper),
.fixed-header #options {
display: none !important;
}
#header summary + *,
#lint > .lint-scroll-container {
margin-left: 1rem;
padding: .25rem 0 .5rem;
}
#actions {
display: flex;
flex-wrap: wrap;
white-space: nowrap;
padding: 0 1rem;
margin: 0;
box-sizing: border-box;
}
#header input[type="checkbox"] {
vertical-align: middle;
}
#header details {
margin: 0;
}
#heading,
h2 {
display: none;
}
#basic-info {
padding: .5rem 1rem;
margin: 0;
margin-bottom: .5rem;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
@ -929,14 +1093,33 @@ body.linter-disabled .hidden-unless-compact {
#options-wrapper {
display: flex;
flex-wrap: wrap;
padding: 0 1rem .5rem;
box-sizing: border-box;
}
#details-wrapper {
flex-direction: row;
flex-wrap: wrap;
}
#options[open] {
width: 100%;
}
#sections-list[open] {
max-height: 102px;
}
#sections-list[open] #toc {
max-height: 60px;
overflow-y: auto;
}
#header details:not(#options) {
max-width: 50%;
}
.options-column {
flex-grow: 1;
padding-right: .5rem;
box-sizing: border-box;
}
.options-column > .usercss-only {
margin-bottom: 0;
}
#options-wrapper .options-column:nth-child(2) {
margin-top: 0;
}
@ -951,12 +1134,13 @@ body.linter-disabled .hidden-unless-compact {
position: static;
margin-bottom: 0;
}
#options summary {
#header summary {
margin-left: 0;
padding-left: 4px;
}
#options h2 {
margin: 0 0 .5em;
#header summary h2 {
margin: 0;
padding: 0;
}
.option label {
margin: 0;
@ -970,15 +1154,12 @@ body.linter-disabled .hidden-unless-compact {
top: 0.2rem;
}
#lint > .lint-scroll-container {
margin: 32px 1rem 0;
bottom: 6px;
padding-top: 0;
margin-right: 0;
}
#lint {
padding: 0;
margin: 1rem 0 0;
}
#lint > summary {
margin-top: 0;
margin: .5rem 0 0;
}
#lint:not([open]) + #footer {
margin: .25em 0 -1em .25em;
@ -994,8 +1175,9 @@ body.linter-disabled .hidden-unless-compact {
margin: 0 .5rem;
padding: .5rem 0;
}
.usercss .CodeMirror-scroll {
max-height: calc(100vh - var(--header-narrow-min-height));
.single-editor {
overflow: hidden;
flex: 1;
}
.usercss #options:not([open]) ~ #lint.hidden ~ #footer,
.usercss #lint:not([open]) + #footer {

View File

@ -1,337 +1,118 @@
/* global CodeMirror onDOMready prefs setupLivePrefs $ $$ $create t tHTML
createSourceEditor sessionStorageHash getOwnTab FIREFOX API tryCatch
closeCurrentTab messageBox debounce
initBeautifyButton ignoreChromeError dirtyReporter linter
moveFocus msg createSectionsEditor rerouteHotkeys CODEMIRROR_THEMES */
/* exported showCodeMirrorPopup editorWorker toggleContextMenuDelete */
/* global $ $create messageBoxProxy waitForSheet */// dom.js
/* global API msg */// msg.js
/* global CodeMirror */
/* global SectionsEditor */
/* global SourceEditor */
/* global baseInit */
/* global clipString createHotkeyInput helpPopup */// util.js
/* global closeCurrentTab deepEqual sessionStore tryJSONparse */// toolbox.js
/* global cmFactory */
/* global editor */
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
let saveSizeOnClose;
//#region init
// direct & reverse mapping of @-moz-document keywords and internal property names
const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'};
const CssToProperty = Object.entries(propertyToCss)
.reduce((o, v) => {
o[v[1]] = v[0];
return o;
}, {});
let editor;
let scrollPointTimer;
document.addEventListener('visibilitychange', beforeUnload);
window.addEventListener('beforeunload', beforeUnload);
msg.onExtension(onRuntimeMessage);
lazyInit();
(async function init() {
const [style] = await Promise.all([
initStyleData(),
onDOMready(),
prefs.initializing.then(() => new Promise(resolve => {
const theme = prefs.get('editor.theme');
const el = $('#cm-theme');
if (theme === 'default') {
resolve();
} else {
// preload the theme so CodeMirror can use the correct metrics
el.href = `vendor/codemirror/theme/${theme}.css`;
el.addEventListener('load', resolve, {once: true});
}
})),
]);
const usercss = isUsercss(style);
const dirty = dirtyReporter();
let wasDirty = false;
let nameTarget;
prefs.subscribe(['editor.linter'], updateLinter);
prefs.subscribe(['editor.keyMap'], showHotkeyInTooltip);
addEventListener('showHotkeyInTooltip', showHotkeyInTooltip);
showHotkeyInTooltip();
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
initNameArea();
initBeautifyButton($('#beautify'), () => editor.getEditors());
initResizeListener();
detectLayout();
updateTitle();
$('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !style.id);
editor = (usercss ? createSourceEditor : createSectionsEditor)({
style,
dirty,
updateName,
toggleStyle,
});
dirty.onChange(updateDirty);
baseInit.ready.then(async () => {
await waitForSheet();
(editor.isUsercss ? SourceEditor : SectionsEditor)();
await editor.ready;
editor.ready = true;
editor.dirty.onChange(editor.updateDirty);
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
$('#name').required = !usercss;
$('#name').required = !editor.isUsercss;
$('#save-button').onclick = editor.save;
function initNameArea() {
const nameEl = $('#name');
const resetEl = $('#reset-name');
const isCustomName = style.updateUrl || usercss;
nameTarget = isCustomName ? 'customName' : 'name';
nameEl.placeholder = t(usercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.addEventListener('input', () => {
updateName(true);
resetEl.hidden = false;
});
resetEl.hidden = !style.customName;
resetEl.onclick = () => {
const style = editor.style;
nameEl.focus();
nameEl.select();
// trying to make it undoable via Ctrl-Z
if (!document.execCommand('insertText', false, style.name)) {
nameEl.value = style.name;
updateName(true);
}
style.customName = null; // to delete it from db
resetEl.hidden = true;
};
const enabledEl = $('#enabled');
enabledEl.onchange = () => updateEnabledness(enabledEl.checked);
}
const elSec = $('#sections-list');
// editor.toc.expanded pref isn't saved in compact-layout so prefs.subscribe won't work
if (elSec.open) editor.updateToc();
// and we also toggle `open` directly in other places e.g. in detectLayout()
new MutationObserver(() => elSec.open && editor.updateToc())
.observe(elSec, {attributes: true, attributeFilter: ['open']});
function findKeyForCommand(command, map) {
if (typeof map === 'string') map = CodeMirror.keyMap[map];
let key = Object.keys(map).find(k => map[k] === command);
if (key) {
return key;
}
for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
key = ft && findKeyForCommand(command, ft);
if (key) {
return key;
}
}
return '';
}
$('#toc').onclick = e =>
editor.jumpToEditor([...$('#toc').children].indexOf(e.target));
$('#keyMap-help').onclick = () =>
require(['/edit/show-keymap-help'], () => showKeymapHelp()); /* global showKeymapHelp */
$('#linter-settings').onclick = () =>
require(['/edit/linter-dialogs'], () => linterMan.showLintConfig());
$('#lint-help').onclick = () =>
require(['/edit/linter-dialogs'], () => linterMan.showLintHelp());
require([
'/edit/autocomplete',
'/edit/global-search',
]);
});
function buildThemeElement() {
CODEMIRROR_THEMES.unshift(chrome.i18n.getMessage('defaultTheme'));
$('#editor.theme').append(...CODEMIRROR_THEMES.map(s => $create('option', s)));
// move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme'));
}
//#endregion
//#region events
function buildKeymapElement() {
// move 'pc' or 'mac' prefix to the end of the displayed label
const maps = Object.keys(CodeMirror.keyMap)
.map(name => ({
value: name,
name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
}))
.sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
const IGNORE_UPDATE_REASONS = [
'editPreview',
'editPreviewEnd',
'editSave',
'config',
];
const fragment = document.createDocumentFragment();
let bin = fragment;
let groupName;
// group suffixed maps in <optgroup>
maps.forEach(({value, name}, i) => {
groupName = !name.includes('-') ? name : groupName;
const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
if (groupWithNext) {
if (bin === fragment) {
bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
}
}
const el = bin.appendChild($create('option', {value}, name));
if (value === prefs.defaults['editor.keyMap']) {
el.dataset.default = '';
el.title = t('defaultTheme');
}
if (!groupWithNext) bin = fragment;
});
$('#editor.keyMap').appendChild(fragment);
}
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
const extraKeys = CodeMirror.defaults.extraKeys;
for (const el of $$('[data-hotkey-tooltip]')) {
if (el._hotkeyTooltipKeyMap !== mapName) {
el._hotkeyTooltipKeyMap = mapName;
const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
const cmd = el.dataset.hotkeyTooltip;
const key = cmd[0] === '=' ? cmd.slice(1) :
findKeyForCommand(cmd, mapName) ||
extraKeys && findKeyForCommand(cmd, extraKeys);
const newTitle = title + (title && key ? '\n' : '') + (key || '');
if (el.title !== newTitle) el.title = newTitle;
}
}
}
function initResizeListener() {
const {onBoundsChanged} = chrome.windows || {};
if (onBoundsChanged) {
// * movement is reported even if the window wasn't resized
// * fired just once when done so debounce is not needed
onBoundsChanged.addListener(wnd => {
// getting the current window id as it may change if the user attached/detached the tab
chrome.windows.getCurrent(ownWnd => {
if (wnd.id === ownWnd.id) rememberWindowSize();
});
});
}
window.addEventListener('resize', () => {
if (!onBoundsChanged) debounce(rememberWindowSize, 100);
detectLayout();
});
}
function toggleStyle() {
$('#enabled').checked = !style.enabled;
updateEnabledness(!style.enabled);
}
function updateDirty() {
const isDirty = dirty.isDirty();
if (wasDirty !== isDirty) {
wasDirty = isDirty;
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
}
updateTitle();
}
function updateEnabledness(enabled) {
dirty.modify('enabled', style.enabled, enabled);
style.enabled = enabled;
editor.updateLivePreview();
}
function updateName(isUserInput) {
if (!editor) return;
if (isUserInput) {
const {value} = $('#name');
dirty.modify('name', style[nameTarget] || style.name, value);
style[nameTarget] = value;
}
updateTitle({});
}
function updateTitle() {
document.title = `${dirty.isDirty() ? '* ' : ''}${style.customName || style.name}`;
}
function updateLinter(key, value) {
$('body').classList.toggle('linter-disabled', value === '');
linter.run();
}
})();
/* Stuff not needed for the main init so we can let it run at its own tempo */
async function lazyInit() {
const ownTabId = (await getOwnTab()).id;
// use browser history back when 'back to manage' is clicked
if (sessionStorageHash('manageStylesHistory').value[ownTabId] === location.href) {
onDOMready().then(() => {
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
});
}
// no windows on android
if (!chrome.windows) {
return;
}
const tabs = await browser.tabs.query({currentWindow: true});
const windowId = tabs[0].windowId;
if (prefs.get('openEditInWindow')) {
if (
/true/.test(sessionStorage.saveSizeOnClose) &&
'left' in prefs.get('windowPosition', {}) &&
!isWindowMaximized()
) {
// window was reopened via Ctrl-Shift-T etc.
chrome.windows.update(windowId, prefs.get('windowPosition'));
}
if (tabs.length === 1 && window.history.length === 1) {
chrome.windows.getAll(windows => {
if (windows.length > 1) {
sessionStorageHash('saveSizeOnClose').set(windowId, true);
saveSizeOnClose = true;
}
});
} else {
saveSizeOnClose = sessionStorageHash('saveSizeOnClose').value[windowId];
}
}
chrome.tabs.onAttached.addListener((tabId, info) => {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
chrome.windows.get(info.newWindowId, {populate: true}, win => {
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
if (openEditInWindow && FIREFOX) {
// FF-only because Chrome retardedly resets the size during dragging
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
});
});
}
function onRuntimeMessage(request) {
msg.onExtension(request => {
const {style} = request;
switch (request.method) {
case 'styleUpdated':
if (
editor.style.id === request.style.id &&
!['editPreview', 'editPreviewEnd', 'editSave', 'config']
.includes(request.reason)
) {
Promise.resolve(
request.codeIsUpdated === false ?
request.style : API.getStyle(request.style.id)
)
.then(newStyle => {
editor.replaceStyle(newStyle, request.codeIsUpdated);
});
if (editor.style.id === style.id && !IGNORE_UPDATE_REASONS.includes(request.reason)) {
Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id))
.then(newStyle => editor.replaceStyle(newStyle, request.codeIsUpdated));
}
break;
case 'styleDeleted':
if (editor.style.id === request.style.id) {
document.removeEventListener('visibilitychange', beforeUnload);
document.removeEventListener('beforeunload', beforeUnload);
if (editor.style.id === style.id) {
closeCurrentTab();
break;
}
break;
case 'editDeleteText':
document.execCommand('delete');
break;
}
}
});
/**
* Invoked for 'visibilitychange' event by default.
* Invoked for 'beforeunload' event when the style is modified and unsaved.
* See https://developers.google.com/web/updates/2018/07/page-lifecycle-api#legacy-lifecycle-apis-to-avoid
* > Never add a beforeunload listener unconditionally or use it as an end-of-session signal.
* > Only add it when a user has unsaved work, and remove it as soon as that work has been saved.
*/
function beforeUnload(e) {
if (saveSizeOnClose) rememberWindowSize();
window.on('beforeunload', e => {
let pos;
if (editor.isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
( // only if not maximized
screenX > 0 || outerWidth < screen.availWidth ||
screenY > 0 || outerHeight < screen.availHeight ||
screenX <= -10 || outerWidth >= screen.availWidth + 10 ||
screenY <= -10 || outerHeight >= screen.availHeight + 10
)
) {
pos = {
left: screenX,
top: screenY,
width: outerWidth,
height: outerHeight,
};
prefs.set('windowPosition', pos);
}
sessionStore.windowPos = JSON.stringify(pos || {});
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;
if (activeElement) {
// blurring triggers 'change' or 'input' event if needed
@ -339,249 +120,259 @@ function beforeUnload(e) {
// refocus if unloading was canceled
setTimeout(() => activeElement.focus());
}
if (editor && editor.dirty.isDirty()) {
if (editor.dirty.isDirty()) {
// neither confirm() nor custom messages work in modern browsers but just in case
e.returnValue = t('styleChangesNotSaved');
}
}
});
function isUsercss(style) {
return (
style.usercssData ||
!style.id && prefs.get('newStyleAsUsercss')
);
}
//#endregion
//#region editor methods
function initStyleData() {
const params = new URLSearchParams(location.search);
const id = Number(params.get('id'));
const createEmptyStyle = () => ({
name: params.get('domain') ||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'',
enabled: true,
sections: [
Object.assign({code: ''},
...Object.keys(CssToProperty)
.map(name => ({
[CssToProperty[name]]: params.get(name) && [params.get(name)] || []
}))
)
],
});
return fetchStyle()
.then(style => {
if (style.id) sessionStorage.justEditedStyleId = style.id;
// we set "usercss" class on <html> when <body> is empty
// so there'll be no flickering of the elements that depend on it
if (isUsercss(style)) {
document.documentElement.classList.add('usercss');
}
// strip URL parameters when invoked for a non-existent id
if (!style.id) {
history.replaceState({}, document.title, location.pathname);
}
return style;
});
(() => {
const toc = [];
const {dirty} = editor;
let {style} = editor;
let wasDirty = false;
function fetchStyle() {
if (id) {
return API.getStyle(id);
}
return Promise.resolve(createEmptyStyle());
}
}
function showHelp(title = '', body) {
const div = $('#help-popup');
div.className = '';
const contents = $('.contents', div);
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? tHTML(body) : body);
}
$('.title', div).textContent = title;
showHelp.close = showHelp.close || (event => {
const canClose =
!event ||
event.type === 'click' ||
(
event.key === 'Escape' &&
!event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey &&
!$('.CodeMirror-hints, #message-box') &&
(
!document.activeElement ||
!document.activeElement.closest('#search-replace-dialog') &&
document.activeElement.matches(':not(input), .can-close-on-esc')
)
);
if (!canClose) {
return;
}
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(() => {
messageBox.confirm(t('confirmDiscardChanges'))
.then(ok => ok && showHelp.close());
});
return;
}
if (div.contains(document.activeElement) && showHelp.originalFocus) {
showHelp.originalFocus.focus();
}
div.style.display = '';
contents.textContent = '';
clearTimeout(contents.timer);
window.removeEventListener('keydown', showHelp.close, true);
window.dispatchEvent(new Event('closeHelp'));
Object.defineProperties(editor, {
scrollInfo: {
get: () => style.id && tryJSONparse(sessionStore['editorScrollInfo' + style.id]) || {},
},
style: {
get: () => style,
set: val => (style = val),
},
});
window.addEventListener('keydown', showHelp.close, true);
$('.dismiss', div).onclick = showHelp.close;
/** @namespace Editor */
Object.assign(editor, {
// reset any inline styles
div.style = 'display: block';
applyScrollInfo(cm, si = (editor.scrollInfo.cms || [])[0]) {
if (si && si.sel) {
const bmOpts = {sublimeBookmark: true, clearWhenEmpty: false}; // copied from sublime.js
cm.operation(() => {
cm.setSelections(...si.sel, {scroll: false});
cm.scrollIntoView(cm.getCursor(), si.parentHeight / 2);
cm.state.sublimeBookmarks = si.bookmarks.map(b => cm.markText(b.from, b.to, bmOpts));
});
}
},
showHelp.originalFocus = document.activeElement;
return div;
}
toggleStyle() {
$('#enabled').checked = !style.enabled;
editor.updateEnabledness(!style.enabled);
},
function showCodeMirrorPopup(title, html, options) {
const popup = showHelp(title, html);
popup.classList.add('big');
updateDirty() {
const isDirty = dirty.isDirty();
if (wasDirty !== isDirty) {
wasDirty = isDirty;
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
}
editor.updateTitle();
},
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
mode: 'css',
lineNumbers: true,
lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
matchBrackets: true,
styleActiveLine: true,
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap')
}, options));
cm.focus();
rerouteHotkeys(false);
updateEnabledness(enabled) {
dirty.modify('enabled', style.enabled, enabled);
style.enabled = enabled;
editor.updateLivePreview();
},
document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto';
updateName(isUserInput) {
if (!editor) return;
if (isUserInput) {
const {value} = $('#name');
dirty.modify('name', style[editor.nameTarget] || style.name, value);
style[editor.nameTarget] = value;
}
editor.updateTitle();
},
const onKeyDown = event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
const search = $('#search-replace-dialog');
const area = search && search.contains(document.activeElement) ? search : popup;
moveFocus(area, event.shiftKey ? -1 : 1);
event.preventDefault();
}
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('closeHelp', () => {
window.removeEventListener('keydown', onKeyDown, true);
document.documentElement.style.removeProperty('pointer-events');
rerouteHotkeys(true);
cm = popup.codebox = null;
}, {once: true});
return popup;
}
function rememberWindowSize() {
if (
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized()
) {
prefs.set('windowPosition', {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
});
}
}
function fixedHeader() {
const scrollPoint = $('#header').clientHeight - 40;
const linterEnabled = prefs.get('editor.linter') !== '';
if (window.scrollY >= scrollPoint && !$('.fixed-header') && linterEnabled) {
$('body').classList.add('fixed-header');
} else if (window.scrollY < 40 && linterEnabled) {
$('body').classList.remove('fixed-header');
}
}
function detectLayout() {
const body = $('body');
const options = $('#options');
const lint = $('#lint');
const compact = window.innerWidth <= 850;
const shortViewportLinter = window.innerHeight < 692;
const shortViewportNoLinter = window.innerHeight < 554;
const linterEnabled = prefs.get('editor.linter') !== '';
if (compact) {
body.classList.add('compact-layout');
options.removeAttribute('open');
options.classList.add('ignore-pref');
lint.removeAttribute('open');
lint.classList.add('ignore-pref');
if (!$('.usercss')) {
clearTimeout(scrollPointTimer);
scrollPointTimer = setTimeout(() => {
const scrollPoint = $('#header').clientHeight - 40;
if (window.scrollY >= scrollPoint && !$('.fixed-header') && linterEnabled) {
body.classList.add('fixed-header');
updateToc(added = editor.sections) {
if (!toc.el) {
toc.el = $('#toc');
toc.elDetails = toc.el.closest('details');
}
if (!toc.elDetails.open) return;
const {sections} = editor;
const first = sections.indexOf(added[0]);
const elFirst = toc.el.children[first];
if (first >= 0 && (!added.focus || !elFirst)) {
for (let el = elFirst, i = first; i < sections.length; i++) {
const entry = sections[i].tocEntry;
if (!deepEqual(entry, toc[i])) {
if (!el) el = toc.el.appendChild($create('li', {tabIndex: 0}));
el.tabIndex = entry.removed ? -1 : 0;
toc[i] = Object.assign({}, entry);
const s = el.textContent = clipString(entry.label) || (
entry.target == null
? t('appliesToEverything')
: clipString(entry.target) + (entry.numTargets > 1 ? ', ...' : ''));
if (s.length > 30) el.title = s;
}
el = el.nextElementSibling;
}
}, 250);
window.addEventListener('scroll', fixedHeader, {passive: true});
}
} else {
body.classList.remove('compact-layout');
body.classList.remove('fixed-header');
window.removeEventListener('scroll', fixedHeader);
if (shortViewportLinter && linterEnabled || shortViewportNoLinter && !linterEnabled) {
options.removeAttribute('open');
options.classList.add('ignore-pref');
if (prefs.get('editor.lint.expanded')) {
lint.setAttribute('open', '');
}
while (toc.length > sections.length) {
toc.el.lastElementChild.remove();
toc.length--;
}
if (added.focus) {
const cls = 'current';
const old = $('.' + cls, toc.el);
const el = elFirst || toc.el.children[first];
if (old && old !== el) old.classList.remove(cls);
el.classList.add(cls);
}
},
});
})();
//#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
*/
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;
});
}
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
//#region colorpickerHelper
(async function colorpickerHelper() {
prefs.subscribe('editor.colorpicker.hotkey', (id, hotkey) => {
CodeMirror.commands.colorpicker = invokeColorpicker;
const extraKeys = CodeMirror.defaults.extraKeys;
for (const key in extraKeys) {
if (extraKeys[key] === 'colorpicker') {
delete extraKeys[key];
break;
}
}
if (hotkey) {
extraKeys[hotkey] = 'colorpicker';
}
});
prefs.subscribe('editor.colorpicker', (id, enabled) => {
const defaults = CodeMirror.defaults;
const keyName = prefs.get('editor.colorpicker.hotkey');
defaults.colorpicker = enabled;
if (enabled) {
if (keyName) {
CodeMirror.commands.colorpicker = invokeColorpicker;
defaults.extraKeys = defaults.extraKeys || {};
defaults.extraKeys[keyName] = 'colorpicker';
}
defaults.colorpicker = {
tooltip: t('colorpickerTooltip'),
popup: {
tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'),
paletteLine: t('numberedLine'),
paletteHint: t('colorpickerPaletteHint'),
hexUppercase: prefs.get('editor.colorpicker.hexUppercase'),
embedderCallback: state => {
['hexUppercase', 'color']
.filter(name => state[name] !== prefs.get('editor.colorpicker.' + name))
.forEach(name => prefs.set('editor.colorpicker.' + name, state[name]));
},
get maxHeight() {
return prefs.get('editor.colorpicker.maxHeight');
},
set maxHeight(h) {
prefs.set('editor.colorpicker.maxHeight', h);
},
},
};
} else {
options.classList.remove('ignore-pref');
lint.classList.remove('ignore-pref');
if (prefs.get('editor.options.expanded')) {
options.setAttribute('open', '');
}
if (prefs.get('editor.lint.expanded')) {
lint.setAttribute('open', '');
if (defaults.extraKeys) {
delete defaults.extraKeys[keyName];
}
}
cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
}, {runNow: true});
await baseInit.domReady;
$('#colorpicker-settings').onclick = function (event) {
event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', {onDone: () => helpPopup.close()});
const popup = helpPopup.show(t('helpKeyMapHotkey'), input);
const bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
$('input', popup).focus();
};
function invokeColorpicker(cm) {
cm.state.colorpicker.openPopup(prefs.get('editor.colorpicker.color'));
}
}
})();
function isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
window.outerWidth >= screen.availWidth &&
window.outerHeight >= screen.availHeight &&
window.screenX > -10 &&
window.screenY > -10 &&
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
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);
}
}
//#endregion

View File

@ -1,88 +1,140 @@
/* global importScripts workerUtil CSSLint require metaParser */
/* global createWorkerApi */// worker-util.js
'use strict';
importScripts('/js/worker-util.js');
const {createAPI, loadScript} = workerUtil;
(() => {
const hasCurlyBraceError = warning =>
warning.text === 'Unnecessary curly bracket (CssSyntaxError)';
let sugarssFallback;
createAPI({
csslint: (code, config) => {
loadScript('/vendor-overwrites/csslint/parserlib.js', '/vendor-overwrites/csslint/csslint.js');
return CSSLint.verify(code, config).messages
.map(m => Object.assign(m, {rule: {id: m.rule.id}}));
},
stylelint: (code, config) => {
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
return require('stylelint').lint({code, config});
},
metalint: code => {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
const result = metaParser.lint(code);
// extract needed info
result.errors = result.errors.map(err =>
({
/** @namespace EditorWorker */
createWorkerApi({
async csslint(code, config) {
require(['/js/csslint/parserlib', '/js/csslint/csslint']); /* global CSSLint */
return CSSLint
.verify(code, config).messages
.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)) {
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 {own: res, global: GlobalKeywords};
},
getRules(linter) {
return ruleRetriever[linter](); // eslint-disable-line no-use-before-define
},
metalint(code) {
require(['/js/meta-parser']); /* global metaParser */
const result = metaParser.lint(code);
// extract needed info
result.errors = result.errors.map(err => ({
code: err.code,
args: err.args,
message: err.message,
index: err.index
})
);
return result;
},
getStylelintRules,
getCsslintRules
});
index: err.index,
}));
return result;
},
function getCsslintRules() {
loadScript('/vendor-overwrites/csslint/csslint.js');
return CSSLint.getRules().map(rule => {
const output = {};
for (const [key, value] of Object.entries(rule)) {
if (typeof value !== 'function') {
output[key] = value;
async stylelint(opts) {
require(['/vendor/stylelint-bundle/stylelint-bundle.min']); /* global stylelint */
try {
let res;
let pass = 0;
/* sugarss is used for stylus-lang by default,
but it fails on normal css syntax so we retry in css mode. */
const isSugarSS = opts.syntax === 'sugarss';
if (sugarssFallback && isSugarSS) opts.syntax = sugarssFallback;
while (
++pass <= 2 &&
(res = (await stylelint.lint(opts)).results[0]) &&
isSugarSS && res.warnings.some(hasCurlyBraceError)
) sugarssFallback = opts.syntax = 'css';
delete res._postcssResult; // huge and unused
return res;
} catch (e) {
delete e.postcssNode; // huge, unused, non-transferable
throw e;
}
}
return output;
},
});
}
function getStylelintRules() {
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
const stylelint = require('stylelint');
const options = {};
const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
const rxString = /"([-\w\s]{3,}?)"/g;
for (const id of Object.keys(stylelint.rules)) {
const ruleCode = String(stylelint.rules[id]);
const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
const possible = m[1];
const set = [];
while ((mStr = rxString.exec(possible))) {
const s = mStr[1];
if (s.includes(' ')) {
set.push(...s.split(/\s+/));
} else {
set.push(s);
const ruleRetriever = {
csslint() {
require(['/js/csslint/csslint']);
return CSSLint.getRuleList().map(rule => {
const output = {};
for (const [key, value] of Object.entries(rule)) {
if (typeof value !== 'function') {
output[key] = value;
}
}
return output;
});
},
stylelint() {
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
const options = {};
const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
const rxString = /"([-\w\s]{3,}?)"/g;
for (const [id, rule] of Object.entries(stylelint.rules)) {
const ruleCode = `${rule()}`;
const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
const possible = m[1];
const set = [];
while ((mStr = rxString.exec(possible))) {
const s = mStr[1];
if (s.includes(' ')) {
set.push(...s.split(/\s+/));
} else {
set.push(s);
}
}
if (possible.includes('ignoreAtRules')) {
set.push('ignoreAtRules');
}
if (possible.includes('ignoreShorthands')) {
set.push('ignoreShorthands');
}
if (set.length) {
sets.push(set);
}
}
options[id] = sets;
}
if (possible.includes('ignoreAtRules')) {
set.push('ignoreAtRules');
}
if (possible.includes('ignoreShorthands')) {
set.push('ignoreShorthands');
}
if (set.length) {
sets.push(set);
}
}
if (sets.length) {
options[id] = sets;
}
}
return options;
}
return options;
},
};
})();

114
edit/embedded-popup.js Normal file
View File

@ -0,0 +1,114 @@
/* global $ $create $remove getEventKeyName */// dom.js
/* global CodeMirror */
/* global baseInit */// base.js
/* global prefs */
/* global t */// localization.js
'use strict';
(() => {
const ID = 'popup-iframe';
const SEL = '#' + ID;
const URL = chrome.runtime.getManifest().browser_action.default_popup;
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
/** @type {HTMLIFrameElement} */
let frame;
let isLoaded;
let scrollbarWidth;
const btn = $create('img', {
id: 'popup-button',
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup,
});
document.documentElement.appendChild(btn);
baseInit.domReady.then(() => {
document.body.appendChild(btn);
// Adding a dummy command to show in keymap help popup
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup';
});
prefs.subscribe('iconset', (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
}, {runNow: true});
window.on('keydown', e => {
if (getEventKeyName(e) === POPUP_HOTKEY) {
embedPopup();
}
});
function embedPopup() {
if ($(SEL)) return;
isLoaded = false;
scrollbarWidth = 0;
frame = $create('iframe', {
id: ID,
src: URL,
height: 600,
width: prefs.get('popupWidth'),
onload: initFrame,
});
window.on('mousedown', removePopup);
document.body.appendChild(frame);
}
function initFrame() {
frame = this;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', removePopupOnEsc);
pw.close = removePopup;
if (pw.IntersectionObserver) {
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, {
attributes: true,
attributeFilter: ['style'],
});
}
function onMutation() {
const body = frame.contentDocument.body;
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}
function onIntersect([e]) {
const pw = frame.contentWindow;
const el = pw.document.scrollingElement;
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
const hasSB = h > el.offsetHeight;
const {width} = e.boundingClientRect;
frame.height = h;
if (!hasSB !== !scrollbarWidth || frame.width - width) {
scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + scrollbarWidth;
}
if (!isLoaded) {
isLoaded = true;
frame.dataset.loaded = '';
}
}
function removePopup() {
frame = null;
$remove(SEL);
window.off('mousedown', removePopup);
}
function removePopupOnEsc(e) {
if (getEventKeyName(e) === 'Escape') {
removePopup();
}
}
})();

View File

@ -183,7 +183,8 @@
/*********** CM search highlight restyling, which shouldn't need color variables ****************/
body.find-open .search-target-editor {
outline-color: darkorange !important;
box-shadow: 0 0 0 1px hsl(33, 100%, 50%), 0 0 3px hsla(33, 100%, 50%, .4);
/* same as our global.css focus rule */
}
body.find-open .cm-searching {

View File

@ -1,8 +1,14 @@
/* global CodeMirror focusAccessibility colorMimicry editor chromeLocal
onDOMready $ $$ $create t debounce tryRegExp stringAsRegExp template */
/* global $ $$ $create $remove focusAccessibility toggleDataset */// dom.js
/* global CodeMirror */
/* global chromeLocal */// storage-util.js
/* global colorMimicry */
/* global debounce stringAsRegExp tryRegExp */// toolbox.js
/* global editor */
/* global t */// localization.js
'use strict';
onDOMready().then(() => {
(() => {
require(['/edit/global-search.css']);
//region Constants and state
@ -10,7 +16,6 @@ onDOMready().then(() => {
const ANNOTATE_SCROLLBAR_DELAY = 350;
const ANNOTATE_SCROLLBAR_OPTIONS = {maxMatches: 10e3};
const STORAGE_UPDATE_DELAY = 500;
const SCROLL_REVEAL_MIN_PX = 50;
const DIALOG_SELECTOR = '#search-replace-dialog';
const DIALOG_STYLE_SELECTOR = '#search-replace-dialog-style';
@ -22,6 +27,7 @@ onDOMready().then(() => {
const RX_MAYBE_REGEXP = /^\s*\/(.+?)\/([simguy]*)\s*$/;
const state = {
firstRun: true,
// used for case-sensitive matching directly
find: '',
// used when /re/ is detected or for case-insensitive matching
@ -64,10 +70,12 @@ onDOMready().then(() => {
if (found) {
const target = $('.' + TARGET_CLASS);
const cm = target.CodeMirror;
(cm || target).focus();
/* Since this runs in `keydown` event we have to delay focusing
* to prevent CodeMirror from seeing and handling the key */
setTimeout(() => (cm || target).focus());
if (cm) {
const pos = cm.state.search.searchPos;
cm.setSelection(pos.from, pos.to);
const {from, to} = cm.state.search.searchPos;
cm.jumpToPos(from, to);
}
}
destroyDialog({restoreFocus: !found});
@ -78,7 +86,7 @@ onDOMready().then(() => {
doReplace();
return;
}
return !event.target.closest(focusAccessibility.ELEMENTS.join(','));
return !focusAccessibility.closest(event.target);
},
'Esc': () => {
destroyDialog({restoreFocus: true});
@ -99,7 +107,7 @@ onDOMready().then(() => {
state.lastFind = '';
toggleDataset(this, 'enabled', !state.icase);
doSearch({canAdvance: false});
}
},
},
};
@ -125,17 +133,17 @@ onDOMready().then(() => {
},
onfocusout() {
if (!state.dialog.contains(document.activeElement)) {
state.dialog.addEventListener('focusin', EVENTS.onfocusin);
state.dialog.removeEventListener('focusout', EVENTS.onfocusout);
state.dialog.on('focusin', EVENTS.onfocusin);
state.dialog.off('focusout', EVENTS.onfocusout);
}
},
onfocusin() {
state.dialog.addEventListener('focusout', EVENTS.onfocusout);
state.dialog.removeEventListener('focusin', EVENTS.onfocusin);
state.dialog.on('focusout', EVENTS.onfocusout);
state.dialog.off('focusin', EVENTS.onfocusin);
trimUndoHistory();
enableUndoButton(state.undoHistory.length);
if (state.find) doSearch({canAdvance: false});
}
},
};
const DIALOG_PROPS = {
@ -151,7 +159,7 @@ onDOMready().then(() => {
state.replace = this.value;
adjustTextareaSize(this);
debounce(writeStorage, STORAGE_UPDATE_DELAY);
}
},
},
};
@ -168,7 +176,7 @@ onDOMready().then(() => {
replace(cm) {
state.reverse = false;
focusDialog('replace', cm);
}
},
};
COMMANDS.replaceAll = COMMANDS.replace;
@ -176,7 +184,6 @@ onDOMready().then(() => {
Object.assign(CodeMirror.commands, COMMANDS);
readStorage();
return;
//region Find
@ -241,6 +248,7 @@ onDOMready().then(() => {
} else {
showTally(0, 0);
}
state.firstRun = false;
return found;
}
@ -559,15 +567,16 @@ onDOMready().then(() => {
function createDialog(type) {
state.originalFocus = document.activeElement;
state.firstRun = true;
const dialog = state.dialog = template.searchReplaceDialog.cloneNode(true);
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
Object.assign(dialog, DIALOG_PROPS.dialog);
dialog.addEventListener('focusout', EVENTS.onfocusout);
dialog.on('focusout', EVENTS.onfocusout);
dialog.dataset.type = type;
dialog.style.pointerEvents = 'auto';
const content = $('[data-type="content"]', dialog);
content.parentNode.replaceChild(template[type].cloneNode(true), content);
content.parentNode.replaceChild(t.template[type].cloneNode(true), content);
createInput(0, 'input', state.find);
createInput(1, 'input2', state.replace);
@ -575,9 +584,9 @@ onDOMready().then(() => {
state.tally = $('[data-type="tally"]', dialog);
const colors = {
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}),
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}),
body: colorMimicry(document.body, {bg: 'backgroundColor'}),
input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
};
document.documentElement.appendChild(
$(DIALOG_STYLE_SELECTOR) ||
@ -630,14 +639,14 @@ onDOMready().then(() => {
input.value = value;
Object.assign(input, DIALOG_PROPS[name]);
input.parentElement.appendChild(template.clearSearch.cloneNode(true));
input.parentElement.appendChild(t.template.clearSearch.cloneNode(true));
$('[data-action]', input.parentElement)._input = input;
}
function destroyDialog({restoreFocus = false} = {}) {
state.input = null;
$.remove(DIALOG_SELECTOR);
$remove(DIALOG_SELECTOR);
debounce.unregister(doSearch);
makeTargetVisible(null);
if (restoreFocus) {
@ -671,7 +680,7 @@ onDOMready().then(() => {
el.style.width = newWidth + 'px';
}
const numLines = el.value.split('\n').length;
if (numLines !== parseInt(el.rows)) {
if (numLines !== Number(el.rows)) {
el.rows = numLines;
}
el.style.overflowX = el.scrollWidth > el.clientWidth ? '' : 'hidden';
@ -766,25 +775,22 @@ onDOMready().then(() => {
// scrolls the editor to reveal the match
function makeMatchVisible(cm, searchCursor) {
const canFocus = !state.dialog || !state.dialog.contains(document.activeElement);
const canFocus = !state.firstRun && (!state.dialog || !state.dialog.contains(document.activeElement));
state.cm = cm;
// scroll within the editor
const pos = searchCursor.pos;
Object.assign(getStateSafe(cm), {
cursorPos: {
from: cm.getCursor('from'),
to: cm.getCursor('to'),
},
searchPos: searchCursor.pos,
searchPos: pos,
unclosedOp: !cm.curOp,
});
if (!cm.curOp) cm.startOperation();
if (canFocus) cm.setSelection(searchCursor.pos.from, searchCursor.pos.to);
cm.scrollIntoView(searchCursor.pos, SCROLL_REVEAL_MIN_PX);
// scroll to the editor itself
editor.scrollToEditor(cm);
if (!state.firstRun) {
cm.jumpToPos(pos.from, pos.to);
}
// focus or expose as the current search target
clearMarker();
if (canFocus) {
@ -793,7 +799,6 @@ onDOMready().then(() => {
} else {
makeTargetVisible(cm.display.wrapper);
// mark the match
const pos = searchCursor.pos;
state.marker = cm.state.search.marker = cm.markText(pos.from, pos.to, {
className: MATCH_CLASS,
clearOnEnter: true,
@ -871,15 +876,6 @@ onDOMready().then(() => {
}
function toggleDataset(el, prop, state) {
if (state) {
el.dataset[prop] = '';
} else {
delete el.dataset[prop];
}
}
function saveWindowScrollPos() {
state.scrollX = window.scrollX;
state.scrollY = window.scrollY;
@ -900,7 +896,9 @@ onDOMready().then(() => {
// produces [i, i+1, i-1, i+2, i-2, i+3, i-3, ...]
function radiateArray(arr, focalIndex) {
const result = [arr[focalIndex]];
const focus = arr[focalIndex];
if (!focus) return arr;
const result = [focus];
const len = arr.length;
for (let i = 1; i < len; i++) {
if (focalIndex + i < len) {
@ -946,4 +944,4 @@ onDOMready().then(() => {
}
//endregion
});
})();

View File

@ -1,197 +0,0 @@
/* global memoize editorWorker showCodeMirrorPopup loadScript messageBox
LINTER_DEFAULTS rerouteHotkeys $ $create $createLink tryJSONparse t
chromeSync */
'use strict';
(() => {
document.addEventListener('DOMContentLoaded', () => {
$('#linter-settings').addEventListener('click', showLintConfig);
}, {once: true});
function stringifyConfig(config) {
return JSON.stringify(config, null, 2)
.replace(/,\n\s+\{\n\s+("severity":\s"\w+")\n\s+\}/g, ', {$1}');
}
function showLinterErrorMessage(title, contents, popup) {
messageBox({
title,
contents,
className: 'danger center lint-config',
buttons: [t('confirmOK')],
}).then(() => popup && popup.codebox && popup.codebox.focus());
}
function showLintConfig() {
const linter = $('#editor.linter').value;
if (!linter) {
return;
}
const storageName = linter === 'stylelint' ? 'editorStylelintConfig' : 'editorCSSLintConfig';
const getRules = memoize(linter === 'stylelint' ?
editorWorker.getStylelintRules : editorWorker.getCsslintRules);
const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';
const defaultConfig = stringifyConfig(
linter === 'stylelint' ? LINTER_DEFAULTS.STYLELINT : LINTER_DEFAULTS.CSSLINT
);
const title = t('linterConfigPopupTitle', linterTitle);
const popup = showCodeMirrorPopup(title, null, {
lint: false,
extraKeys: {'Ctrl-Enter': save},
hintOptions: {hint},
});
$('.contents', popup).appendChild(makeFooter());
let cm = popup.codebox;
cm.focus();
chromeSync.getLZValue(storageName).then(config => {
cm.setValue(config ? stringifyConfig(config) : defaultConfig);
cm.clearHistory();
cm.markClean();
updateButtonState();
});
cm.on('changes', updateButtonState);
rerouteHotkeys(false);
window.addEventListener('closeHelp', () => {
rerouteHotkeys(true);
cm = null;
}, {once: true});
loadScript([
'/vendor/codemirror/mode/javascript/javascript.js',
'/vendor/codemirror/addon/lint/json-lint.js',
'/vendor/jsonlint/jsonlint.js'
]).then(() => {
cm.setOption('mode', 'application/json');
cm.setOption('lint', true);
});
function findInvalidRules(config, linter) {
return getRules()
.then(rules => {
if (linter === 'stylelint') {
return Object.keys(config.rules).filter(k => !config.rules.hasOwnProperty(k));
}
const ruleSet = new Set(rules.map(r => r.id));
return Object.keys(config).filter(k => !ruleSet.has(k));
});
}
function makeFooter() {
return $create('div', [
$create('p', [
$createLink(
linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
t('linterRulesLink')),
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
]),
$create('.buttons', [
$create('button.save', {onclick: save, title: 'Ctrl-Enter'}, t('styleSaveLabel')),
$create('button.cancel', {onclick: cancel}, t('confirmClose')),
$create('button.reset', {onclick: reset, title: t('linterResetMessage')}, t('genericResetLabel')),
]),
]);
}
function save(event) {
if (event instanceof Event) {
event.preventDefault();
}
const json = tryJSONparse(cm.getValue());
if (!json) {
showLinterErrorMessage(linter, t('linterJSONError'), popup);
cm.focus();
return;
}
findInvalidRules(json, linter).then(invalid => {
if (invalid.length) {
showLinterErrorMessage(linter, [
t('linterInvalidConfigError'),
$create('ul', invalid.map(name => $create('li', name))),
], popup);
return;
}
chromeSync.setLZValue(storageName, json);
cm.markClean();
cm.focus();
updateButtonState();
});
}
function reset(event) {
event.preventDefault();
cm.setValue(defaultConfig);
cm.focus();
updateButtonState();
}
function cancel(event) {
event.preventDefault();
$('.dismiss').dispatchEvent(new Event('click'));
}
function updateButtonState() {
$('.save', popup).disabled = cm.isClean();
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
}
function hint(cm) {
return getRules().then(rules => {
let ruleIds, options;
if (linter === 'stylelint') {
ruleIds = Object.keys(rules);
options = rules;
} else {
ruleIds = rules.map(r => r.id);
options = {};
}
const cursor = cm.getCursor();
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
const {line, ch} = cursor;
const quoted = string.startsWith('"');
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
const depth = getLexicalDepth(lexical);
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
let [, prevWord] = search.find(true) || [];
let words = [];
if (depth === 1 && linter === 'stylelint') {
words = quoted ? ['rules'] : [];
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
words = ruleIds;
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
words = !quoted ? ['true', 'false', 'null'] :
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
} else if (depth === 4 && prevWord === 'severity') {
words = ['error', 'warning'];
} else if (depth === 4) {
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
} else if (depth === 5 && lexical.type === ']' && quoted) {
while (prevWord && !ruleIds.includes(prevWord)) {
prevWord = (search.find(true) || [])[1];
}
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
}
return {
list: words.filter(word => word.startsWith(leftPart)),
from: {line, ch: start + (quoted ? 1 : 0)},
to: {line, ch: string.endsWith('"') ? end - 1 : end},
};
});
}
function getLexicalDepth(lexicalState) {
let depth = 0;
while ((lexicalState = lexicalState.prev)) {
depth++;
}
return depth;
}
}
})();

View File

@ -1,222 +0,0 @@
/* exported LINTER_DEFAULTS */
'use strict';
const LINTER_DEFAULTS = (() => {
const SEVERITY = {severity: 'warning'};
const STYLELINT = {
// 'sugarss' is a indent-based syntax like Sass or Stylus
// ref: https://github.com/postcss/postcss#syntaxes
// syntax: 'sugarss',
// ** recommended rules **
// ref: https://github.com/stylelint/stylelint-config-recommended/blob/master/index.js
rules: {
'at-rule-no-unknown': [true, {
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
'severity': 'warning'
}],
'block-no-empty': [true, SEVERITY],
'color-no-invalid-hex': [true, SEVERITY],
'declaration-block-no-duplicate-properties': [true, {
'ignore': ['consecutive-duplicates-with-different-values'],
'severity': 'warning'
}],
'declaration-block-no-shorthand-property-overrides': [true, SEVERITY],
'font-family-no-duplicate-names': [true, SEVERITY],
'function-calc-no-unspaced-operator': [true, SEVERITY],
'function-linear-gradient-no-nonstandard-direction': [true, SEVERITY],
'keyframe-declaration-no-important': [true, SEVERITY],
'media-feature-name-no-unknown': [true, SEVERITY],
/* recommended true */
'no-empty-source': false,
'no-extra-semicolons': [true, SEVERITY],
'no-invalid-double-slash-comments': [true, SEVERITY],
'property-no-unknown': [true, SEVERITY],
'selector-pseudo-class-no-unknown': [true, SEVERITY],
'selector-pseudo-element-no-unknown': [true, SEVERITY],
'selector-type-no-unknown': false, // for scss/less/stylus-lang
'string-no-newline': [true, SEVERITY],
'unit-no-unknown': [true, SEVERITY],
// ** non-essential rules
'comment-no-empty': false,
'declaration-block-no-redundant-longhand-properties': false,
'shorthand-property-no-redundant-values': false,
// ** stylistic rules **
/*
'at-rule-empty-line-before': [
'always',
{
'except': [
'blockless-after-same-name-blockless',
'first-nested'
],
'ignore': [
'after-comment'
]
}
],
'at-rule-name-case': 'lower',
'at-rule-name-space-after': 'always-single-line',
'at-rule-semicolon-newline-after': 'always',
'block-closing-brace-empty-line-before': 'never',
'block-closing-brace-newline-after': 'always',
'block-closing-brace-newline-before': 'always-multi-line',
'block-closing-brace-space-before': 'always-single-line',
'block-opening-brace-newline-after': 'always-multi-line',
'block-opening-brace-space-after': 'always-single-line',
'block-opening-brace-space-before': 'always',
'color-hex-case': 'lower',
'color-hex-length': 'short',
'comment-empty-line-before': [
'always',
{
'except': [
'first-nested'
],
'ignore': [
'stylelint-commands'
]
}
],
'comment-whitespace-inside': 'always',
'custom-property-empty-line-before': [
'always',
{
'except': [
'after-custom-property',
'first-nested'
],
'ignore': [
'after-comment',
'inside-single-line-block'
]
}
],
'declaration-bang-space-after': 'never',
'declaration-bang-space-before': 'always',
'declaration-block-semicolon-newline-after': 'always-multi-line',
'declaration-block-semicolon-space-after': 'always-single-line',
'declaration-block-semicolon-space-before': 'never',
'declaration-block-single-line-max-declarations': 1,
'declaration-block-trailing-semicolon': 'always',
'declaration-colon-newline-after': 'always-multi-line',
'declaration-colon-space-after': 'always-single-line',
'declaration-colon-space-before': 'never',
'declaration-empty-line-before': [
'always',
{
'except': [
'after-declaration',
'first-nested'
],
'ignore': [
'after-comment',
'inside-single-line-block'
]
}
],
'function-comma-newline-after': 'always-multi-line',
'function-comma-space-after': 'always-single-line',
'function-comma-space-before': 'never',
'function-max-empty-lines': 0,
'function-name-case': 'lower',
'function-parentheses-newline-inside': 'always-multi-line',
'function-parentheses-space-inside': 'never-single-line',
'function-whitespace-after': 'always',
'indentation': 2,
'length-zero-no-unit': true,
'max-empty-lines': 1,
'media-feature-colon-space-after': 'always',
'media-feature-colon-space-before': 'never',
'media-feature-name-case': 'lower',
'media-feature-parentheses-space-inside': 'never',
'media-feature-range-operator-space-after': 'always',
'media-feature-range-operator-space-before': 'always',
'media-query-list-comma-newline-after': 'always-multi-line',
'media-query-list-comma-space-after': 'always-single-line',
'media-query-list-comma-space-before': 'never',
'no-eol-whitespace': true,
'no-missing-end-of-source-newline': true,
'number-leading-zero': 'always',
'number-no-trailing-zeros': true,
'property-case': 'lower',
'rule-empty-line-before': [
'always-multi-line',
{
'except': [
'first-nested'
],
'ignore': [
'after-comment'
]
}
],
'selector-attribute-brackets-space-inside': 'never',
'selector-attribute-operator-space-after': 'never',
'selector-attribute-operator-space-before': 'never',
'selector-combinator-space-after': 'always',
'selector-combinator-space-before': 'always',
'selector-descendant-combinator-no-non-space': true,
'selector-list-comma-newline-after': 'always',
'selector-list-comma-space-before': 'never',
'selector-max-empty-lines': 0,
'selector-pseudo-class-case': 'lower',
'selector-pseudo-class-parentheses-space-inside': 'never',
'selector-pseudo-element-case': 'lower',
'selector-pseudo-element-colon-notation': 'double',
'selector-type-case': 'lower',
'unit-case': 'lower',
'value-list-comma-newline-after': 'always-multi-line',
'value-list-comma-space-after': 'always-single-line',
'value-list-comma-space-before': 'never',
'value-list-max-empty-lines': 0
*/
}
};
const CSSLINT = {
// Default warnings
'display-property-grouping': 1,
'duplicate-properties': 1,
'empty-rules': 1,
'errors': 1,
'warnings': 1,
'known-properties': 1,
// Default disabled
'adjoining-classes': 0,
'box-model': 0,
'box-sizing': 0,
'bulletproof-font-face': 0,
'compatible-vendor-prefixes': 0,
'duplicate-background-images': 0,
'fallback-colors': 0,
'floats': 0,
'font-faces': 0,
'font-sizes': 0,
'gradients': 0,
'ids': 0,
'import': 0,
'import-ie-limit': 0,
'important': 0,
'order-alphabetical': 0,
'outline-none': 0,
'overqualified-elements': 0,
'qualified-headings': 0,
'regex-selectors': 0,
'rules-count': 0,
'selector-max': 0,
'selector-max-approaching': 0,
'selector-newline': 0,
'shorthand': 0,
'star-property-hack': 0,
'text-indent': 0,
'underscore-property-hack': 0,
'unique-headings': 0,
'universal-selector': 0,
'unqualified-attributes': 0,
'vendor-prefix': 0,
'zero-units': 0
};
return {STYLELINT, CSSLINT, SEVERITY};
})();

248
edit/linter-dialogs.js Normal file
View File

@ -0,0 +1,248 @@
/* global $ $create $createLink messageBoxProxy */// dom.js
/* global chromeSync */// storage-util.js
/* global editor */
/* global helpPopup showCodeMirrorPopup */// util.js
/* global linterMan */
/* global t */// localization.js
/* global tryJSONparse */// toolbox.js
'use strict';
(() => {
/** @type {{csslint:{}, stylelint:{}}} */
const RULES = {};
let cm;
let defaultConfig;
let isStylelint;
let linter;
let popup;
linterMan.showLintConfig = async () => {
linter = await getLinter();
if (!linter) {
return;
}
await require([
'/vendor/codemirror/mode/javascript/javascript',
'/vendor/codemirror/addon/lint/json-lint',
'/vendor/jsonlint/jsonlint',
]);
const config = await chromeSync.getLZValue(chromeSync.LZ_KEY[linter]);
const title = t('linterConfigPopupTitle', isStylelint ? 'Stylelint' : 'CSSLint');
isStylelint = linter === 'stylelint';
defaultConfig = stringifyConfig(linterMan.DEFAULTS[linter]);
popup = showCodeMirrorPopup(title, null, {
extraKeys: {'Ctrl-Enter': onConfigSave},
hintOptions: {hint},
lint: true,
mode: 'application/json',
value: config ? stringifyConfig(config) : defaultConfig,
});
$('.contents', popup).appendChild(
$create('div', [
$create('p', [
$createLink(
isStylelint
? 'https://stylelint.io/user-guide/rules/'
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
t('linterRulesLink')),
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
]),
$create('.buttons', [
$create('button.save', {onclick: onConfigSave, title: 'Ctrl-Enter'},
t('styleSaveLabel')),
$create('button.cancel', {onclick: onConfigCancel}, t('confirmClose')),
$create('button.reset', {onclick: onConfigReset, title: t('linterResetMessage')},
t('genericResetLabel')),
]),
]));
cm = popup.codebox;
cm.focus();
const rulesStr = getActiveRules().join('|');
if (rulesStr) {
const rx = new RegExp(`"(${rulesStr})"\\s*:`);
let line = 0;
cm.startOperation();
cm.eachLine(({text}) => {
const m = rx.exec(text);
if (m) {
const ch = m.index + 1;
cm.markText({line, ch}, {line, ch: ch + m[1].length}, {className: 'active-linter-rule'});
}
++line;
});
cm.endOperation();
}
cm.on('changes', updateConfigButtons);
updateConfigButtons();
window.on('closeHelp', onConfigClose, {once: true});
};
linterMan.showLintHelp = async () => {
const linter = await getLinter();
const baseUrl = linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
: '';
let headerLink, template;
if (linter === 'csslint') {
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
template = ruleID => {
const rule = RULES.csslint.find(rule => rule.id === ruleID);
return rule &&
$create('li', [
$create('b', ruleID + ': '),
rule.url ? $createLink(rule.url, rule.name) : $create('span', `"${rule.name}"`),
$create('p', rule.desc),
]);
};
} else {
headerLink = $createLink(baseUrl, 'stylelint');
template = rule =>
$create('li',
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
}
const header = t('linterIssuesHelp', '\x01').split('\x01');
helpPopup.show(t('linterIssues'),
$create([
header[0], headerLink, header[1],
$create('ul.rules', getActiveRules().map(template)),
$create('button', {onclick: linterMan.showLintConfig}, t('configureStyle')),
]));
};
function getActiveRules() {
const all = [...linterMan.getIssues()].map(issue => issue.rule);
const uniq = new Set(all);
return [...uniq];
}
function getLexicalDepth(lexicalState) {
let depth = 0;
while ((lexicalState = lexicalState.prev)) {
depth++;
}
return depth;
}
async function getLinter() {
const val = $('#editor.linter').value;
if (val && !RULES[val]) {
RULES[val] = await linterMan.worker.getRules(val);
}
return val;
}
function hint(cm) {
const rules = RULES[linter];
let ruleIds, options;
if (isStylelint) {
ruleIds = Object.keys(rules);
options = rules;
} else {
ruleIds = rules.map(r => r.id);
options = {};
}
const cursor = cm.getCursor();
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
const {line, ch} = cursor;
const quoted = string.startsWith('"');
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
const depth = getLexicalDepth(lexical);
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
let [, prevWord] = search.find(true) || [];
let words = [];
if (depth === 1 && isStylelint) {
words = quoted ? ['rules'] : [];
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
words = ruleIds;
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
words = !quoted ? ['true', 'false', 'null'] :
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
} else if (depth === 4 && prevWord === 'severity') {
words = ['error', 'warning'];
} else if (depth === 4) {
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
} else if (depth === 5 && lexical.type === ']' && quoted) {
while (prevWord && !ruleIds.includes(prevWord)) {
prevWord = (search.find(true) || [])[1];
}
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
}
return {
list: words.filter(word => word.startsWith(leftPart)),
from: {line, ch: start + (quoted ? 1 : 0)},
to: {line, ch: string.endsWith('"') ? end - 1 : end},
};
}
function onConfigCancel() {
helpPopup.close();
editor.closestVisible().focus();
}
function onConfigClose() {
cm = null;
}
function onConfigReset(event) {
event.preventDefault();
cm.setValue(defaultConfig);
cm.focus();
updateConfigButtons();
}
async function onConfigSave(event) {
if (event instanceof Event) {
event.preventDefault();
}
const json = tryJSONparse(cm.getValue());
if (!json) {
showLinterErrorMessage(linter, t('linterJSONError'), popup);
cm.focus();
return;
}
let invalid;
if (isStylelint) {
invalid = Object.keys(json.rules).filter(k => !RULES.stylelint.hasOwnProperty(k));
} else {
const ids = RULES.csslint.map(r => r.id);
invalid = Object.keys(json).filter(k => !ids.includes(k));
}
if (invalid.length) {
showLinterErrorMessage(linter, [
t('linterInvalidConfigError'),
$create('ul', invalid.map(name => $create('li', name))),
], popup);
return;
}
chromeSync.setLZValue(chromeSync.LZ_KEY[linter], json);
cm.markClean();
cm.focus();
updateConfigButtons();
}
function stringifyConfig(config) {
return JSON.stringify(config, null, 2)
.replace(/,\n\s+{\n\s+("severity":\s"\w+")\n\s+}/g, ', {$1}');
}
async function showLinterErrorMessage(title, contents, popup) {
await messageBoxProxy.show({
title,
contents,
className: 'danger center lint-config',
buttons: [t('confirmOK')],
});
if (popup && popup.codebox) {
popup.codebox.focus();
}
}
function updateConfigButtons() {
$('.save', popup).disabled = cm.isClean();
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
}
})();

View File

@ -1,103 +0,0 @@
/* global LINTER_DEFAULTS linter editorWorker prefs chromeSync */
'use strict';
(() => {
registerLinters({
csslint: {
storageName: 'editorCSSLintConfig',
lint: csslint,
validMode: mode => mode === 'css',
getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config)
},
stylelint: {
storageName: 'editorStylelintConfig',
lint: stylelint,
validMode: () => true,
getConfig: config => ({
syntax: 'sugarss',
rules: Object.assign({}, LINTER_DEFAULTS.STYLELINT.rules, config && config.rules)
})
}
});
function stylelint(text, config) {
return editorWorker.stylelint(text, config)
.then(({results}) => !results[0] ? [] :
results[0].warnings.map(({line, column: ch, text, severity}) => ({
from: {line: line - 1, ch: ch - 1},
to: {line: line - 1, ch},
message: text
.replace('Unexpected ', '')
.replace(/^./, firstLetter => firstLetter.toUpperCase())
.replace(/\s*\([^(]+\)$/, ''), // strip the rule,
rule: text.replace(/^.*?\s*\(([^(]+)\)$/, '$1'),
severity,
})));
}
function csslint(text, config) {
return editorWorker.csslint(text, config)
.then(results =>
results
.map(({line, col: ch, message, rule, type: severity}) => line && {
message,
from: {line: line - 1, ch: ch - 1},
to: {line: line - 1, ch},
rule: rule.id,
severity,
})
.filter(Boolean)
);
}
function registerLinters(engines) {
const configs = new Map();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') {
return;
}
for (const [name, engine] of Object.entries(engines)) {
if (changes.hasOwnProperty(engine.storageName)) {
chromeSync.getLZValue(engine.storageName)
.then(config => {
configs.set(name, engine.getConfig(config));
linter.run();
});
}
}
});
linter.register((text, options, cm) => {
const selectedLinter = prefs.get('editor.linter');
if (!selectedLinter) {
return;
}
const mode = cm.getOption('mode');
if (engines[selectedLinter].validMode(mode)) {
return runLint(selectedLinter);
}
for (const [name, engine] of Object.entries(engines)) {
if (engine.validMode(mode)) {
return runLint(name);
}
}
function runLint(name) {
return getConfig(name)
.then(config => engines[name].lint(text, config, mode));
}
});
function getConfig(name) {
if (configs.has(name)) {
return Promise.resolve(configs.get(name));
}
return chromeSync.getLZValue(engines[name].storageName)
.then(config => {
configs.set(name, engines[name].getConfig(config));
return configs.get(name);
});
}
}
})();

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