Refactor the entire storage system and the section editor (#518)

* Squashed commit of the following:

commit d84c4dc3fe
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 19:13:29 2018 +0800

    Fix: remove unused comment

commit 46027120ec
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 19:09:06 2018 +0800

    Add: handle styleUpdated message

commit f85d4de39b
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 18:59:29 2018 +0800

    Fix: handle styleAdded message in popup

commit 81f3e69574
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 18:50:54 2018 +0800

    Change: getStylesInfoByUrl -> getStylesByUrl

commit f9dc04558f
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 18:48:20 2018 +0800

    Fix: drop getStylesInfo

commit fea04d591f
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 18:39:28 2018 +0800

    Fix: remove unused ignoreChromeError

commit 2aff14e213
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 18:09:53 2018 +0800

    Fix: don't dup promisify in prefs

commit d4ddfcc713
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 17:56:16 2018 +0800

    Change: drop .last and .rotate

commit 85e70491e4
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 17:36:00 2018 +0800

    Fix: unused renderIndex

commit 7acb131642
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 17:32:49 2018 +0800

    Fix: update title on input

commit a39405ac4c
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 17:17:20 2018 +0800

    Fix: remove unused messages

commit 14c2fdbb58
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 16:36:12 2018 +0800

    Fix: dirty state for new added applies

commit fb1b49b8bb
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 16:27:17 2018 +0800

    Fix: minor

commit 2c2d849fa4
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 16:20:14 2018 +0800

    Fix: drop unused getCode

commit f133c3e67a
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 16:18:14 2018 +0800

    Fix: drop unused lastActive

commit 05a6208f5c
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 16:17:45 2018 +0800

    Fix: minor

commit 05a87ed00f
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 15:58:33 2018 +0800

    Fix: minor

commit 576f73f333
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 03:03:35 2018 +0800

    Fix: always register listeners

commit e93819deb4
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:58:49 2018 +0800

    Fix: unused statement

commit 39b11685b4
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:54:29 2018 +0800

    Fix: minor

commit 9dd3cd43c1
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:49:22 2018 +0800

    Fix: don't reorder options

commit 90aadfd728
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:43:52 2018 +0800

    Fix: drop __ERROR__

commit 838c21e3b3
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:36:20 2018 +0800

    Fix: use findStyle API

commit 93a4cdf595
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:34:05 2018 +0800

    Add: findStyle API

commit 8e75871b9b
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:19:01 2018 +0800

    Breaking: drop getStylesFallback

commit ad06551440
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 02:16:48 2018 +0800

    Fix: use dataurl to inject page script

commit cb5cbb4d10
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 01:39:50 2018 +0800

    Fix: various

commit 53efd78b89
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 01:12:57 2018 +0800

    Update doc

commit 7d005f3eaa
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 01:09:22 2018 +0800

    Change: kill style.reason

commit fc53bed3de
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 00:56:04 2018 +0800

    Fix: doo many indents

commit 14e321d258
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 00:40:23 2018 +0800

    Fix: don't update icon for popup and options

commit 01bdd529bc
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 00:39:17 2018 +0800

    Fix: updateCount

commit b9968830d3
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 00:38:49 2018 +0800

    Fix: don't send null value

commit ff3bf6f52d
Author: eight <eight04@gmail.com>
Date:   Sun Oct 14 00:03:34 2018 +0800

    Add: styleViaAPI updateCount

commit 39d21c3d29
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 23:57:45 2018 +0800

    Fix: broadcastError -> ignoreError

commit ecb622c93c
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 21:29:06 2018 +0800

    Fix: implement styleViaAPI

commit 7c3d49c005
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 17:50:28 2018 +0800

    Fix: ROOT may change in XML pages

commit 3fd8d937f3
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 16:49:43 2018 +0800

    Fix: various

commit 859afc8ee9
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 16:39:54 2018 +0800

    Enhance: don't cache enabled state

commit fbe77a8d15
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 16:15:07 2018 +0800

    Fix: various

commit a4fc3e9162
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 16:11:38 2018 +0800

    Fix: various

commit 7e0eddeb8f
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 15:58:31 2018 +0800

    Fix: various

commit 8b4ab47d89
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 15:20:10 2018 +0800

    Add: some type hint

commit 7d340d62dc
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 15:13:11 2018 +0800

    Change: drop storage.js, some functions are moved to sections-util

commit d286997d6a
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 15:12:00 2018 +0800

    Fix: minor

commit d60db9dbef
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 15:03:10 2018 +0800

    Fix: minor

commit 43afa31fa0
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 14:50:31 2018 +0800

    Fix: update tab icon on forward/backward

commit f08faea149
Author: eight <eight04@gmail.com>
Date:   Sat Oct 13 13:50:03 2018 +0800

    Fix: parallel import

commit 4d06435486
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 23:32:03 2018 +0800

    Add: importStyle API

commit c55675912e
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 23:14:46 2018 +0800

    Fix: refactor import-export

commit 86ea846a89
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 17:34:36 2018 +0800

    Fix: search db is broken

commit 831ca07c2d
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 17:29:35 2018 +0800

    fixup! Add: implement sloppy regexp indicator

commit e67b7f4f36
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 17:27:19 2018 +0800

    Add: implement sloppy regexp indicator

commit 36e13f88f0
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 16:59:23 2018 +0800

    Add: return excluded/sloppy state in getStylesInfoByUrl

commit f6ce78f55b
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 16:39:47 2018 +0800

    Fix: dead object

commit 5ae95a1ad9
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 16:27:54 2018 +0800

    Fix: don't reinit all editors on save

commit 1a5a206fe6
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 16:18:40 2018 +0800

    Refactor: pull out sections editor section

commit 8016346035
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 15:30:35 2018 +0800

    Fix: replaceStyle make style name undefined

commit fa080d1913
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 15:21:36 2018 +0800

    Fix: catch csp error

commit e0b064115d
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 15:03:00 2018 +0800

    Fix: use a simple eval to execute page scripts

commit 405b7f8f06
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 03:48:13 2018 +0800

    Fix: removed unused API

commit 1b2c88f926
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 03:46:51 2018 +0800

    Fix: no need to access db

commit a8131fc9c5
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 03:43:31 2018 +0800

    Fix: remove unused methods

commit 3ae0c4dd13
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 03:10:26 2018 +0800

    Enhance: allow matcher to return verbose info

commit 0ea7ada48f
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 02:02:14 2018 +0800

    Fix: content script may load before the background is ready

commit 04c2d6bbf6
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 01:49:52 2018 +0800

    Fix: throw receiving end doesn't exist message

commit f0c0bc4d6a
Author: eight <eight04@gmail.com>
Date:   Fri Oct 12 01:11:17 2018 +0800

    Fix: unwrap error

commit 4d42765d6c
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 23:55:16 2018 +0800

    fixup! Fix: match subdomain

commit 99626e4a48
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 23:54:58 2018 +0800

    Fix: match subdomain

commit a57b3b2716
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 23:39:11 2018 +0800

    Fix: firefox

commit 5cfea3933f
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 22:46:34 2018 +0800

    Add some comment to db.js

commit 25fd3a1c2b
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 22:14:56 2018 +0800

    Fix: remove unused prop

commit bdae1c3697
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 20:00:25 2018 +0800

    Change: simpler styleCodeEmpty

commit bd4a453f45
Merge: c1bf9f5 9058c06
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 19:49:37 2018 +0800

    Merge branch 'dev-usercss-meta' into dev-exclusions

commit c1bf9f57e9
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 19:29:17 2018 +0800

    Fix: minor

commit fd5eeb4b81
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 19:00:05 2018 +0800

    Add: refresh on view

commit 3e38810a49
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 18:13:24 2018 +0800

    Fix: make sure icons are refreshed at startup

commit c657d7e55c
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 17:32:27 2018 +0800

    Add: implement bug 461

commit 7ed39ab6ef
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 15:42:44 2018 +0800

    fixup! Add: icon-util

commit 30e494eda9
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 15:42:23 2018 +0800

    Add: icon-util

commit 510a886e14
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 03:21:38 2018 +0800

    Fix: exposeIframes

commit c7f81662c4
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 02:19:14 2018 +0800

    Fix: autoCloseBrackets is true by default

commit f3a103645d
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 02:11:14 2018 +0800

    Fix: various

commit d4436cde20
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 01:39:10 2018 +0800

    Add: implement exposeIframe

commit 43db875fd8
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 01:26:24 2018 +0800

    Kill more globals

commit dc491e9be3
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 01:22:13 2018 +0800

    Kill old storage, storage-dummy

commit ba64b95575
Author: eight <eight04@gmail.com>
Date:   Thu Oct 11 00:54:38 2018 +0800

    WIP: kill cachedStyles

commit 7eba890a21
Merge: d2b36a1 81e4823
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 23:15:14 2018 +0800

    Merge branch 'dev-private-prefs' into dev-exclusions

commit d2b36a168e
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 23:05:20 2018 +0800

    Kill hidden globals

commit 22d4767511
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 19:23:34 2018 +0800

    Fix: margin for deleted sections

commit 00687983f0
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 18:21:07 2018 +0800

    Fix: default value

commit ff6fd8cad3
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 18:02:51 2018 +0800

    Fix: default options

commit c23f315c52
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 17:40:07 2018 +0800

    Refactor: use CodeMirror.defineOption

commit 4419c5dc1e
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 16:32:39 2018 +0800

    Change: kill editors, styleId

commit 6494985b50
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 16:14:51 2018 +0800

    Fix: various

commit 37e1f43f75
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 15:04:03 2018 +0800

    Fix: minor

commit d26ce3238e
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 14:49:37 2018 +0800

    Add: codemirror-factory

commit 15a1f552f6
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 12:08:35 2018 +0800

    WIP: kill getSection

commit ba6159e067
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 02:43:09 2018 +0800

    WIP: edit page

commit fd9ab5d6e5
Author: eight <eight04@gmail.com>
Date:   Wed Oct 10 00:41:07 2018 +0800

    Fix: switch to editor

commit 06e22d0d18
Author: eight <eight04@gmail.com>
Date:   Tue Oct 9 23:38:29 2018 +0800

    Change: add sections-editor

commit 30e8662946
Author: eight <eight04@gmail.com>
Date:   Mon Oct 8 20:12:39 2018 +0800

    Add: preview error

commit 47b2b4fc49
Author: eight <eight04@gmail.com>
Date:   Mon Oct 8 18:38:01 2018 +0800

    Add: livePreview.show

commit 7b5e7c96d5
Author: eight <eight04@gmail.com>
Date:   Mon Oct 8 18:16:45 2018 +0800

    Hook up live preview

commit 15efafff3c
Author: eight <eight04@gmail.com>
Date:   Mon Oct 8 17:49:57 2018 +0800

    Add: live preview

commit a38558ef78
Author: eight <eight04@gmail.com>
Date:   Mon Oct 8 15:30:39 2018 +0800

    WIP: make notifyAllTabs a noop

commit 582e9078af
Author: eight <eight04@gmail.com>
Date:   Mon Oct 8 14:39:08 2018 +0800

    Fix: inject all scripts

commit f4651da8d8
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 23:41:46 2018 +0800

    Drop deleteStyle

commit 0489fb3b2f
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 23:33:51 2018 +0800

    Drop saveStyle

commit 02f471f077
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 23:28:41 2018 +0800

    Fix: usercss API

commit 057111b171
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 22:59:31 2018 +0800

    Update usercss API

commit 69cae02381
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 21:40:29 2018 +0800

    Drop getStyles

commit c5d41529d9
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 21:28:51 2018 +0800

    Minor fixes

commit 5b3b4e680f
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 21:20:39 2018 +0800

    Add: navigator-util

commit b5107b78a5
Author: eight <eight04@gmail.com>
Date:   Sun Oct 7 01:42:43 2018 +0800

    Add: broadcast messages with reasons

commit e7ef4948cd
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 18:10:47 2018 +0800

    Fix: observer is unavailable?

commit 1c635b5bc1
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 17:47:43 2018 +0800

    Drop requestStyles

commit 75f2561154
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 16:38:04 2018 +0800

    Fix: don't recreate element when style update in popup

commit 583ca31d97
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 15:40:07 2018 +0800

    fixup! Add: isCodeEmpty

commit 1cf6008514
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 15:33:18 2018 +0800

    Add: isCodeEmpty

commit 450cd60aeb
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 15:22:04 2018 +0800

    Fix: ignore comment block

commit 196b6aac63
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 15:16:00 2018 +0800

    Fix: the return value of getSectionsByUrl is changed

commit 3122d28c1a
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 15:14:05 2018 +0800

    Fix: always use promise in API call

commit e594b8ccb1
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 15:11:01 2018 +0800

    Cache enabled state

commit 1f18b13a92
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 13:48:46 2018 +0800

    Add: match global sections

commit fedf844ddd
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 13:45:37 2018 +0800

    Add: getStylesInfoByUrl

commit 095998f07c
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 13:27:58 2018 +0800

    Change: switch to msg.js

commit fa3127d988
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 13:02:45 2018 +0800

    Change: switch to msg.js

commit 05d582c726
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 11:43:42 2018 +0800

    Add: msg.sendBg

commit 171339f710
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 04:39:48 2018 +0800

    WIP: drop api.js

commit 3a618aca2a
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 03:19:51 2018 +0800

    WIP: use deepCopy

commit bb1cb58024
Author: eight <eight04@gmail.com>
Date:   Sat Oct 6 03:10:04 2018 +0800

    WIP: msg.js

commit 2472e91f57
Author: eight <eight04@gmail.com>
Date:   Fri Oct 5 21:28:19 2018 +0800

    WIP: emitChangesToTabs

commit 34497ebe16
Author: eight <eight04@gmail.com>
Date:   Fri Oct 5 18:47:52 2018 +0800

    WIP: switch to API

commit f1639cc33e
Author: eight <eight04@gmail.com>
Date:   Fri Oct 5 01:03:40 2018 +0800

    WIP: broadcastMessage

commit 81e4823f46
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 19:39:59 2018 +0800

    Debounce updateAllTabsIcon

commit dc5f3e209f
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 19:34:36 2018 +0800

    Fix: settings could be empty on the first install

commit 2328cf623a
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 19:34:22 2018 +0800

    Change: start-firefox -> start

commit 7be6a1cba9
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 19:24:35 2018 +0800

    Add: applications

commit 630725196f
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 19:22:44 2018 +0800

    fixup! Fix: update all icons when some prefs changed

commit 0d0e1b4dc0
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 19:20:36 2018 +0800

    Fix: update all icons when some prefs changed

commit 5c0288e9ba
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 19:20:11 2018 +0800

    fixup! Remove unused FIREFOX_NO_DOM_STORAGE

commit 56b737b65a
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 18:14:57 2018 +0800

    Remove unused FIREFOX_NO_DOM_STORAGE

commit 829a134ed1
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 18:10:53 2018 +0800

    Fix: this -> prefs

commit d35f92250e
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 18:08:19 2018 +0800

    Fixme: styleViaAPI

commit 8a6e8ac03a
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 18:05:41 2018 +0800

    Change: drop prefChanged, use prefs service

commit 10f9449144
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 17:46:45 2018 +0800

    Change: move setupLivePrefs to dom.js. Remove prefs.js dependencies

commit dd2b8ed091
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 17:18:38 2018 +0800

    Fix: type error

commit 3af310c341
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 17:09:26 2018 +0800

    Fix: open-manager has no default value

commit 874a2da33e
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 17:04:23 2018 +0800

    Enhance: make prefs use storage.sync

commit c01f93f62c
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 15:57:02 2018 +0800

    WIP

commit 6d32ffb76b
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 12:46:19 2018 +0800

    WIP

commit 0f148eac32
Author: eight <eight04@gmail.com>
Date:   Thu Oct 4 03:35:07 2018 +0800

    WIP

commit 282bdf7706
Author: eight <eight04@gmail.com>
Date:   Wed Oct 3 20:24:06 2018 +0800

    Fix: numbers are not compared correctly

commit 24b1eea8a4
Merge: 8a6011d 5cbe8a8
Author: eight <eight04@gmail.com>
Date:   Wed Oct 3 15:00:07 2018 +0800

    Merge branch 'master' of https://github.com/openstyles/stylus into dev-exclusions

commit 5cbe8a8d78
Author: eight <eight04@gmail.com>
Date:   Tue Oct 2 20:22:18 2018 +0800

    Add: fetch style object from DB directly in the editor (#507)

commit 9058c06c54
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 23:24:29 2018 +0800

    Fix: bad API

commit 1f2d116aae
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 23:14:56 2018 +0800

    Fix: use meta parser

commit 918e47b1ed
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 23:01:21 2018 +0800

    Fix: emit update event if no fatal errors

commit 81a7bb9ac9
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 22:56:25 2018 +0800

    Add: editorWorker.metalint

commit f47d57aea8
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 22:49:16 2018 +0800

    Change: use editorWorker.metalint

commit 5778d5c858
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 22:39:01 2018 +0800

    Change: editor-worker-body -> editor-worker

commit 268e1716b4
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 22:38:06 2018 +0800

    Change: switch to worker-util

commit cc2980b647
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 22:30:16 2018 +0800

    Drop: parserlib-loader

commit 08adcb60f2
Merge: 6909c73 2fd531e
Author: eight <eight04@gmail.com>
Date:   Mon Oct 1 22:29:39 2018 +0800

    Merge branch 'master' into dev-usercss-meta

commit e4135ce35d
Author: eight <eight04@gmail.com>
Date:   Fri Sep 28 11:57:34 2018 +0800

    Fix: remove unused function

commit 39a6d1909f
Author: eight <eight04@gmail.com>
Date:   Fri Sep 28 00:26:29 2018 +0800

    Fix: prefs doesn't work in FF's private windows. Add web-ext. Drop prefs.readOnlyValues

commit 6909c73c69
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 12:16:33 2018 +0800

    Fix: minor

commit 79833d8bba
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 11:40:04 2018 +0800

    Fix: a better way to draw list?

commit a849fd6dda
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 11:39:53 2018 +0800

    Fix: missing placeholders

commit d5ee31a080
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 11:37:50 2018 +0800

    Fix: a better way to draw character list?

commit 7b959af3e3
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 11:30:10 2018 +0800

    Update usercss-meta

commit fefa987c4d
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 10:37:28 2018 +0800

    Change: sections-equal -> sections-util

commit 2abbf670d8
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 10:37:14 2018 +0800

    Fix: check err.code

commit 1fe0586b29
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 10:33:02 2018 +0800

    Add: i18n error message

commit ab0ef239cf
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 09:34:57 2018 +0800

    Change: move styleCodeEmpty to sections-util, load colorConverter in background worker

commit d5ade807f0
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 09:27:30 2018 +0800

    Fix: display error message

commit 4f5337e51d
Author: eight <eight04@gmail.com>
Date:   Wed Sep 26 09:26:55 2018 +0800

    Fix: remove unused colorconverter

commit 29b8f51292
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 23:21:44 2018 +0800

    Fix: vars could be undefined

commit a7cfeb22e4
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 22:54:40 2018 +0800

    Fix: window is undefined

commit 9713c6a3be
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:56:38 2018 +0800

    Fix: throw an error for unparsable color

commit 3c30bc3eb0
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:55:55 2018 +0800

    Fix: try to get error message

commit 3d32b0428b
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:38:40 2018 +0800

    Fix: vars might be empty

commit 7d75dd8754
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:18:39 2018 +0800

    Add: meta-parser

commit a4df641b96
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:18:18 2018 +0800

    Enhance: set flag in parserlib so we don't need another loader

commit 8028a3529f
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:17:40 2018 +0800

    Include util, worker-util in background

commit ba5d6cc31a
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:16:59 2018 +0800

    Fix: use spread syntax in loadScript

commit b853be13f8
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:14:46 2018 +0800

    Enhance: swith to usercss-meta (in worker)

commit a3e7915199
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:11:54 2018 +0800

    Fix: use promise API

commit 5d07a8cd4e
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:11:09 2018 +0800

    Fix: buildMeta now returns a promise

commit a004bc3c7d
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:10:35 2018 +0800

    Move styleCodeEmpty to util

commit 41ac66a137
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:09:40 2018 +0800

    Add: background worker

commit ffb13bf1db
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 21:09:04 2018 +0800

    Enhance: move moz-parser/meta-parser/usercss compiler to worker

commit 42e97ef153
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 20:45:07 2018 +0800

    Fix: display error on install page

commit 64aa9fcf53
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 17:34:54 2018 +0800

    Add: background worker

commit b0e407e98f
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 14:52:35 2018 +0800

    Add: worker util

commit 7a24547e09
Author: eight <eight04@gmail.com>
Date:   Tue Sep 25 00:01:18 2018 +0800

    Add: usercss-meta

commit 8a6011de8c
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Sun Jul 22 09:15:09 2018 -0500

    Attempt to update icon count

commit 4fcb1a88d7
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Sun Jul 15 13:44:29 2018 -0500

    Fix empty exclusion storage error

commit bfe54ab4c4
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Sun Jul 15 12:59:51 2018 -0500

    Add tab communication

commit 983a7bc219
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Sun Jul 15 10:51:11 2018 -0500

    Fix escaped regex example

commit 3950482f34
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Wed Apr 25 18:11:37 2018 -0500

    Fix undefined error

commit e94c7edb38
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Wed Apr 25 17:09:45 2018 -0500

    Attempt to fix popup exclusion issues

commit 2b4a1a5635
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Thu Apr 19 13:00:27 2018 -0500

    Modify input method

commit 9f75b69cd8
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Wed Mar 7 11:54:05 2018 -0600

    Include iframe urls in exclusion popup

commit 68dfa0153c
Author: Rob Garrison <wowmotty@gmail.com>
Date:   Wed Jan 24 19:42:02 2018 -0600

    Add style exclusions. Closes #113

* Revert: exclusions

* Fix: pass eslint

* Fix: the style is injected twice

* Fix: don't load script async

* Fix: styleCodeEmpty returns true for empty string

* Fix: drop array selection

* Fix: the config dialog is broken

* Fix: popup doesn't use getStyle/getStylesByUrl correctly

* Fix: keep disabled state in setStyleContent

* Fix: allow live-preview to assign newest vars

* Fix: transition fix is broken because setStyleContent becomes async

* Fix: typo, TypeError in styleExists

* Fix: use new API

* Fix: pass linter

* Fix: LICENCE -> LICENSE

* Fix: remove unused distroy function
This commit is contained in:
eight 2018-11-07 14:09:29 +08:00 committed by GitHub
parent 79c6506c5c
commit e3d3604afc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 5197 additions and 5595 deletions

View File

@ -8,64 +8,6 @@ env:
es6: true
webextensions: true
globals:
# messaging.js
KEEP_CHANNEL_OPEN: false
CHROME: false
FIREFOX: false
VIVALDI: false
OPERA: false
URLS: false
BG: false
API: false
notifyAllTabs: false
sendMessage: false
queryTabs: false
getTab: false
getOwnTab: false
getActiveTab: false
getActiveTabRealURL: false
getTabRealURL: false
openURL: false
activateTab: false
stringAsRegExp: false
ignoreChromeError: false
tryCatch: false
tryRegExp: false
tryJSONparse: false
debounce: false
deepCopy: false
sessionStorageHash: false
download: false
invokeOrPostpone: false
# localization.js
template: false
t: false
o: false
tE: false
tHTML: false
tNodeList: false
tDocLoader: false
tWordBreak: false
formatDate: false
# dom.js
onDOMready: false
onDOMscriptReady: false
scrollElementIntoView: false
enforceInputRange: false
animateElement: false
$: false
$$: false
$create: false
$createLink: false
# prefs.js
prefs: false
setupLivePrefs: false
# storage-util.js
chromeLocal: false
chromeSync: false
LZString: false
rules:
accessor-pairs: [2]
array-bracket-spacing: [2, never]
@ -214,7 +156,6 @@ rules:
no-trailing-spaces: [2]
no-undef-init: [2]
no-undef: [2]
no-undefined: [0]
no-underscore-dangle: [0]
no-unexpected-multiline: [2]
no-unmodified-loop-condition: [0]
@ -224,7 +165,7 @@ rules:
no-unsafe-negation: [2]
no-unused-expressions: [1]
no-unused-labels: [0]
no-unused-vars: [1, {args: after-used, vars: local, argsIgnorePattern: ^_}]
no-unused-vars: [2, {args: after-used}]
no-use-before-define: [2, nofunc]
no-useless-call: [2]
no-useless-computed-key: [2]

View File

@ -689,6 +689,194 @@
"message": "Show active style count",
"description": "Label (must be very short) for the checkbox in the toolbar button context menu controlling toolbar badge text."
},
"meta_invalidCheckboxDefault": {
"message": "Invalid @var checkbox: value must be 0 or 1",
"description": "Error displayed when the value of @var checkbox is invalid"
},
"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"
}
}
},
"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"
}
}
},
"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"
}
}
},
"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"
}
}
},
"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"
}
}
},
"meta_invalidSelectEmptyOptions": {
"message": "Invalid @var select: options list is empty",
"description": "Error displayed when the value of @var select is invalid"
},
"meta_invalidSelectMultipleDefaults": {
"message": "Invalid @var select: multiple default options are defined",
"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_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"
}
}
},
"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"
},
"meta_invalidWord": {
"message": "Expect a word",
"description": "Error displayed when the value is expected to be a word"
},
"meta_missingChar": {
"message": "Expect characters: $chars$",
"description": "Error displayed when the value is expected to be some characters",
"placeholders": {
"chars": {
"content": "$1"
}
}
},
"meta_missingEOT": {
"message": "Expect EOT data",
"description": "Error displayed when the value is expected to be an EOT list"
},
"meta_missingMandatory": {
"message": "Missing mandatory metadata: $keys$",
"description": "Error displayed when mandatory keys are missing",
"placeholders": {
"keys": {
"content": "$1"
}
}
},
"meta_unknownJSONLiteral": {
"message": "Invalid JSON: $literal$ is not a valid JSON literal",
"description": "Error displayed when JSON value is invalid",
"placeholders": {
"literal": {
"content": "$1"
}
}
},
"meta_unknownMeta": {
"message": "Unknown metadata: $key$",
"description": "Error displayed when unknown metadata is parsed",
"placeholders": {
"key": {
"content": "$1"
}
}
},
"meta_unknownVarType": {
"message": "Unknown @$varkey$ type: $vartype$",
"description": "Error displayed when unknown variable type is parsed",
"placeholders": {
"varkey": {
"content": "$1"
},
"vartype": {
"content": "$2"
}
}
},
"meta_unknownPreprocessor": {
"message": "Unknown @preprocessor: $preprocessor$",
"description": "Error displayed when unknown @preprocessor is parsed",
"placeholders": {
"preprocessor": {
"content": "$1"
}
}
},
"noStylesForSite": {
"message": "No styles installed for this site.",
"description": "Text displayed when no styles are installed for the current site"
@ -922,10 +1110,6 @@
"message": "Code",
"description": "Label for the code for a section"
},
"sectionHelp": {
"message": "Sections let you define different pieces of code to apply to different sets of URLs in the same style. For example, a single style could change the homepage of a site one way, while changing the rest of a site another way.",
"description": "Help text for sections"
},
"sectionRemove": {
"message": "Remove section",
"description": "Label for the button to remove a section"
@ -1038,50 +1222,6 @@
},
"description": "Confirmation when re-installing a style"
},
"styleMetaErrorCheckbox": {
"message": "Invalid @var checkbox: value must be 0 or 1",
"description": "Error displayed when the value of @var checkbox is invalid"
},
"styleMetaErrorColor": {
"message": "$color$ is not a valid color",
"placeholders": {
"color": {
"content": "$1"
}
},
"description": "Error displayed when the value of @var color is invalid"
},
"styleMetaErrorRangeOrNumber": {
"message": "Invalid @var $type$: value must be an array containing at least one number at index zero",
"description": "Error displayed when the value of @var number or @var range is invalid",
"placeholders": {
"type": {
"content": "$1"
}
}
},
"styleMetaErrorPreprocessor": {
"message": "Unsupported @preprocessor: $preprocessor$",
"placeholders": {
"preprocessor": {
"content": "$1"
}
},
"description": "Error displayed when the value of @preprocessor is not supported"
},
"styleMetaErrorSelectValueMismatch": {
"message": "Invalid @select: value doesn't exist in the list",
"description": "Error displayed when the value of @select is invalid"
},
"styleMissingMeta": {
"message": "Missing metadata @$key$",
"placeholders": {
"key": {
"content": "$1"
}
},
"description": "Error displayed when a mandatory metadata is missing"
},
"styleMissingName": {
"message": "Enter a name",
"description": "Error displayed when user saves without providing a name"
@ -1136,10 +1276,6 @@
"message": "Save",
"description": "Label for save button for style editing"
},
"styleSectionsTitle": {
"message": "Sections",
"description": "Title for the style sections section"
},
"styleToMozillaFormatHelp": {
"message": "The Mozilla format of the code can be submitted to userstyles.org and used with the classic Stylish for Firefox",
"description": "Help info for the Mozilla format header section that converts the code to/from Mozilla format"

View File

@ -0,0 +1,167 @@
/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */
'use strict';
importScripts('/js/worker-util.js');
const {loadScript, createAPI} = workerUtil;
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);
},
nullifyInvalidVars(vars) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.nullifyInvalidVars(vars);
}
});
function compileUsercss(preprocessor, code, vars) {
loadScript('/vendor-overwrites/csslint/parserlib.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}))
.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/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, rgb) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), true);
}
return null;
}
if (rgb) {
if (vars[name].type === 'color') {
const color = colorConverter.parse(vars[name].value);
if (!color) return null;
const {r, g, b} = color;
return `${r}, ${g}, ${b}`;
}
return null;
}
if (vars[name].type === 'dropdown' || vars[name].type === 'select') {
// prevent infinite recursion
pool.set(name, '');
return doReplace(vars[name].value);
}
return vars[name].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,54 +1,54 @@
/*
global dbExec getStyles saveStyle deleteStyle
global handleCssTransitionBug detectSloppyRegexps
global openEditor
global styleViaAPI
global loadScript
global usercss
*/
/* global download prefs openURL FIREFOX CHROME VIVALDI
openEditor debounce URLS ignoreChromeError queryTabs getTab
styleManager msg navigatorUtil iconUtil workerUtil */
'use strict';
// eslint-disable-next-line no-var
var backgroundWorker = workerUtil.createWorker({
url: '/background/background-worker.js'
});
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,
installStyle: styleManager.installStyle,
styleExists: styleManager.styleExists,
toggleStyle: styleManager.toggleStyle,
getStyles,
saveStyle,
deleteStyle,
getStyleFromDB: id =>
dbExec('get', id).then(event => event.target.result),
getTabUrlPrefix() {
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];
},
download(msg) {
delete msg.method;
return download(msg.url, msg);
},
parseCss({code}) {
return usercss.invokeWorker({action: 'parse', code});
return backgroundWorker.parseMozFormat({code});
},
getPrefs: prefs.getAll,
healthCheck: () => dbExec().then(() => true),
detectSloppyRegexps,
openEditor,
updateIcon,
updateIconBadge(count) {
return updateIconBadge(this.sender.tab.id, count);
},
// exposed for stuff that requires followup sendMessage() like popup::openSettings
// that would fail otherwise if another extension forced the tab to open
// in the foreground thus auto-closing the popup (in Chrome)
openURL,
closeTab: (msg, sender, respond) => {
chrome.tabs.remove(msg.tabId || sender.tab.id, () => {
if (chrome.runtime.lastError && msg.tabId !== sender.tab.id) {
respond(new Error(chrome.runtime.lastError.message));
}
});
return KEEP_CHANNEL_OPEN;
},
optionsCustomizeHotkeys() {
return browser.runtime.openOptionsPage()
.then(() => new Promise(resolve => setTimeout(resolve, 100)))
.then(() => sendMessage({method: 'optionsCustomizeHotkeys'}));
.then(() => msg.broadcastExtension({method: 'optionsCustomizeHotkeys'}));
},
});
@ -57,68 +57,32 @@ var browserCommands, contextMenus;
// *************************************************************************
// register all listeners
chrome.runtime.onMessage.addListener(onRuntimeMessage);
msg.on(onRuntimeMessage);
if (FIREFOX) {
// see notes in apply.js for getStylesFallback
const MSG_GET_STYLES = 'getStyles:';
const MSG_GET_STYLES_LEN = MSG_GET_STYLES.length;
chrome.runtime.onConnect.addListener(port => {
if (!port.name.startsWith(MSG_GET_STYLES)) return;
const tabId = port.sender.tab.id;
const frameId = port.sender.frameId;
const options = tryJSONparse(port.name.slice(MSG_GET_STYLES_LEN));
port.disconnect();
getStyles(options).then(styles => {
if (!styles.length) return;
chrome.tabs.executeScript(tabId, {
code: `
applyOnMessage({
method: 'styleApply',
styles: ${JSON.stringify(styles)},
})
`,
runAt: 'document_start',
frameId,
});
});
});
navigatorUtil.onUrlChange(({tabId, frameId}, type) => {
if (type === 'committed') {
// styles would be updated when content script is injected.
return;
}
{
const listener =
URLS.chromeProtectsNTP
? webNavigationListenerChrome
: webNavigationListener;
chrome.webNavigation.onBeforeNavigate.addListener(data =>
listener(null, data));
chrome.webNavigation.onCommitted.addListener(data =>
listener('styleApply', data));
chrome.webNavigation.onHistoryStateUpdated.addListener(data =>
listener('styleReplaceAll', data));
chrome.webNavigation.onReferenceFragmentUpdated.addListener(data =>
listener('styleReplaceAll', data));
msg.sendTab(tabId, {method: 'urlChanged'}, {frameId})
.catch(msg.ignoreError);
});
if (FIREFOX) {
// FF applies page CSP even to content scripts, https://bugzil.la/1267027
chrome.webNavigation.onCommitted.addListener(webNavUsercssInstallerFF, {
navigatorUtil.onCommitted(webNavUsercssInstallerFF, {
url: [
{hostSuffix: '.githubusercontent.com', urlSuffix: '.user.css'},
{hostSuffix: '.githubusercontent.com', urlSuffix: '.user.styl'},
]
});
// FF misses some about:blank iframes so we inject our content script explicitly
chrome.webNavigation.onDOMContentLoaded.addListener(webNavIframeHelperFF, {
navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, {
url: [
{urlEquals: 'about:blank'},
]
});
}
}
if (chrome.contextMenus) {
chrome.contextMenus.onClicked.addListener((info, tab) =>
@ -130,22 +94,45 @@ if (chrome.commands) {
chrome.commands.onCommand.addListener(command => browserCommands[command]());
}
if (!chrome.browserAction ||
!['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
window.updateIcon = () => {};
}
const tabIcons = new Map();
chrome.tabs.onRemoved.addListener(tabId => tabIcons.delete(tabId));
chrome.tabs.onReplaced.addListener((added, removed) => tabIcons.delete(removed));
// *************************************************************************
// set the default icon displayed after a tab is created until webNavigation kicks in
prefs.subscribe(['iconset'], () =>
updateIcon({
tab: {id: undefined},
styles: {},
}));
prefs.subscribe([
'disableAll',
'badgeDisabled',
'badgeNormal',
], () => debounce(refreshIconBadgeColor));
prefs.subscribe([
'show-badge'
], () => debounce(refreshIconBadgeText));
prefs.subscribe([
'disableAll',
'iconset',
], () => debounce(refreshAllIcons));
prefs.initializing.then(() => {
refreshIconBadgeColor();
refreshAllIconsBadgeText();
refreshAllIcons();
});
navigatorUtil.onUrlChange(({tabId, frameId, transitionQualifiers}, type) => {
if (type === 'committed' && !frameId) {
// it seems that the tab icon would be reset by navigation. We
// invalidate the cache here so it would be refreshed by `apply.js`.
tabIcons.delete(tabId);
// however, if the tab was swapped in by forward/backward buttons,
// `apply.js` doesn't notify the background to update the icon,
// so we have to refresh it manually.
if (transitionQualifiers.includes('forward_back')) {
msg.sendTab(tabId, {method: 'updateCount'}).catch(msg.ignoreError);
}
}
});
// *************************************************************************
chrome.runtime.onInstalled.addListener(({reason}) => {
@ -191,7 +178,7 @@ contextMenus = {
contexts: ['editable'],
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
click: (info, tab) => {
sendMessage({tabId: tab.id, method: 'editDeleteText'});
msg.sendTab(tab.id, {method: 'editDeleteText'});
},
}
};
@ -205,11 +192,10 @@ if (chrome.contextMenus) {
}
item = Object.assign({id}, item);
delete item.presentIf;
const prefValue = prefs.readOnlyValues[id];
item.title = chrome.i18n.getMessage(item.title);
if (!item.type && typeof prefValue === 'boolean') {
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
item.type = 'checkbox';
item.checked = prefValue;
item.checked = prefs.get(id);
}
if (!item.contexts) {
item.contexts = ['browser_action'];
@ -233,24 +219,35 @@ if (chrome.contextMenus) {
};
const keys = Object.keys(contextMenus);
prefs.subscribe(keys.filter(id => typeof prefs.readOnlyValues[id] === 'boolean'), toggleCheckmark);
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark);
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf), togglePresence);
createContextMenus(keys);
}
// *************************************************************************
// [re]inject content scripts
window.addEventListener('storageReady', function _() {
window.removeEventListener('storageReady', _);
// reinject content scripts when the extension is reloaded/updated. Firefox
// would handle this automatically.
if (!FIREFOX) {
reinjectContentScripts();
}
updateIcon({
tab: {id: undefined},
styles: {},
// 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) {}
});
}
// Firefox injects content script automatically
if (FIREFOX) return;
msg.broadcastTab({method: 'backgroundReady'});
function reinjectContentScripts() {
const NTP = 'chrome://newtab/';
const ALL_URLS = '<all_urls>';
const contentScripts = chrome.runtime.getManifest().content_scripts;
@ -266,20 +263,23 @@ window.addEventListener('storageReady', function _() {
const injectCS = (cs, tabId) => {
ignoreChromeError();
for (const file of cs.js) {
chrome.tabs.executeScript(tabId, {
file: cs.js[0],
file,
runAt: cs.run_at,
allFrames: cs.all_frames,
matchAboutBlank: cs.match_about_blank,
}, ignoreChromeError);
}
};
const pingCS = (cs, {id, url}) => {
const maybeInject = pong => !pong && injectCS(cs, id);
cs.matches.some(match => {
if ((match === ALL_URLS || url.match(match)) &&
(!url.startsWith('chrome') || url === NTP)) {
sendMessage({method: 'ping', tabId: id}, maybeInject);
msg.sendTab(id, {method: 'ping'})
.catch(() => false)
.then(pong => !pong && injectCS(cs, id));
return true;
}
});
@ -293,85 +293,19 @@ window.addEventListener('storageReady', function _() {
setTimeout(pingCS, 0, cs, tab));
}
}));
});
// *************************************************************************
{
const getStylesForFrame = (msg, sender) => {
const stylesTask = getStyles(msg);
if (!sender || !sender.frameId) return stylesTask;
return Promise.all([
stylesTask,
getTab(sender.tab.id),
]).then(([styles, tab]) => {
if (tab) styles.exposeIframes = tab.url.replace(/(\/\/[^/]*).*/, '$1');
return styles;
});
};
const updateAPI = (_, enabled) => {
window.API_METHODS.getStylesForFrame = enabled ? getStylesForFrame : getStyles;
};
prefs.subscribe(['exposeIframes'], updateAPI);
updateAPI(null, prefs.readOnlyValues.exposeIframes);
}
// *************************************************************************
function webNavigationListener(method, {url, tabId, frameId}) {
Promise.all([
getStyles({matchUrl: url, asHash: true}),
frameId && prefs.readOnlyValues.exposeIframes && getTab(tabId),
]).then(([styles, tab]) => {
if (method && URLS.supported(url) && tabId >= 0) {
if (method === 'styleApply') {
handleCssTransitionBug({tabId, frameId, url, styles});
}
if (tab) styles.exposeIframes = tab.url.replace(/(\/\/[^/]*).*/, '$1');
sendMessage({
tabId,
frameId,
method,
// ping own page so it retrieves the styles directly
styles: url.startsWith(URLS.ownOrigin) ? 'DIY' : styles,
});
}
// main page frame id is 0
if (frameId === 0) {
tabIcons.delete(tabId);
updateIcon({tab: {id: tabId, url}, styles});
}
});
}
function webNavigationListenerChrome(method, data) {
// Chrome 61.0.3161+ doesn't run content scripts on NTP
if (
!data.url.startsWith('https://www.google.') ||
!data.url.includes('/_/chrome/newtab?')
) {
webNavigationListener(method, data);
return;
}
getTab(data.tabId).then(tab => {
if (tab.url === 'chrome://newtab/') {
data.url = tab.url;
}
webNavigationListener(method, data);
});
}
function webNavUsercssInstallerFF(data) {
const {tabId} = data;
Promise.all([
sendMessage({tabId, method: 'ping'}),
msg.sendTab(tabId, {method: 'ping'})
.catch(() => false),
// we need tab index to open the installer next to the original one
// and also to skip the double-invocation in FF which assigns tab url later
getTab(tabId),
]).then(([pong, tab]) => {
if (pong !== true && tab.url !== 'about:blank') {
window.API_METHODS.installUsercss({direct: true}, {tab});
window.API_METHODS.openUsercssInstallPage({direct: true}, {tab});
}
});
}
@ -379,135 +313,107 @@ function webNavUsercssInstallerFF(data) {
function webNavIframeHelperFF({tabId, frameId}) {
if (!frameId) return;
sendMessage({method: 'ping', tabId, frameId}, pong => {
ignoreChromeError();
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: '/content/apply.js',
file,
matchAboutBlank: true,
}, ignoreChromeError);
}
});
}
function updateIcon({tab, styles}) {
if (tab.id < 0) {
function updateIconBadge(tabId, count) {
let tabIcon = tabIcons.get(tabId);
if (!tabIcon) tabIcons.set(tabId, (tabIcon = {}));
if (tabIcon.count === count) {
return;
}
if (URLS.chromeProtectsNTP && tab.url === 'chrome://newtab/') {
styles = {};
const oldCount = tabIcon.count;
tabIcon.count = count;
refreshIconBadgeText(tabId, tabIcon);
if (Boolean(oldCount) !== Boolean(count)) {
refreshIcon(tabId, tabIcon);
}
if (styles) {
stylesReceived(styles);
}
function refreshIconBadgeText(tabId, icon) {
iconUtil.setBadgeText({
text: prefs.get('show-badge') && icon.count ? String(icon.count) : '',
tabId
});
}
function refreshIcon(tabId, icon) {
const disableAll = prefs.get('disableAll');
const iconset = prefs.get('iconset') === 1 ? 'light/' : '';
const postfix = disableAll ? 'x' : !icon.count ? 'w' : '';
const iconType = iconset + postfix;
if (icon.iconType === iconType) {
return;
}
getTabRealURL(tab)
.then(url => getStyles({matchUrl: url, asHash: true}))
.then(stylesReceived);
function stylesReceived(styles) {
const disableAll = 'disableAll' in styles ? styles.disableAll : prefs.get('disableAll');
const postfix = disableAll ? 'x' : !styles.length ? 'w' : '';
const color = prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal');
const text = prefs.get('show-badge') && styles.length ? String(styles.length) : '';
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
let tabIcon = tabIcons.get(tab.id);
if (!tabIcon) tabIcons.set(tab.id, (tabIcon = {}));
if (tabIcon.iconType !== iconset + postfix) {
tabIcon.iconType = iconset + postfix;
icon.iconType = iconset + postfix;
const sizes = FIREFOX || CHROME >= 2883 && !VIVALDI ? [16, 32] : [19, 38];
const usePath = tabIcons.get('usePath');
Promise.all(sizes.map(size => {
const src = `/images/icon/${iconset}${size}${postfix}.png`;
return usePath ? src : tabIcons.get(src) || loadIcon(src);
})).then(data => {
const imageKey = typeof data[0] === 'string' ? 'path' : 'imageData';
const imageData = {};
sizes.forEach((size, i) => (imageData[size] = data[i]));
chrome.browserAction.setIcon({
tabId: tab.id,
[imageKey]: imageData,
}, ignoreChromeError);
iconUtil.setIcon({
path: sizes.reduce(
(obj, size) => {
obj[size] = `/images/icon/${iconset}${size}${postfix}.png`;
return obj;
},
{}
),
tabId
});
}
if (tab.id === undefined) return;
let defaultIcon = tabIcons.get(undefined);
if (!defaultIcon) tabIcons.set(undefined, (defaultIcon = {}));
if (defaultIcon.color !== color) {
defaultIcon.color = color;
chrome.browserAction.setBadgeBackgroundColor({color});
}
if (tabIcon.text === text) return;
tabIcon.text = text;
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
chrome.browserAction.setBadgeText({text, tabId: tab.id}, ignoreChromeError);
} catch (e) {
setTimeout(() => {
getTab(tab.id).then(realTab => {
// skip pre-rendered tabs
if (realTab.index >= 0) {
chrome.browserAction.setBadgeText({text, tabId: tab.id});
function refreshIconBadgeColor() {
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
iconUtil.setBadgeBackgroundColor({
color
});
}
});
});
function refreshAllIcons() {
for (const [tabId, icon] of tabIcons) {
refreshIcon(tabId, icon);
}
refreshIcon(null, {}); // default icon
}
function refreshAllIconsBadgeText() {
for (const [tabId, icon] of tabIcons) {
refreshIconBadgeText(tabId, icon);
}
}
function loadIcon(src, resolve) {
if (!resolve) return new Promise(resolve => loadIcon(src, resolve));
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = src;
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);
const data = ctx.getImageData(0, 0, w, h);
// Firefox breaks Canvas when privacy.resistFingerprinting=true, https://bugzil.la/1412961
let usePath = tabIcons.get('usePath');
if (usePath === undefined) {
usePath = data.data.every(b => b === 255);
tabIcons.set('usePath', usePath);
}
if (usePath) {
resolve(src);
function onRuntimeMessage(msg, sender) {
if (msg.method !== 'invokeAPI') {
return;
}
tabIcons.set(src, data);
resolve(data);
};
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 onRuntimeMessage(msg, sender, sendResponse) {
const fn = window.API_METHODS[msg.method];
if (!fn) return;
// wrap 'Error' object instance as {__ERROR__: message},
// which will be unwrapped by sendMessage,
// and prevent exceptions on sending to a closed tab
const respond = data =>
tryCatch(sendResponse,
data instanceof Error ? {__ERROR__: data.message} : data);
const result = fn(msg, sender, respond);
if (result instanceof Promise) {
result
.catch(e => ({__ERROR__: e instanceof Error ? e.message : e}))
.then(respond);
return KEEP_CHANNEL_OPEN;
} else if (result === KEEP_CHANNEL_OPEN) {
return KEEP_CHANNEL_OPEN;
} else if (result !== undefined) {
respond(result);
// FIXME: popup.js also open editor but it doesn't use this API.
function openEditor({id}) {
let url = '/edit.html';
if (id) {
url += `?id=${id}`;
}
if (chrome.windows && prefs.get('openEditInWindow')) {
chrome.windows.create(Object.assign({url}, prefs.get('windowPosition')));
} else {
openURL({url});
}
}

154
background/db.js Normal file
View File

@ -0,0 +1,154 @@
/* global tryCatch chromeLocal ignoreChromeError */
/* 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/
*/
'use strict';
const db = (() => {
let exec;
const preparing = prepare();
return {
exec: (...args) =>
preparing.then(() => exec(...args))
};
function prepare() {
// 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)
// test localStorage
const fallbackSet = localStorage.dbInChromeStorage;
if (fallbackSet === 'true' || !tryCatch(() => indexedDB)) {
useChromeStorage();
return Promise.resolve();
}
if (fallbackSet === 'false') {
useIndexedDB();
return Promise.resolve();
}
// test storage.local
return chromeLocal.get('dbInChromeStorage')
.then(data =>
data && data.dbInChromeStorage && Promise.reject())
.then(() =>
tryCatch(dbExecIndexedDB, 'getAllKeys', IDBKeyRange.lowerBound(1), 1) ||
Promise.reject())
.then(({target}) => (
(target.result || [])[0] ?
Promise.reject('ok') :
dbExecIndexedDB('put', {id: -1})))
.then(() =>
dbExecIndexedDB('get', -1))
.then(({target}) => (
(target.result || {}).id === -1 ?
dbExecIndexedDB('delete', -1) :
Promise.reject()))
.then(() =>
Promise.reject('ok'))
.catch(result => {
if (result === 'ok') {
useIndexedDB();
} else {
useChromeStorage();
}
});
}
function useChromeStorage() {
exec = dbExecChromeStorage;
chromeLocal.set({dbInChromeStorage: true}, ignoreChromeError);
localStorage.dbInChromeStorage = 'true';
}
function useIndexedDB() {
exec = dbExecIndexedDB;
chromeLocal.set({dbInChromeStorage: false}, ignoreChromeError);
localStorage.dbInChromeStorage = 'false';
}
function dbExecIndexedDB(method, ...args) {
return new Promise((resolve, reject) => {
Object.assign(indexedDB.open('stylish', 2), {
onsuccess(event) {
const database = event.target.result;
if (!method) {
resolve(database);
} else {
const transaction = database.transaction(['styles'], 'readwrite');
const store = transaction.objectStore('styles');
try {
Object.assign(store[method](...args), {
onsuccess: event => resolve(event, store, transaction, database),
onerror: reject,
});
} catch (err) {
reject(err);
}
}
},
onerror(event) {
console.warn(event.target.error || event.target.errorCode);
reject(event);
},
onupgradeneeded(event) {
if (event.oldVersion === 0) {
event.target.result.createObjectStore('styles', {
keyPath: 'id',
autoIncrement: true,
});
}
},
});
});
}
function dbExecChromeStorage(method, data) {
const STYLE_KEY_PREFIX = 'style-';
switch (method) {
case 'get':
return chromeLocal.getValue(STYLE_KEY_PREFIX + data)
.then(result => ({target: {result}}));
case 'put':
if (!data.id) {
return getAllStyles().then(styles => {
data.id = 1;
for (const style of styles) {
data.id = Math.max(data.id, style.id + 1);
}
return dbExecChromeStorage('put', data);
});
}
return chromeLocal.setValue(STYLE_KEY_PREFIX + data.id, data)
.then(() => (chrome.runtime.lastError ? Promise.reject() : data.id));
case 'delete':
return chromeLocal.remove(STYLE_KEY_PREFIX + data);
case 'getAll':
return getAllStyles()
.then(styles => ({target: {result: styles}}));
}
return Promise.reject();
function getAllStyles() {
return chromeLocal.get(null).then(storage => {
const styles = [];
for (const key in storage) {
if (key.startsWith(STYLE_KEY_PREFIX) &&
Number(key.substr(STYLE_KEY_PREFIX.length))) {
styles.push(storage[key]);
}
}
return styles;
});
}
}
})();

91
background/icon-util.js Normal file
View File

@ -0,0 +1,91 @@
/* 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,75 @@
/* global promisify CHROME URLS */
/* exported navigatorUtil */
'use strict';
const navigatorUtil = (() => {
const handler = {
urlChange: null
};
const tabGet = promisify(chrome.tabs.get.bind(chrome.tabs));
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 tabGet(data.tabId)
.then(tab => {
if (tab.url === 'chrome://newtab/') {
data.url = tab.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,9 +0,0 @@
/* global importScripts parserlib CSSLint parseMozFormat */
'use strict';
importScripts('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js');
parserlib.css.Tokens[parserlib.css.Tokens.COMMENT].hide = false;
self.onmessage = ({data}) => {
self.postMessage(parseMozFormat(data));
};

View File

@ -1,226 +0,0 @@
/*
global API_METHODS cachedStyles
global getStyles filterStyles invalidateCache normalizeStyleSections
global updateIcon
*/
'use strict';
(() => {
const previewFromTabs = new Map();
/**
* When style id and state is provided, only that style is propagated.
* Otherwise all styles are replaced and the toolbar icon is updated.
* @param {Object} [msg]
* @param {{id:Number, enabled?:Boolean, sections?: (Array|String)}} [msg.style] -
* style to propagate
* @param {Boolean} [msg.codeIsUpdated]
* @returns {Promise<void>}
*/
API_METHODS.refreshAllTabs = (msg = {}) =>
Promise.all([
queryTabs(),
maybeParseUsercss(msg),
getStyles(),
]).then(([tabs, style]) =>
new Promise(resolve => {
if (style) msg.style.sections = normalizeStyleSections(style);
run(tabs, msg, resolve);
}));
function run(tabs, msg, resolve) {
const {style, codeIsUpdated, refreshOwnTabs} = msg;
// the style was updated/saved so we need to remove the old copy of the original style
if (msg.method === 'styleUpdated' && msg.reason !== 'editPreview') {
for (const [tabId, original] of previewFromTabs.entries()) {
if (style.id === original.id) {
previewFromTabs.delete(tabId);
}
}
if (!previewFromTabs.size) {
unregisterTabListeners();
}
}
if (!style) {
msg = {method: 'styleReplaceAll'};
// live preview puts the code in cachedStyles, saves the original in previewFromTabs,
// and if preview is being disabled, but the style is already deleted, we bail out
} else if (msg.reason === 'editPreview' && !updateCache(msg)) {
return;
// simple style update:
// * if disabled, apply.js will remove the element
// * if toggled and code is unchanged, apply.js will toggle the element
} else if (!style.enabled || codeIsUpdated === false) {
msg = {
method: 'styleUpdated',
reason: msg.reason,
style: {
id: style.id,
enabled: style.enabled,
},
codeIsUpdated,
};
// live preview normal operation, the new code is already in cachedStyles
} else {
msg.method = 'styleApply';
msg.style = {id: msg.style.id};
}
if (!tabs || !tabs.length) {
resolve();
return;
}
const last = tabs[tabs.length - 1];
for (const tab of tabs) {
if (FIREFOX && !tab.width) continue;
if (refreshOwnTabs === false && tab.url.startsWith(URLS.ownOrigin)) continue;
chrome.webNavigation.getAllFrames({tabId: tab.id}, frames =>
refreshFrame(tab, frames, msg, tab === last && resolve));
}
}
function refreshFrame(tab, frames, msg, resolve) {
ignoreChromeError();
if (!frames || !frames.length) {
frames = [{
frameId: 0,
url: tab.url,
}];
}
msg.tabId = tab.id;
const styleId = msg.style && msg.style.id;
for (const frame of frames) {
const styles = filterStyles({
matchUrl: getFrameUrl(frame, frames),
asHash: true,
id: styleId,
});
msg = Object.assign({}, msg);
msg.frameId = frame.frameId;
if (msg.method !== 'styleUpdated') {
msg.styles = styles;
}
if (msg.method === 'styleApply' && !styles.length) {
// remove the style from a previously matching frame
invokeOrPostpone(tab.active, sendMessage, {
method: 'styleUpdated',
reason: 'editPreview',
style: {
id: styleId,
enabled: false,
},
tabId: tab.id,
frameId: frame.frameId,
}, ignoreChromeError);
} else {
invokeOrPostpone(tab.active, sendMessage, msg, ignoreChromeError);
}
if (!frame.frameId) {
setTimeout(updateIcon, 0, {
tab,
styles: msg.method === 'styleReplaceAll' ? styles : undefined,
});
}
}
if (resolve) resolve();
}
function getFrameUrl(frame, frames) {
while (frame.url === 'about:blank' && frame.frameId > 0) {
const parent = frames.find(f => f.frameId === frame.parentFrameId);
if (!parent) break;
frame.url = parent.url;
frame = parent;
}
return (frame || frames[0]).url;
}
function maybeParseUsercss({style}) {
if (style && typeof style.sections === 'string') {
return API_METHODS.parseUsercss({sourceCode: style.sections});
}
}
function updateCache(msg) {
const {style, tabId, restoring} = msg;
const spoofed = !restoring && previewFromTabs.get(tabId);
const original = cachedStyles.byId.get(style.id);
if (style.sections && !restoring) {
if (!previewFromTabs.size) {
registerTabListeners();
}
if (!spoofed) {
previewFromTabs.set(tabId, Object.assign({}, original));
}
} else {
previewFromTabs.delete(tabId);
if (!previewFromTabs.size) {
unregisterTabListeners();
}
if (!original) {
return;
}
if (!restoring) {
msg.style = spoofed || original;
}
}
invalidateCache({updated: msg.style});
return true;
}
function registerTabListeners() {
chrome.tabs.onRemoved.addListener(onTabRemoved);
chrome.tabs.onReplaced.addListener(onTabReplaced);
chrome.webNavigation.onCommitted.addListener(onTabNavigated);
}
function unregisterTabListeners() {
chrome.tabs.onRemoved.removeListener(onTabRemoved);
chrome.tabs.onReplaced.removeListener(onTabReplaced);
chrome.webNavigation.onCommitted.removeListener(onTabNavigated);
}
function onTabRemoved(tabId) {
const style = previewFromTabs.get(tabId);
if (style) {
API_METHODS.refreshAllTabs({
style,
tabId,
reason: 'editPreview',
restoring: true,
});
}
}
function onTabReplaced(addedTabId, removedTabId) {
onTabRemoved(removedTabId);
}
function onTabNavigated({tabId}) {
onTabRemoved(tabId);
}
})();

View File

@ -1,4 +1,4 @@
/* global API_METHODS filterStyles cachedStyles */
/* global API_METHODS styleManager tryRegExp debounce */
'use strict';
(() => {
@ -25,7 +25,8 @@
if (/^url:/i.test(query)) {
matchUrl = query.slice(query.indexOf(':') + 1).trim();
if (matchUrl) {
return filterStyles({matchUrl}).map(style => style.id);
return styleManager.getStylesByUrl(matchUrl)
.then(results => results.map(r => r.data.id));
}
}
if (query.startsWith('/') && /^\/(.+?)\/([gimsuy]*)$/.test(query)) {
@ -43,15 +44,18 @@
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 item of ids || cachedStyles.list) {
const id = isNaN(item) ? item.id : item;
for (const style of styles) {
const id = style.id;
if (!query || words && !words.length) {
results.push(id);
continue;
}
const style = isNaN(item) ? item : cachedStyles.byId.get(item);
if (!style) continue;
for (const part in PARTS) {
const text = style[part];
if (text && PARTS[part](text, rx, words, icase)) {
@ -60,9 +64,9 @@
}
}
}
if (cache.size) debounce(clearCache, 60e3);
return results;
});
};
function searchText(text, rx, words, icase) {

View File

@ -1,78 +0,0 @@
'use strict';
// eslint-disable-next-line no-unused-expressions
(chrome.runtime.id.includes('@temporary') || !('sync' in chrome.storage)) && (() => {
const listeners = new Set();
Object.assign(chrome.storage.onChanged, {
addListener: fn => listeners.add(fn),
hasListener: fn => listeners.has(fn),
removeListener: fn => listeners.delete(fn),
});
for (const name of ['local', 'sync']) {
const dummy = tryJSONparse(localStorage['dummyStorage.' + name]) || {};
chrome.storage[name] = {
get(data, cb) {
let result = {};
if (data === null) {
result = deepCopy(dummy);
} else if (Array.isArray(data)) {
for (const key of data) {
result[key] = dummy[key];
}
} else if (typeof data === 'object') {
const hasOwnProperty = Object.prototype.hasOwnProperty;
for (const key in data) {
if (hasOwnProperty.call(data, key)) {
const value = dummy[key];
result[key] = value === undefined ? data[key] : value;
}
}
} else {
result[data] = dummy[data];
}
if (typeof cb === 'function') cb(result);
},
set(data, cb) {
const hasOwnProperty = Object.prototype.hasOwnProperty;
const changes = {};
for (const key in data) {
if (!hasOwnProperty.call(data, key)) continue;
const newValue = data[key];
changes[key] = {newValue, oldValue: dummy[key]};
dummy[key] = newValue;
}
localStorage['dummyStorage.' + name] = JSON.stringify(dummy);
if (typeof cb === 'function') cb();
notify(changes);
},
remove(keyOrKeys, cb) {
const changes = {};
for (const key of Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]) {
changes[key] = {oldValue: dummy[key]};
delete dummy[key];
}
localStorage['dummyStorage.' + name] = JSON.stringify(dummy);
if (typeof cb === 'function') cb();
notify(changes);
},
};
}
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
dummyStorageGet: ({data, name}) => new Promise(resolve => chrome.storage[name].get(data, resolve)),
dummyStorageSet: ({data, name}) => new Promise(resolve => chrome.storage[name].set(data, resolve)),
dummyStorageRemove: ({data, name}) => new Promise(resolve => chrome.storage[name].remove(data, resolve)),
});
function notify(changes, name) {
for (const fn of listeners.values()) {
fn(changes, name);
}
sendMessage({
dummyStorageChanges: changes,
dummyStorageName: name,
}, ignoreChromeError);
}
})();

View File

@ -1,847 +0,0 @@
/* global getStyleWithNoCode styleSectionsEqual */
'use strict';
const RX_NAMESPACE = /\s*(@namespace\s+(?:\S+\s+)?url\(http:\/\/.*?\);)\s*/g;
const RX_CHARSET = /\s*@charset\s+(['"]).*?\1\s*;\s*/g;
const RX_CSS_COMMENTS = /\/\*[\s\S]*?(?:\*\/|$)/g;
// eslint-disable-next-line no-var
var SLOPPY_REGEXP_PREFIX = '\0';
// CSS transition bug workaround: since we insert styles asynchronously,
// the browsers, especially Firefox, may apply all transitions on page load
const CSS_TRANSITION_SUPPRESSOR = '* { transition: none !important; }';
const RX_CSS_TRANSITION_DETECTOR = /([\s\n;/{]|-webkit-|-moz-)transition[\s\n]*:[\s\n]*(?!none)/;
// Note, only 'var'-declared variables are visible from another extension page
// eslint-disable-next-line no-var
var cachedStyles = {
list: null, // array of all styles
byId: new Map(), // all styles indexed by id
filters: new Map(), // filterStyles() parameters mapped to the returned results, 10k max
regexps: new Map(), // compiled style regexps
urlDomains: new Map(), // getDomain() results for 100 last checked urls
needTransitionPatch: new Map(), // FF bug workaround
mutex: {
inProgress: true, // while getStyles() is reading IndexedDB all subsequent calls
// (initially 'true' to prevent rogue getStyles before dbExec.initialized)
onDone: [], // to getStyles() are queued and resolved when the first one finishes
},
};
// eslint-disable-next-line no-var
var dbExec = dbExecIndexedDB;
dbExec.initialized = false;
// 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)
do {
const done = () => {
cachedStyles.mutex.inProgress = false;
getStyles().then(() => {
dbExec.initialized = true;
window.dispatchEvent(new Event('storageReady'));
});
};
const fallback = () => {
dbExec = dbExecChromeStorage;
chromeLocal.set({dbInChromeStorage: true});
localStorage.dbInChromeStorage = 'true';
ignoreChromeError();
done();
};
const fallbackSet = localStorage.dbInChromeStorage;
if (fallbackSet === 'true' || !tryCatch(() => indexedDB)) {
fallback();
break;
} else if (fallbackSet === 'false') {
done();
break;
}
chromeLocal.get('dbInChromeStorage')
.then(data =>
data && data.dbInChromeStorage && Promise.reject())
.then(() =>
tryCatch(dbExecIndexedDB, 'getAllKeys', IDBKeyRange.lowerBound(1), 1) ||
Promise.reject())
.then(({target}) => (
(target.result || [])[0] ?
Promise.reject('ok') :
dbExecIndexedDB('put', {id: -1})))
.then(() =>
dbExecIndexedDB('get', -1))
.then(({target}) => (
(target.result || {}).id === -1 ?
dbExecIndexedDB('delete', -1) :
Promise.reject()))
.then(() =>
Promise.reject('ok'))
.catch(result => {
if (result === 'ok') {
chromeLocal.set({dbInChromeStorage: false});
localStorage.dbInChromeStorage = 'false';
done();
} else {
fallback();
}
});
} while (0);
function dbExecIndexedDB(method, ...args) {
return new Promise((resolve, reject) => {
Object.assign(indexedDB.open('stylish', 2), {
onsuccess(event) {
const database = event.target.result;
if (!method) {
resolve(database);
} else {
const transaction = database.transaction(['styles'], 'readwrite');
const store = transaction.objectStore('styles');
Object.assign(store[method](...args), {
onsuccess: event => resolve(event, store, transaction, database),
onerror: reject,
});
}
},
onerror(event) {
console.warn(event.target.error || event.target.errorCode);
reject(event);
},
onupgradeneeded(event) {
if (event.oldVersion === 0) {
event.target.result.createObjectStore('styles', {
keyPath: 'id',
autoIncrement: true,
});
}
},
});
});
}
function dbExecChromeStorage(method, data) {
const STYLE_KEY_PREFIX = 'style-';
switch (method) {
case 'get':
return chromeLocal.getValue(STYLE_KEY_PREFIX + data)
.then(result => ({target: {result}}));
case 'put':
if (!data.id) {
return getStyles().then(() => {
data.id = 1;
for (const style of cachedStyles.list) {
data.id = Math.max(data.id, style.id + 1);
}
return dbExecChromeStorage('put', data);
});
}
return chromeLocal.setValue(STYLE_KEY_PREFIX + data.id, data)
.then(() => (chrome.runtime.lastError ? Promise.reject() : data.id));
case 'delete':
return chromeLocal.remove(STYLE_KEY_PREFIX + data);
case 'getAll':
return chromeLocal.get(null).then(storage => {
const styles = [];
for (const key in storage) {
if (key.startsWith(STYLE_KEY_PREFIX) &&
Number(key.substr(STYLE_KEY_PREFIX.length))) {
styles.push(storage[key]);
}
}
return {target: {result: styles}};
});
}
return Promise.reject();
}
function getStyles(options) {
if (cachedStyles.list) {
return Promise.resolve(filterStyles(options));
}
if (cachedStyles.mutex.inProgress) {
return new Promise(resolve => {
cachedStyles.mutex.onDone.push({options, resolve});
});
}
cachedStyles.mutex.inProgress = true;
return dbExec('getAll').then(event => {
cachedStyles.list = event.target.result || [];
cachedStyles.list.forEach(fixUsoMd5Issue);
cachedStyles.byId.clear();
for (const style of cachedStyles.list) {
cachedStyles.byId.set(style.id, style);
if (!style.name) {
style.name = 'ID: ' + style.id;
}
}
cachedStyles.mutex.inProgress = false;
for (const {options, resolve} of cachedStyles.mutex.onDone) {
resolve(filterStyles(options));
}
cachedStyles.mutex.onDone = [];
return filterStyles(options);
});
}
function filterStyles({
enabled = null,
id = null,
matchUrl = null,
md5Url = null,
asHash = null,
omitCode,
strictRegexp = true, // used by the popup to detect bad regexps
} = {}) {
if (id) id = Number(id);
if (asHash) enabled = true;
if (
enabled === null &&
id === null &&
matchUrl === null &&
md5Url === null &&
asHash !== true
) {
return cachedStyles.list;
}
if (matchUrl && !URLS.supported(matchUrl)) {
return asHash ? {length: 0} : [];
}
const blankHash = asHash && {
length: 0,
disableAll: prefs.get('disableAll'),
exposeIframes: prefs.get('exposeIframes'),
};
// make sure to use the same order in updateFiltersCache()
const cacheKey =
enabled + '\t' +
id + '\t' +
matchUrl + '\t' +
md5Url + '\t' +
asHash + '\t' +
strictRegexp;
const cached = cachedStyles.filters.get(cacheKey);
let styles;
if (cached) {
cached.hits++;
cached.lastHit = Date.now();
styles = asHash
? Object.assign(blankHash, cached.styles)
: cached.styles.slice();
} else {
styles = filterStylesInternal({
enabled,
id,
matchUrl,
md5Url,
asHash,
strictRegexp,
blankHash,
cacheKey,
});
}
if (!omitCode) return styles;
if (!asHash) return styles.map(getStyleWithNoCode);
for (const id in styles) {
const sections = styles[id];
if (Array.isArray(sections)) {
styles[id] = getStyleWithNoCode({sections}).sections;
}
}
return styles;
}
// The md5Url provided by USO includes a duplicate "update" subdomain (see #523),
// This fixes any already installed styles containing this error
function fixUsoMd5Issue(style) {
if (style && style.md5Url && style.md5Url.includes('update.update.userstyles')) {
style.md5Url = style.md5Url.replace('update.update.userstyles', 'update.userstyles');
}
}
function filterStylesInternal({
// js engines don't like big functions (V8 often deoptimized the original filterStyles)
// it also makes sense to extract the less frequently executed code
enabled,
id,
matchUrl,
md5Url,
asHash,
strictRegexp,
blankHash,
cacheKey,
}) {
if (matchUrl && !cachedStyles.urlDomains.has(matchUrl)) {
cachedStyles.urlDomains.set(matchUrl, getDomains(matchUrl));
for (let i = cachedStyles.urlDomains.size - 100; i > 0; i--) {
const firstKey = cachedStyles.urlDomains.keys().next().value;
cachedStyles.urlDomains.delete(firstKey);
}
}
const styles = id === null
? cachedStyles.list
: [cachedStyles.byId.get(id)];
if (!styles[0]) {
// may happen when users [accidentally] reopen an old URL
// of edit.html with a non-existent style id parameter
return asHash ? blankHash : [];
}
const filtered = asHash ? {length: 0} : [];
const needSections = asHash || matchUrl !== null;
const matchUrlBase = matchUrl && matchUrl.includes('#') && matchUrl.split('#', 1)[0];
let style;
for (let i = 0; (style = styles[i]); i++) {
if ((enabled === null || style.enabled === enabled)
&& (md5Url === null || style.md5Url === md5Url)
&& (id === null || style.id === id)) {
const sections = needSections &&
getApplicableSections({
style,
matchUrl,
strictRegexp,
stopOnFirst: !asHash,
skipUrlCheck: true,
matchUrlBase,
});
if (asHash) {
if (sections.length) {
filtered[style.id] = sections;
filtered.length++;
}
} else if (matchUrl === null || sections.length) {
filtered.push(style);
}
}
}
cachedStyles.filters.set(cacheKey, {
styles: filtered,
lastHit: Date.now(),
hits: 1,
});
if (cachedStyles.filters.size > 10000) {
cleanupCachedFilters();
}
// a shallow copy is needed because the cache doesn't store options like disableAll
return asHash
? Object.assign(blankHash, filtered)
: filtered;
}
function saveStyle(style) {
const id = Number(style.id) || null;
const reason = style.reason;
const notify = style.notify !== false;
delete style.method;
delete style.reason;
delete style.notify;
if (!style.name) {
delete style.name;
}
let existed;
let codeIsUpdated;
fixUsoMd5Issue(style);
return maybeCalcDigest()
.then(maybeImportFix)
.then(decide);
function maybeCalcDigest() {
if (['install', 'update', 'update-digest'].includes(reason)) {
return calcStyleDigest(style).then(digest => {
style.originalDigest = digest;
});
}
return Promise.resolve();
}
function maybeImportFix() {
if (reason === 'import') {
style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future
delete style.styleDigest; // TODO: remove in the future
if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) {
delete style.originalDigest;
}
}
}
function decide() {
if (id !== null) {
// Update or create
style.id = id;
return dbExec('get', id).then((event, store) => {
const oldStyle = event.target.result;
existed = Boolean(oldStyle);
if (reason === 'update-digest' && oldStyle.originalDigest === style.originalDigest) {
return style;
}
codeIsUpdated = !existed || 'sections' in style && !styleSectionsEqual(style, oldStyle);
style = Object.assign({installDate: Date.now()}, oldStyle, style);
return write(style, store);
});
} else {
// Create
delete style.id;
style = Object.assign({
// Set optional things if they're undefined
enabled: true,
updateUrl: null,
md5Url: null,
url: null,
originalMd5: null,
installDate: Date.now(),
}, style);
return write(style);
}
}
function write(style, store) {
style.sections = normalizeStyleSections(style);
if (store) {
return new Promise(resolve => {
store.put(style).onsuccess = event => resolve(done(event));
});
} else {
return dbExec('put', style).then(done);
}
}
function done(event) {
if (reason === 'update-digest') {
return style;
}
style.id = style.id || event.target.result;
invalidateCache(existed ? {updated: style} : {added: style});
if (notify) {
notifyAllTabs({
method: existed ? 'styleUpdated' : 'styleAdded',
style, codeIsUpdated, reason,
});
}
return style;
}
}
function deleteStyle({id, notify = true}) {
id = Number(id);
return dbExec('delete', id).then(() => {
invalidateCache({deletedId: id});
if (notify) {
notifyAllTabs({method: 'styleDeleted', id});
}
return id;
});
}
function getApplicableSections({
style,
matchUrl,
strictRegexp = true,
// filterStylesInternal() sets the following to avoid recalc on each style:
stopOnFirst,
skipUrlCheck,
matchUrlBase = matchUrl.includes('#') && matchUrl.split('#', 1)[0],
// as per spec the fragment portion is ignored in @-moz-document:
// https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc
// but the spec is outdated and doesn't account for SPA sites
// so we only respect it in case of url("http://exact.url/without/hash")
}) {
if (!skipUrlCheck && !URLS.supported(matchUrl)) {
return [];
}
const sections = [];
for (const section of style.sections) {
const {urls, domains, urlPrefixes, regexps, code} = section;
const isGlobal = !urls.length && !urlPrefixes.length && !domains.length && !regexps.length;
const isMatching = !isGlobal && (
urls.length
&& (urls.includes(matchUrl) || matchUrlBase && urls.includes(matchUrlBase))
|| urlPrefixes.length
&& arraySomeIsPrefix(urlPrefixes, matchUrl)
|| domains.length
&& arraySomeIn(cachedStyles.urlDomains.get(matchUrl) || getDomains(matchUrl), domains)
|| regexps.length
&& arraySomeMatches(regexps, matchUrl, strictRegexp));
if (isGlobal && !styleCodeEmpty(code) || isMatching) {
sections.push(section);
if (stopOnFirst) {
break;
}
}
}
return sections;
function arraySomeIsPrefix(array, string) {
for (const prefix of array) {
if (string.startsWith(prefix)) {
return true;
}
}
return false;
}
function arraySomeIn(array, haystack) {
for (const el of array) {
if (haystack.indexOf(el) >= 0) {
return true;
}
}
return false;
}
function arraySomeMatches(array, matchUrl, strictRegexp) {
for (const regexp of array) {
for (let pass = 1; pass <= (strictRegexp ? 1 : 2); pass++) {
const cacheKey = pass === 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp;
let rx = cachedStyles.regexps.get(cacheKey);
if (rx === false) {
// invalid regexp
break;
}
if (!rx) {
const anchored = pass === 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$';
rx = tryRegExp(anchored);
cachedStyles.regexps.set(cacheKey, rx || false);
if (!rx) {
// invalid regexp
break;
}
}
if (rx.test(matchUrl)) {
return true;
}
}
}
return false;
}
}
function styleCodeEmpty(code) {
// Collect the global section if it's not empty, not comment-only, not namespace-only.
const cmtOpen = code && code.indexOf('/*');
if (cmtOpen >= 0) {
const cmtCloseLast = code.lastIndexOf('*/');
if (cmtCloseLast < 0) {
code = code.substr(0, cmtOpen);
} else {
code = code.substr(0, cmtOpen) +
code.substring(cmtOpen, cmtCloseLast + 2).replace(RX_CSS_COMMENTS, '') +
code.substr(cmtCloseLast + 2);
}
}
if (!code || !code.trim()) return true;
if (code.includes('@namespace')) code = code.replace(RX_NAMESPACE, '').trim();
if (code.includes('@charset')) code = code.replace(RX_CHARSET, '').trim();
return !code;
}
function invalidateCache({added, updated, deletedId} = {}) {
if (!cachedStyles.list) return;
const id = added ? added.id : updated ? updated.id : deletedId;
const cached = cachedStyles.byId.get(id);
if (updated) {
if (cached) {
const isSectionGlobal = section =>
!section.urls.length &&
!section.urlPrefixes.length &&
!section.domains.length &&
!section.regexps.length;
const hadOrHasGlobals = cached.sections.some(isSectionGlobal) ||
updated.sections.some(isSectionGlobal);
const reenabled = !cached.enabled && updated.enabled;
const equal = !hadOrHasGlobals &&
!reenabled &&
styleSectionsEqual(updated, cached, {ignoreCode: true});
Object.assign(cached, updated);
if (equal) {
updateFiltersCache(cached);
} else {
cachedStyles.filters.clear();
}
cachedStyles.needTransitionPatch.delete(id);
return;
} else {
added = updated;
}
}
if (added) {
if (!cached) {
cachedStyles.list.push(added);
cachedStyles.byId.set(added.id, added);
cachedStyles.filters.clear();
cachedStyles.needTransitionPatch.delete(id);
}
return;
}
if (deletedId !== undefined) {
if (cached) {
const cachedIndex = cachedStyles.list.indexOf(cached);
cachedStyles.list.splice(cachedIndex, 1);
cachedStyles.byId.delete(deletedId);
for (const {styles} of cachedStyles.filters.values()) {
if (Array.isArray(styles)) {
const index = styles.findIndex(({id}) => id === deletedId);
if (index >= 0) styles.splice(index, 1);
} else if (deletedId in styles) {
delete styles[deletedId];
styles.length--;
}
}
cachedStyles.needTransitionPatch.delete(id);
return;
}
}
cachedStyles.list = null;
cachedStyles.filters.clear();
cachedStyles.needTransitionPatch.clear(id);
}
function updateFiltersCache(style) {
const {id} = style;
for (const [key, {styles}] of cachedStyles.filters.entries()) {
if (Array.isArray(styles)) {
const index = styles.findIndex(style => style.id === id);
if (index >= 0) styles[index] = Object.assign({}, style);
continue;
}
if (id in styles) {
const [, , matchUrl, , , strictRegexp] = key.split('\t');
if (!style.enabled) {
delete styles[id];
styles.length--;
continue;
}
const matchUrlBase = matchUrl && matchUrl.includes('#') && matchUrl.split('#', 1)[0];
const sections = getApplicableSections({
style,
matchUrl,
matchUrlBase,
strictRegexp,
skipUrlCheck: true,
});
if (sections.length) {
styles[id] = sections;
} else {
delete styles[id];
styles.length--;
}
}
}
}
function cleanupCachedFilters({force = false} = {}) {
if (!force) {
debounce(cleanupCachedFilters, 1000, {force: true});
return;
}
const size = cachedStyles.filters.size;
const oldestHit = cachedStyles.filters.values().next().value.lastHit;
const now = Date.now();
const timeSpan = now - oldestHit;
const recencyWeight = 5 / size;
const hitWeight = 1 / 4; // we make ~4 hits per URL
const lastHitWeight = 10;
// delete the oldest 10%
[...cachedStyles.filters.entries()]
.map(([id, v], index) => ({
id,
weight:
index * recencyWeight +
v.hits * hitWeight +
(v.lastHit - oldestHit) / timeSpan * lastHitWeight,
}))
.sort((a, b) => a.weight - b.weight)
.slice(0, size / 10 + 1)
.forEach(({id}) => cachedStyles.filters.delete(id));
}
function getDomains(url) {
let d = /.*?:\/*([^/:]+)|$/.exec(url)[1];
if (!d || url.startsWith('file:')) {
return [];
}
const domains = [d];
while (d.indexOf('.') !== -1) {
d = d.substring(d.indexOf('.') + 1);
domains.push(d);
}
return domains;
}
function normalizeStyleSections({sections}) {
// retain known properties in an arbitrarily predefined order
return (sections || []).map(section => ({
code: section.code || '',
urls: section.urls || [],
urlPrefixes: section.urlPrefixes || [],
domains: section.domains || [],
regexps: section.regexps || [],
}));
}
function calcStyleDigest(style) {
const jsonString = style.usercssData ?
style.sourceCode : JSON.stringify(normalizeStyleSections(style));
const text = new TextEncoder('utf-8').encode(jsonString);
return crypto.subtle.digest('SHA-1', text).then(hex);
function hex(buffer) {
const parts = [];
const PAD8 = '00000000';
const view = new DataView(buffer);
for (let i = 0; i < view.byteLength; i += 4) {
parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8));
}
return parts.join('');
}
}
function handleCssTransitionBug({tabId, frameId, url, styles}) {
for (let id in styles) {
id |= 0;
if (!id) {
continue;
}
let need = cachedStyles.needTransitionPatch.get(id);
if (need === false) {
continue;
}
if (need !== true) {
need = styles[id].some(sectionContainsTransitions);
cachedStyles.needTransitionPatch.set(id, need);
if (!need) {
continue;
}
}
if (FIREFOX && !url.startsWith(URLS.ownOrigin)) {
patchFirefox();
} else {
styles.needTransitionPatch = true;
}
break;
}
function patchFirefox() {
const options = {
frameId,
code: CSS_TRANSITION_SUPPRESSOR,
matchAboutBlank: true,
};
if (FIREFOX >= 53) {
options.cssOrigin = 'user';
}
browser.tabs.insertCSS(tabId, Object.assign(options, {
runAt: 'document_start',
})).then(() => setTimeout(() => {
browser.tabs.removeCSS(tabId, options).catch(ignoreChromeError);
})).catch(ignoreChromeError);
}
function sectionContainsTransitions(section) {
let code = section.code;
const firstTransition = code.indexOf('transition');
if (firstTransition < 0) {
return false;
}
const firstCmt = code.indexOf('/*');
// check the part before the first comment
if (firstCmt < 0 || firstTransition < firstCmt) {
if (quickCheckAround(code, firstTransition)) {
return true;
} else if (firstCmt < 0) {
return false;
}
}
// check the rest
const lastCmt = code.lastIndexOf('*/');
if (lastCmt < firstCmt) {
// the comment is unclosed and we already checked the preceding part
return false;
}
let mid = code.slice(firstCmt, lastCmt + 2);
mid = mid.indexOf('*/') === mid.length - 2 ? '' : mid.replace(RX_CSS_COMMENTS, '');
code = mid + code.slice(lastCmt + 2);
return quickCheckAround(code) || RX_CSS_TRANSITION_DETECTOR.test(code);
}
function quickCheckAround(code, pos = code.indexOf('transition')) {
return RX_CSS_TRANSITION_DETECTOR.test(code.substr(Math.max(0, pos - 10), 50));
}
}
/*
According to CSS4 @document specification the entire URL must match.
Stylish-for-Chrome implemented it incorrectly since the very beginning.
We'll detect styles that abuse the bug by finding the sections that
would have been applied by Stylish but not by us as we follow the spec.
Additionally we'll check for invalid regexps.
*/
function detectSloppyRegexps({matchUrl, ids}) {
const results = [];
for (const id of ids) {
const style = cachedStyles.byId.get(id);
if (!style) continue;
// make sure all regexps are compiled
const rxCache = cachedStyles.regexps;
let hasRegExp = false;
for (const section of style.sections) {
for (const regexp of section.regexps) {
hasRegExp = true;
for (let pass = 1; pass <= 2; pass++) {
const cacheKey = pass === 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp;
if (!rxCache.has(cacheKey)) {
// according to CSS4 @document specification the entire URL must match
const anchored = pass === 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$';
// create in the bg context to avoid leaking of "dead objects"
const rx = tryRegExp(anchored);
rxCache.set(cacheKey, rx || false);
}
}
}
}
if (!hasRegExp) continue;
const applied = getApplicableSections({style, matchUrl});
const wannabe = getApplicableSections({style, matchUrl, strictRegexp: false});
results.push({
id,
applied,
skipped: wannabe.length - applied.length,
hasInvalidRegexps: wannabe.some(({regexps}) => regexps.some(rx => !rxCache.has(rx))),
});
}
return results;
}

492
background/style-manager.js Normal file
View File

@ -0,0 +1,492 @@
/* eslint no-eq-null: 0, eqeqeq: [2, "smart"] */
/* global createCache db calcStyleDigest db tryRegExp styleCodeEmpty
getStyleWithNoCode msg */
/* exported styleManager */
'use strict';
/*
This style manager is a layer between content script and the DB. When a style
is added/updated, it broadcast a message to content script and the content
script would try to fetch the new code.
The live preview feature relies on `runtime.connect` and `port.onDisconnect`
to cleanup the temporary code. See /edit/live-preview.js.
*/
const styleManager = (() => {
const preparing = prepare();
/* styleId => {
data: styleData,
preview: styleData,
appliesTo: Set<url>
} */
const styles = new Map();
/* url => {
maybeMatch: Set<styleId>,
sections: Object<styleId => {
id: styleId,
code: Array<String>
}>
} */
const cachedStyleForUrl = createCache({
onDeleted: (url, cache) => {
for (const section of Object.values(cache.sections)) {
const style = styles.get(section.id);
if (style) {
style.appliesTo.delete(url);
}
}
}
});
const BAD_MATCHER = {test: () => false};
const compileRe = createCompiler(text => `^(${text})$`);
const compileSloppyRe = createCompiler(text => `^${text}$`);
const compileExclusion = createCompiler(buildGlob);
handleLivePreviewConnections();
return ensurePrepared({
get,
getSectionsByUrl,
installStyle,
deleteStyle,
editSave,
findStyle,
importStyle,
toggleStyle,
setStyleExclusions,
getAllStyles, // used by import-export
getStylesByUrl, // used by popup
styleExists,
});
function handleLivePreviewConnections() {
chrome.runtime.onConnect.addListener(port => {
if (port.name !== 'livePreview') {
return;
}
let id;
port.onMessage.addListener(data => {
if (!id) {
id = data.id;
}
const style = styles.get(id);
style.preview = data;
broadcastStyleUpdated(style.preview, 'editPreview');
});
port.onDisconnect.addListener(() => {
port = null;
if (id) {
const style = styles.get(id);
if (!style) {
// maybe deleted
return;
}
style.preview = null;
broadcastStyleUpdated(style.data, 'editPreviewEnd');
}
});
});
}
function get(id, noCode = false) {
const data = styles.get(id).data;
return noCode ? getStyleWithNoCode(data) : data;
}
function getAllStyles(noCode = false) {
const datas = [...styles.values()].map(s => s.data);
return noCode ? datas.map(getStyleWithNoCode) : datas;
}
function toggleStyle(id, enabled) {
const style = styles.get(id);
const data = Object.assign({}, style.data, {enabled});
return saveStyle(data)
.then(newData => handleSave(newData, 'toggle', false))
.then(() => id);
}
// used by install-hook-userstyles.js
function findStyle(filter, noCode = false) {
for (const style of styles.values()) {
if (filterMatch(filter, style.data)) {
return noCode ? getStyleWithNoCode(style.data) : style.data;
}
}
return null;
}
function styleExists(filter) {
return [...styles.values()].some(s => filterMatch(filter, s.data));
}
function filterMatch(filter, target) {
for (const key of Object.keys(filter)) {
if (filter[key] !== target[key]) {
return false;
}
}
return true;
}
function importStyle(data) {
// FIXME: is it a good idea to save the data directly?
return saveStyle(data)
.then(newData => handleSave(newData, 'import'));
}
function installStyle(data, reason = null) {
const style = styles.get(data.id);
if (!style) {
data = Object.assign(createNewStyle(), data);
} else {
data = Object.assign({}, style.data, data);
}
if (!reason) {
reason = style ? 'update' : 'install';
}
// FIXME: update updateDate? what about usercss config?
return calcStyleDigest(data)
.then(digest => {
data.originalDigest = digest;
return saveStyle(data);
})
.then(newData => handleSave(newData, reason));
}
function editSave(data) {
const style = styles.get(data.id);
if (style) {
data = Object.assign({}, style.data, data);
} else {
data = Object.assign(createNewStyle(), data);
}
return saveStyle(data)
.then(newData => handleSave(newData, 'editSave'));
}
function setStyleExclusions(id, exclusions) {
const data = Object.assign({}, styles.get(id).data, {exclusions});
return saveStyle(data)
.then(newData => handleSave(newData, 'exclusions'));
}
function deleteStyle(id) {
const style = styles.get(id);
return db.exec('delete', id)
.then(() => {
for (const url of style.appliesTo) {
const cache = cachedStyleForUrl.get(url);
if (cache) {
delete cache.sections[id];
}
}
styles.delete(id);
return msg.broadcast({
method: 'styleDeleted',
style: {id}
});
})
.then(() => id);
}
function ensurePrepared(methods) {
const prepared = {};
for (const [name, fn] of Object.entries(methods)) {
prepared[name] = (...args) =>
preparing.then(() => fn(...args));
}
return prepared;
}
function createNewStyle() {
return {
enabled: true,
updateUrl: null,
md5Url: null,
url: null,
originalMd5: null,
installDate: Date.now()
};
}
function broadcastStyleUpdated(data, reason, method = 'styleUpdated', codeIsUpdated = true) {
const style = styles.get(data.id);
const excluded = new Set();
const updated = new Set();
for (const [url, cache] of cachedStyleForUrl.entries()) {
if (!style.appliesTo.has(url)) {
cache.maybeMatch.add(data.id);
continue;
}
const code = getAppliedCode(url, data);
if (!code) {
excluded.add(url);
delete cache.sections[data.id];
} else {
updated.add(url);
cache.sections[data.id] = {
id: data.id,
code
};
}
}
style.appliesTo = updated;
return msg.broadcast({
method,
style: {
id: data.id,
enabled: data.enabled
},
reason,
codeIsUpdated
});
}
function saveStyle(style) {
if (!style.name) {
throw new Error('style name is empty');
}
if (style.id == null) {
delete style.id;
}
fixUsoMd5Issue(style);
return db.exec('put', style)
.then(event => {
if (style.id == null) {
style.id = event.target.result;
}
return style;
});
}
function handleSave(data, reason, codeIsUpdated) {
const style = styles.get(data.id);
let method;
if (!style) {
styles.set(data.id, {
appliesTo: new Set(),
data
});
method = 'styleAdded';
} else {
style.data = data;
method = 'styleUpdated';
}
return broadcastStyleUpdated(data, reason, method, codeIsUpdated)
.then(() => data);
}
// get styles matching a URL, including sloppy regexps and excluded items.
function getStylesByUrl(url, id = null) {
// FIXME: do we want to cache this? Who would like to open popup rapidly
// or search the DB with the same URL?
const result = [];
const datas = !id ? [...styles.values()].map(s => s.data) :
styles.has(id) ? [styles.get(id).data] : [];
for (const data of datas) {
let excluded = false;
let sloppy = false;
let sectionMatched = false;
const match = urlMatchStyle(url, data);
// TODO: enable this when the function starts returning false
// if (match === false) {
// continue;
// }
if (match === 'excluded') {
excluded = true;
}
for (const section of data.sections) {
if (styleCodeEmpty(section.code)) {
continue;
}
const match = urlMatchSection(url, section);
if (match) {
if (match === 'sloppy') {
sloppy = true;
}
sectionMatched = true;
break;
}
}
if (sectionMatched) {
result.push({
data: getStyleWithNoCode(data),
excluded,
sloppy
});
}
}
return result;
}
function getSectionsByUrl(url, id) {
let cache = cachedStyleForUrl.get(url);
if (!cache) {
cache = {
sections: {},
maybeMatch: new Set()
};
buildCache(styles.values());
cachedStyleForUrl.set(url, cache);
} else if (cache.maybeMatch.size) {
buildCache(
[...cache.maybeMatch]
.filter(i => styles.has(i))
.map(i => styles.get(i))
);
}
if (id) {
if (cache.sections[id]) {
return {[id]: cache.sections[id]};
}
return {};
}
return cache.sections;
function buildCache(styleList) {
for (const {appliesTo, data, preview} of styleList) {
const code = getAppliedCode(url, preview || data);
if (code) {
cache.sections[data.id] = {
id: data.id,
code
};
appliesTo.add(url);
}
}
}
}
function getAppliedCode(url, data) {
if (urlMatchStyle(url, data) !== true) {
return;
}
const code = [];
for (const section of data.sections) {
if (urlMatchSection(url, section) === true && !styleCodeEmpty(section.code)) {
code.push(section.code);
}
}
return code.length && code;
}
function prepare() {
return db.exec('getAll').then(event => {
const styleList = event.target.result;
if (!styleList) {
return;
}
for (const style of styleList) {
fixUsoMd5Issue(style);
styles.set(style.id, {
appliesTo: new Set(),
data: style
});
if (!style.name) {
style.name = 'ID: ' + style.id;
}
}
});
}
function urlMatchStyle(url, style) {
if (style.exclusions && style.exclusions.some(e => compileExclusion(e).test(url))) {
return 'excluded';
}
if (!style.enabled) {
return 'disabled';
}
return true;
}
function urlMatchSection(url, section) {
const domain = getDomain(url);
if (section.domains && section.domains.some(d => d === domain || domain.endsWith(`.${d}`))) {
return true;
}
if (section.urlPrefixes && section.urlPrefixes.some(p => url.startsWith(p))) {
return true;
}
// as per spec the fragment portion is ignored in @-moz-document:
// https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc
// but the spec is outdated and doesn't account for SPA sites
// so we only respect it for `url()` function
if (section.urls && (
section.urls.includes(url) ||
section.urls.includes(getUrlNoHash(url))
)) {
return true;
}
if (section.regexps && section.regexps.some(r => compileRe(r).test(url))) {
return true;
}
/*
According to CSS4 @document specification the entire URL must match.
Stylish-for-Chrome implemented it incorrectly since the very beginning.
We'll detect styles that abuse the bug by finding the sections that
would have been applied by Stylish but not by us as we follow the spec.
*/
if (section.regexps && section.regexps.some(r => compileSloppyRe(r).test(url))) {
return 'sloppy';
}
// TODO: check for invalid regexps?
if (
(!section.regexps || !section.regexps.length) &&
(!section.urlPrefixes || !section.urlPrefixes.length) &&
(!section.urls || !section.urls.length) &&
(!section.domains || !section.domains.length)
) {
return true;
}
return false;
}
function createCompiler(compile) {
// FIXME: FIFO cache doesn't work well here, if we want to match many
// regexps more than the cache size, we will never hit the cache because
// the first cache is deleted. So we use a simple map but it leaks memory.
const cache = new Map();
return text => {
let re = cache.get(text);
if (!re) {
re = tryRegExp(compile(text));
if (!re) {
re = BAD_MATCHER;
}
cache.set(text, re);
}
return re;
};
}
function buildGlob(text) {
const prefix = text[0] === '^' ? '' : '\\b';
const suffix = text[text.length - 1] === '$' ? '' : '\\b';
return `${prefix}${escape(text)}${suffix}`;
function escape(text) {
// FIXME: using .* everywhere is slow
return text.replace(/[.*]/g, m => m === '.' ? '\\.' : '.*');
}
}
function getDomain(url) {
return url.match(/^[\w-]+:\/+(?:[\w:-]+@)?([^:/#]+)/)[1];
}
function getUrlNoHash(url) {
return url.split('#')[0];
}
// The md5Url provided by USO includes a duplicate "update" subdomain (see #523),
// This fixes any already installed styles containing this error
function fixUsoMd5Issue(style) {
if (style && style.md5Url && style.md5Url.includes('update.update.userstyles')) {
style.md5Url = style.md5Url.replace('update.update.userstyles', 'update.userstyles');
}
}
})();

View File

@ -1,4 +1,4 @@
/* global getStyles API_METHODS */
/* global API_METHODS styleManager CHROME prefs updateIconBadge */
'use strict';
API_METHODS.styleViaAPI = !CHROME && (() => {
@ -9,6 +9,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
styleAdded,
styleReplaceAll,
prefChanged,
updateCount,
};
const NOP = Promise.resolve(new Error('NOP'));
const onError = () => {};
@ -22,15 +23,23 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
let observingTabs = false;
return (request, sender) => {
const action = ACTIONS[request.action];
return function (request) {
const action = ACTIONS[request.method];
return !action ? NOP :
action(request, sender)
action(request, this.sender)
.catch(onError)
.then(maybeToggleObserver);
};
function styleApply({id = null, ignoreUrlCheck}, {tab, frameId, url}) {
function updateCount(request, {tab, frameId}) {
if (frameId) {
throw new Error('we do not count styles for frames');
}
const {frameStyles} = getCachedData(tab.id, frameId);
updateIconBadge(tab.id, Object.keys(frameStyles).length);
}
function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) {
if (prefs.get('disableAll')) {
return NOP;
}
@ -38,24 +47,15 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
if (id === null && !ignoreUrlCheck && frameStyles.url === url) {
return NOP;
}
return getStyles({id, matchUrl: url, asHash: true}).then(styles => {
return styleManager.getSectionsByUrl(url, id).then(sections => {
const tasks = [];
for (const styleId in styles) {
if (isNaN(parseInt(styleId))) {
continue;
}
// shallow-extract code from the sections array in order to reuse references
// in other places whereas the combined string gets garbage-collected
const styleSections = styles[styleId].map(section => section.code);
const code = styleSections.join('\n');
if (!code) {
delete frameStyles[styleId];
continue;
}
for (const section of Object.values(sections)) {
const styleId = section.id;
const code = section.code.join('\n');
if (code === (frameStyles[styleId] || []).join('\n')) {
continue;
}
frameStyles[styleId] = styleSections;
frameStyles[styleId] = section.code;
tasks.push(
browser.tabs.insertCSS(tab.id, {
code,
@ -70,16 +70,18 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
cache.set(tab.id, tabFrames);
}
return Promise.all(tasks);
});
})
.then(() => updateCount(null, {tab, frameId}));
}
function styleDeleted({id}, {tab, frameId}) {
function styleDeleted({style: {id}}, {tab, frameId}) {
const {tabFrames, frameStyles, styleSections} = getCachedData(tab.id, frameId, id);
const code = styleSections.join('\n');
if (code && !duplicateCodeExists({frameStyles, id, code})) {
delete frameStyles[id];
removeFrameIfEmpty(tab.id, frameId, tabFrames, frameStyles);
return removeCSS(tab.id, frameId, code);
return removeCSS(tab.id, frameId, code)
.then(() => updateCount(null, {tab, frameId}));
} else {
return NOP;
}
@ -87,7 +89,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
function styleUpdated({style}, sender) {
if (!style.enabled) {
return styleDeleted(style, sender);
return styleDeleted({style}, sender);
}
const {tab, frameId} = sender;
const {frameStyles, styleSections} = getCachedData(tab.id, frameId, style.id);

View File

@ -1,9 +1,7 @@
/*
global getStyles saveStyle styleSectionsEqual
global calcStyleDigest cachedStyles getStyleWithNoCode
global usercss semverCompare
global API_METHODS
*/
/* global styleSectionsEqual prefs download tryJSONparse ignoreChromeError
calcStyleDigest getStyleWithNoCode debounce chromeLocal
usercss semverCompare
API_METHODS styleManager */
'use strict';
(() => {
@ -51,7 +49,7 @@ global API_METHODS
checkingAll = true;
retrying.clear();
const port = observe && chrome.runtime.connect({name: 'updater'});
return getStyles({}).then(styles => {
return styleManager.getAllStyles().then(styles => {
styles = styles.filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length});
log('');
@ -70,7 +68,7 @@ global API_METHODS
function checkStyle({
id,
style = cachedStyles.byId.get(id),
style,
port,
save = true,
ignoreDigest,
@ -89,14 +87,33 @@ global API_METHODS
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
return Promise.resolve(style)
.then([calcStyleDigest][!ignoreDigest ? 0 : 'skip'])
.then([checkIfEdited][!ignoreDigest ? 0 : 'skip'])
.then([maybeUpdateUSO, maybeUpdateUsercss][style.usercssData ? 1 : 0])
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.name}`);
const info = {updated: true, style: saved};
@ -145,8 +162,8 @@ global API_METHODS
function maybeUpdateUsercss() {
// TODO: when sourceCode is > 100kB use http range request(s) for version check
return download(style.updateUrl).then(text => {
const json = usercss.buildMeta(text);
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))) {
@ -162,7 +179,8 @@ global API_METHODS
return Promise.reject(STATES.ERROR_VERSION);
}
return usercss.buildCode(json);
});
})
);
}
function maybeSave(json = {}) {
@ -173,7 +191,6 @@ global API_METHODS
json.id = style.id;
json.updateDate = Date.now();
json.reason = 'update';
// keep current state
delete json.enabled;
@ -185,10 +202,10 @@ global API_METHODS
json.originalName = json.name;
}
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.
json.reason = 'update-digest';
return saveStyle(json)
return styleManager.installStyle(newStyle)
.then(saved => {
style.originalDigest = saved.originalDigest;
return Promise.reject(STATES.SAME_CODE);
@ -200,8 +217,8 @@ global API_METHODS
}
return save ?
API_METHODS[json.usercssData ? 'saveUsercss' : 'saveStyle'](json) :
json;
API_METHODS[json.usercssData ? 'installUsercss' : 'installStyle'](newStyle) :
newStyle;
}
function styleJSONseemsValid(json) {

View File

@ -1,13 +1,15 @@
/* global API_METHODS usercss saveStyle getStyles chromeLocal cachedStyles */
/* global API_METHODS usercss chromeLocal styleManager FIREFOX deepCopy openURL
download */
'use strict';
(() => {
API_METHODS.installUsercss = installUsercss;
API_METHODS.editSaveUsercss = editSaveUsercss;
API_METHODS.configUsercssVars = configUsercssVars;
API_METHODS.saveUsercss = style => save(style, false);
API_METHODS.saveUsercssUnsafe = style => save(style, true);
API_METHODS.buildUsercss = build;
API_METHODS.installUsercss = install;
API_METHODS.parseUsercss = parse;
API_METHODS.openUsercssInstallPage = install;
API_METHODS.findUsercss = find;
const TEMP_CODE_PREFIX = 'tempUsercssCode';
@ -40,69 +42,96 @@
if (style.usercssData) {
return Promise.resolve(style);
}
try {
const {sourceCode} = style;
// allow sourceCode to be normalized
const {sourceCode} = style;
delete style.sourceCode;
return Promise.resolve(Object.assign(usercss.buildMeta(sourceCode), style));
} catch (e) {
return Promise.reject(e);
}
return usercss.buildMeta(sourceCode)
.then(newStyle => Object.assign(newStyle, style));
}
function assignVars(style) {
if (style.reason === 'config' && style.id) {
return style;
}
const dup = find(style);
return find(style)
.then(dup => {
if (dup) {
style.id = dup.id;
if (style.reason !== 'config') {
// preserve style.vars during update
usercss.assignVars(style, dup);
}
return usercss.assignVars(style, dup)
.then(() => style);
}
return style;
});
}
/**
* Parse the source and find the duplication
* 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({
sourceCode,
checkDup,
metaOnly,
vars,
assignVars = false,
}) {
const task = buildMeta({sourceCode});
return (metaOnly ? task : task.then(usercss.buildCode))
.then(style => ({
style,
dup: checkDup && find(style),
}));
return usercss.buildMeta(sourceCode)
.then(style => {
const findDup = checkDup || assignVars ? find(style) : null;
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);
}
}
// Parse the source, apply customizations, report fatal/syntax errors
function parse(style, allowErrors = false) {
// restore if stripped by getStyleWithNoCode
if (typeof style.sourceCode !== 'string') {
style.sourceCode = cachedStyles.byId.get(style.id).sourceCode;
}
// 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(style => usercss.buildCode(style, allowErrors));
.then(usercss.buildCode);
}
function save(style, allowErrors = false) {
return parse(style, allowErrors)
.then(result =>
allowErrors ?
saveStyle(result.style).then(style => ({style, errors: result.errors})) :
saveStyle(result));
// 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);
}
/**
@ -110,9 +139,12 @@
* @returns {Style}
*/
function find(styleOrData) {
if (styleOrData.id) return cachedStyles.byId.get(styleOrData.id);
if (styleOrData.id) {
return styleManager.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
for (const dup of cachedStyles.list) {
return styleManager.getAllStyles().then(styleList => {
for (const dup of styleList) {
const data = dup.usercssData;
if (!data) continue;
if (data.name === name &&
@ -120,9 +152,10 @@
return dup;
}
}
});
}
function install({url, direct, downloaded, tab}, sender) {
function install({url, direct, downloaded, tab}, sender = this.sender) {
tab = tab !== undefined ? tab : sender.tab;
url = url || tab.url;
if (direct && !downloaded) {

View File

@ -1,44 +1,128 @@
/* eslint no-var: 0 */
/* global msg API prefs */
/* exported APPLY */
'use strict';
(() => {
if (typeof window.applyOnMessage === 'function') {
// some weird bug in new Chrome: the content script gets injected multiple times
return;
}
// define a constant so it throws when redefined
const APPLY = (() => {
const CHROME = chrome.app ? parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]) : NaN;
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
var ID_PREFIX = 'stylus-';
var ROOT = document.documentElement;
var ROOT;
var isOwnPage = location.protocol.endsWith('-extension:');
var disableAll = false;
var exposeIframes = false;
var styleElements = new Map();
var disabledElements = new Map();
var retiredStyleTimers = new Map();
var docRewriteObserver;
var docRootObserver;
const setStyleContent = createSetStyleContent();
const initializing = init();
msg.onTab(applyOnMessage);
if (!isOwnPage) {
window.dispatchEvent(new CustomEvent(chrome.runtime.id, {
detail: pageObject({method: 'orphan'})
}));
window.addEventListener(chrome.runtime.id, orphanCheck, true);
}
let parentDomain;
prefs.subscribe(['disableAll'], (key, value) => doDisableAll(value));
if (window !== parent) {
prefs.subscribe(['exposeIframes'], updateExposeIframes);
}
function init() {
if (STYLE_VIA_API) {
return API.styleViaAPI({method: 'styleApply'});
}
return API.getSectionsByUrl(getMatchUrl())
.then(result => {
ROOT = document.documentElement;
applyStyles(result, () => {
// CSS transition bug workaround: since we insert styles asynchronously,
// the browsers, especially Firefox, may apply all transitions on page load
if ([...styleElements.values()].some(n => n.textContent.includes('transition'))) {
applyTransitionPatch();
}
});
});
}
function pageObject(target) {
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts
const obj = new window.Object();
Object.assign(obj, target);
return obj;
}
function createSetStyleContent() {
// FF59+ bug workaround
// See https://github.com/openstyles/stylus/issues/461
// Since it's easy to spoof the browser version in pre-Quantum FF we're checking
// for getPreventDefault which got removed in FF59 https://bugzil.la/691151
const FF_BUG461 = !CHROME && !isOwnPage && !Event.prototype.getPreventDefault;
const pageContextQueue = [];
requestStyles();
chrome.runtime.onMessage.addListener(applyOnMessage);
window.applyOnMessage = applyOnMessage;
if (!isOwnPage) {
window.dispatchEvent(new CustomEvent(chrome.runtime.id));
window.addEventListener(chrome.runtime.id, orphanCheck, true);
const EVENT_NAME = chrome.runtime.id;
const usePageScript = CHROME || isOwnPage || Event.prototype.getPreventDefault ?
Promise.resolve(false) : injectPageScript();
return (el, content) =>
usePageScript.then(ok => {
if (!ok) {
const disabled = el.disabled;
el.textContent = content;
el.disabled = disabled;
} else {
const detail = pageObject({
method: 'setStyleContent',
id: el.id,
content
});
window.dispatchEvent(new CustomEvent(EVENT_NAME, {detail}));
}
});
function requestStyles(options, callback = applyStyles) {
if (!chrome.app && document instanceof XMLDocument) {
chrome.runtime.sendMessage({method: 'styleViaAPI', action: 'styleApply'});
function injectPageScript() {
const scriptContent = EVENT_NAME => {
document.currentScript.remove();
window.addEventListener(EVENT_NAME, function handler(e) {
const {method, id, content} = e.detail;
if (method === 'setStyleContent') {
const el = document.getElementById(id);
if (!el) {
return;
}
const disabled = el.disabled;
el.textContent = content;
el.disabled = disabled;
} else if (method === 'orphan') {
window.removeEventListener(EVENT_NAME, handler);
}
}, true);
};
const code = `(${scriptContent})(${JSON.stringify(EVENT_NAME)})`;
const src = `data:application/javascript;base64,${btoa(code)}`;
const script = document.createElement('script');
const {resolve, promise} = deferred();
script.src = src;
script.onload = () => resolve(true);
script.onerror = () => resolve(false);
document.documentElement.appendChild(script);
return promise;
}
}
function deferred() {
const o = {};
o.promise = new Promise((resolve, reject) => {
o.resolve = resolve;
o.reject = reject;
});
return o;
}
function getMatchUrl() {
var matchUrl = location.href;
if (!matchUrl.match(/^(http|file|chrome|ftp)/)) {
// dynamic about: and javascript: iframes don't have an URL yet
@ -49,78 +133,38 @@
}
} catch (e) {}
}
const request = Object.assign({
method: 'getStylesForFrame',
asHash: true,
matchUrl,
}, options);
// On own pages we request the styles directly to minimize delay and flicker
if (typeof API === 'function') {
API.getStyles(request).then(callback);
} else if (!CHROME && getStylesFallback(request)) {
// NOP
} else {
chrome.runtime.sendMessage(request, callback);
}
return matchUrl;
}
/**
* TODO: remove when FF fixes the bug.
* Firefox borks sendMessage in same-origin iframes that have 'src' with a real path on the site.
* We implement a workaround for the initial styleApply case only.
* Everything else (like toggling of styles) is still buggy.
* @param {Object} msg
* @param {Function} callback
* @returns {Boolean|undefined}
*/
function getStylesFallback(msg) {
if (window !== parent &&
location.href !== 'about:blank') {
try {
if (parent.location.origin === location.origin &&
parent.location.href !== location.href) {
chrome.runtime.connect({name: 'getStyles:' + JSON.stringify(msg)});
function applyOnMessage(request) {
if (request.method === 'ping') {
return true;
}
} catch (e) {}
if (STYLE_VIA_API) {
if (request.method === 'urlChanged') {
request.method = 'styleReplaceAll';
}
}
function applyOnMessage(request, sender, sendResponse) {
if (request.styles === 'DIY') {
// Do-It-Yourself tells our built-in pages to fetch the styles directly
// which is faster because IPC messaging JSON-ifies everything internally
requestStyles({}, styles => {
request.styles = styles;
applyOnMessage(request);
});
return;
}
if (!chrome.app && document instanceof XMLDocument && request.method !== 'ping') {
request.action = request.method;
request.method = 'styleViaAPI';
request.styles = null;
if (request.style) {
request.style.sections = null;
}
chrome.runtime.sendMessage(request);
API.styleViaAPI(request);
return;
}
switch (request.method) {
case 'styleDeleted':
removeStyle(request);
removeStyle(request.style);
break;
case 'styleUpdated':
if (request.codeIsUpdated === false) {
applyStyleState(request.style);
break;
} else if (request.style.enabled) {
API.getSectionsByUrl(getMatchUrl(), request.style.id)
.then(sections => {
if (!sections[request.style.id]) {
removeStyle(request.style);
} else {
applyStyles(sections);
}
if (request.style.enabled) {
removeStyle({id: request.style.id, retire: true});
requestStyles({id: request.style.id});
});
} else {
removeStyle(request.style);
}
@ -128,29 +172,28 @@
case 'styleAdded':
if (request.style.enabled) {
requestStyles({id: request.style.id});
API.getSectionsByUrl(getMatchUrl(), request.style.id)
.then(applyStyles);
}
break;
case 'styleApply':
applyStyles(request.styles);
case 'urlChanged':
API.getSectionsByUrl(getMatchUrl())
.then(replaceAll);
break;
case 'styleReplaceAll':
replaceAll(request.styles);
break;
case 'prefChanged':
if ('disableAll' in request.prefs) {
doDisableAll(request.prefs.disableAll);
}
if ('exposeIframes' in request.prefs) {
doExposeIframes(request.prefs.exposeIframes);
case 'backgroundReady':
initializing
.catch(err => {
if (msg.RX_NO_RECEIVER.test(err.message)) {
return init();
}
})
.catch(console.error);
break;
case 'ping':
sendResponse(true);
case 'updateCount':
updateCount();
break;
}
}
@ -160,6 +203,9 @@
return;
}
disableAll = disable;
if (STYLE_VIA_API) {
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
} else {
Array.prototype.forEach.call(document.styleSheets, stylesheet => {
if (stylesheet.ownerNode.matches(`style.stylus[id^="${ID_PREFIX}"]`)
&& stylesheet.disabled !== disable) {
@ -167,20 +213,53 @@
}
});
}
}
function doExposeIframes(state = exposeIframes) {
if (state === exposeIframes ||
state === true && typeof exposeIframes === 'string' ||
window === parent) {
function fetchParentDomain() {
if (parentDomain) {
return Promise.resolve();
}
return API.getTabUrlPrefix()
.then(newDomain => {
parentDomain = newDomain;
});
}
function updateExposeIframes() {
if (!prefs.get('exposeIframes') || window === parent || !styleElements.size) {
document.documentElement.removeAttribute('stylus-iframe');
} else {
fetchParentDomain().then(() => {
document.documentElement.setAttribute('stylus-iframe', parentDomain);
});
}
}
function updateCount() {
if (window !== parent) {
// we don't care about iframes
return;
}
exposeIframes = state;
const attr = document.documentElement.getAttribute('stylus-iframe');
if (state && state !== attr) {
document.documentElement.setAttribute('stylus-iframe', state);
} else if (!state && attr !== undefined) {
document.documentElement.removeAttribute('stylus-iframe');
if (/^\w+?-extension:\/\/.+(popup|options)\.html$/.test(location.href)) {
// popup and the option page are not tabs
return;
}
if (STYLE_VIA_API) {
API.styleViaAPI({method: 'updateCount'}).catch(msg.ignoreError);
return;
}
let count = 0;
for (const id of styleElements.keys()) {
if (!disabledElements.has(id)) {
count++;
}
}
// we have to send the tabId so we can't use `sendBg` that is used by `API`
msg.send({
method: 'invokeAPI',
name: 'updateIconBadge',
args: [count]
}).catch(msg.ignoreError);
}
function applyStyleState({id, enabled}) {
@ -193,7 +272,8 @@
addStyleElement(inCache);
disabledElements.delete(id);
} else {
requestStyles({id});
return API.getSectionsByUrl(getMatchUrl(), id)
.then(applyStyles);
}
} else {
if (inDoc) {
@ -201,32 +281,25 @@
docRootObserver.evade(() => inDoc.remove());
}
}
updateCount();
}
function removeStyle({id, retire = false}) {
function removeStyle({id}) {
const el = document.getElementById(ID_PREFIX + id);
if (el) {
if (retire) {
// to avoid page flicker when the style is updated
// instead of removing it immediately we rename its ID and queue it
// to be deleted in applyStyles after a new version is fetched and applied
const deadID = id + '-ghost';
el.id = ID_PREFIX + deadID;
// in case something went wrong and new style was never applied
retiredStyleTimers.set(deadID, setTimeout(removeStyle, 1000, {id: deadID}));
} else {
docRootObserver.evade(() => el.remove());
}
}
styleElements.delete(ID_PREFIX + id);
disabledElements.delete(id);
retiredStyleTimers.delete(id);
if (styleElements.delete(id)) {
updateCount();
}
}
function applyStyles(styles) {
if (!styles) {
// Chrome is starting up
requestStyles();
function applyStyles(sections, done) {
if (!Object.keys(sections).length) {
if (done) {
done();
}
return;
}
@ -234,72 +307,40 @@
new MutationObserver((mutations, observer) => {
if (document.documentElement) {
observer.disconnect();
applyStyles(styles);
applyStyles(sections, done);
}
}).observe(document, {childList: true});
return;
}
if ('disableAll' in styles) {
doDisableAll(styles.disableAll);
}
if ('exposeIframes' in styles) {
doExposeIframes(styles.exposeIframes);
}
const gotNewStyles = styles.length || styles.needTransitionPatch;
if (gotNewStyles) {
if (docRootObserver) {
docRootObserver.stop();
} else {
initDocRootObserver();
}
}
if (styles.needTransitionPatch) {
applyTransitionPatch();
}
if (gotNewStyles) {
for (const id in styles) {
const sections = styles[id];
if (!Array.isArray(sections)) continue;
applySections(id, sections.map(({code}) => code).join('\n'));
const pending = [];
for (const section of Object.values(sections)) {
pending.push(applySections(section.id, section.code.join('')));
}
docRootObserver.firstStart();
}
if (FF_BUG461 && (gotNewStyles || styles.needTransitionPatch)) {
setContentsInPageContext();
}
if (!isOwnPage && !docRewriteObserver && styleElements.size) {
initDocRewriteObserver();
}
if (retiredStyleTimers.size) {
setTimeout(() => {
for (const [id, timer] of retiredStyleTimers.entries()) {
removeStyle({id});
clearTimeout(timer);
}
});
updateExposeIframes();
updateCount();
if (done) {
Promise.all(pending).then(done);
}
}
function applySections(styleId, code) {
const id = ID_PREFIX + styleId;
let el = styleElements.get(id) || document.getElementById(id);
if (el && el.textContent !== code) {
if (CHROME < 3321) {
function applySections(id, code) {
let el = styleElements.get(id) || document.getElementById(ID_PREFIX + id);
if (el && CHROME < 3321) {
// workaround for Chrome devtools bug fixed in v65
el.remove();
el = null;
} else if (FF_BUG461) {
pageContextQueue.push({id: el.id, el, code});
} else {
el.textContent = code;
}
}
if (!el) {
if (document.documentElement instanceof SVGSVGElement) {
@ -312,48 +353,19 @@
// HTML document style; also works on HTML-embedded SVG
el = document.createElement('style');
}
el.id = id;
el.id = ID_PREFIX + id;
el.type = 'text/css';
// SVG className is not a string, but an instance of SVGAnimatedString
el.classList.add('stylus');
if (FF_BUG461) {
pageContextQueue.push({id: el.id, el, code});
} else {
el.textContent = code;
}
addStyleElement(el);
}
styleElements.set(id, el);
disabledElements.delete(Number(styleId));
return el;
}
function setContentsInPageContext() {
try {
(document.head || ROOT).appendChild(document.createElement('script')).text = `(${queue => {
document.currentScript.remove();
for (const {id, code} of queue) {
const el = document.getElementById(id) ||
document.querySelector('style.stylus[id="' + id + '"]');
if (!el) continue;
const {disabled} = el.sheet;
el.textContent = code;
el.sheet.disabled = disabled;
}
}})(${JSON.stringify(pageContextQueue)})`;
} catch (e) {}
let failedSome;
for (const {el, code} of pageContextQueue) {
let settingStyle;
if (el.textContent !== code) {
el.textContent = code;
failedSome = true;
settingStyle = setStyleContent(el, code);
}
}
if (failedSome) {
console.debug('Could not set code of some styles in page context, ' +
'see https://github.com/openstyles/stylus/issues/461');
}
pageContextQueue.length = 0;
styleElements.set(id, el);
disabledElements.delete(id);
return Promise.resolve(settingStyle);
}
function addStyleElement(newElement) {
@ -371,34 +383,32 @@
if (next === newElement.nextElementSibling) {
return;
}
docRootObserver.evade(() => {
const insert = () => {
ROOT.insertBefore(newElement, next || null);
if (disableAll) {
newElement.disabled = true;
}
});
};
if (docRootObserver) {
docRootObserver.evade(insert);
} else {
insert();
}
}
function replaceAll(newStyles) {
if ('disableAll' in newStyles &&
disableAll === newStyles.disableAll &&
styleElements.size === countStylesInHash(newStyles) &&
[...styleElements.values()].every(el =>
el.disabled === disableAll &&
el.parentNode === ROOT &&
el.textContent === (newStyles[getStyleId(el)] || []).map(({code}) => code).join('\n'))) {
return;
}
const oldStyles = Array.prototype.slice.call(
document.querySelectorAll(`style.stylus[id^="${ID_PREFIX}"]`));
oldStyles.forEach(el => (el.id += '-ghost'));
styleElements.clear();
disabledElements.clear();
[...retiredStyleTimers.values()].forEach(clearTimeout);
retiredStyleTimers.clear();
applyStyles(newStyles);
docRootObserver.evade(() =>
oldStyles.forEach(el => el.remove()));
const removeOld = () => oldStyles.forEach(el => el.remove());
if (docRewriteObserver) {
docRootObserver.evade(removeOld);
} else {
removeOld();
}
}
function applyTransitionPatch() {
@ -408,11 +418,14 @@
const docId = document.documentElement.id ? '#' + document.documentElement.id : '';
document.documentElement.classList.add(className);
applySections(0, `
${docId}.${className}:root * {
${docId}.${CSS.escape(className)}:root * {
transition: none !important;
}
`);
setTimeout(() => {
`)
.then(() => {
// repaint
// eslint-disable-next-line no-unused-expressions
document.documentElement.offsetWidth;
removeStyle({id: 0});
document.documentElement.classList.remove(className);
});
@ -422,15 +435,10 @@
return parseInt(el.id.substr(ID_PREFIX.length));
}
function countStylesInHash(styleHash) {
let num = 0;
for (const k in styleHash) {
num += !isNaN(parseInt(k)) ? 1 : 0;
function orphanCheck(e) {
if (e && e.detail.method !== 'orphan') {
return;
}
return num;
}
function orphanCheck() {
if (chrome.i18n && chrome.i18n.getUILanguage()) {
return true;
}
@ -439,7 +447,7 @@
[docRewriteObserver, docRootObserver].forEach(ob => ob && ob.disconnect());
window.removeEventListener(chrome.runtime.id, orphanCheck, true);
try {
chrome.runtime.onMessage.removeListener(applyOnMessage);
msg.off(applyOnMessage);
} catch (e) {}
}

View File

@ -1,3 +1,4 @@
/* global API */
'use strict';
(() => {
@ -33,11 +34,10 @@
&& event.data.type === 'ouc-is-installed'
&& allowedOrigins.includes(event.origin)
) {
chrome.runtime.sendMessage({
method: 'findUsercss',
API.findUsercss({
name: event.data.name,
namespace: event.data.namespace
}, style => {
}).then(style => {
const data = {event};
const callbackObject = {
installed: Boolean(style),
@ -129,12 +129,10 @@
&& event.data.type === 'ouc-install-usercss'
&& allowedOrigins.includes(event.origin)
) {
chrome.runtime.sendMessage({
method: 'saveUsercss',
reason: 'install',
API.installUsercss({
name: event.data.title,
sourceCode: event.data.code,
}, style => {
}).then(style => {
sendInstallCallback({
enabled: style.enabled,
key: event.data.key

View File

@ -1,3 +1,4 @@
/* global API */
'use strict';
(() => {
@ -16,8 +17,8 @@
let sourceCode, port, timer;
chrome.runtime.onConnect.addListener(onConnected);
chrome.runtime.sendMessage({method: 'installUsercss', url}, r =>
r && r.__ERROR__ && alert(r.__ERROR__));
API.openUsercssInstallPage({url})
.catch(err => alert(err));
function onConnected(newPort) {
port = newPort;

View File

@ -1,4 +1,4 @@
/* global cloneInto */
/* global cloneInto msg API */
'use strict';
(() => {
@ -8,7 +8,7 @@
document.addEventListener('stylishInstallChrome', onClick);
document.addEventListener('stylishUpdateChrome', onClick);
chrome.runtime.onMessage.addListener(onMessage);
msg.on(onMessage);
onDOMready().then(() => {
window.postMessage({
@ -30,10 +30,9 @@
gotBody = true;
// TODO: remove the following statement when USO pagination title is fixed
document.title = document.title.replace(/^(\d+)&\w+=/, '#$1: ');
chrome.runtime.sendMessage({
method: 'getStyles',
API.findStyle({
md5Url: getMeta('stylish-md5-url') || location.href
}, checkUpdatability);
}).then(checkUpdatability);
}
if (document.getElementById('install_button')) {
onDOMready().then(() => {
@ -44,16 +43,14 @@
}
}
function onMessage(msg, sender, sendResponse) {
function onMessage(msg) {
switch (msg.method) {
case 'ping':
// orphaned content script check
sendResponse(true);
break;
return true;
case 'openSettings':
openSettings();
sendResponse(true);
break;
return true;
}
}
@ -69,7 +66,7 @@
return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : '');
}
function checkUpdatability([installedStyle]) {
function checkUpdatability(installedStyle) {
// TODO: remove the following statement when USO is fixed
document.dispatchEvent(new CustomEvent('stylusFixBuggyUSOsettings', {
detail: installedStyle && installedStyle.updateUrl,
@ -148,10 +145,9 @@
function onUpdate() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
method: 'getStyles',
md5Url: getMeta('stylish-md5-url') || location.href,
}, ([style]) => {
API.findStyle({
md5Url: getMeta('stylish-md5-url') || location.href
}, true).then(style => {
saveStyleCode('styleUpdate', style.name, {id: style.id})
.then(resolve, reject);
});
@ -160,35 +156,26 @@
function saveStyleCode(message, name, addProps) {
return new Promise((resolve, reject) => {
const isNew = message === 'styleInstall';
const needsConfirmation = isNew || !saveStyleCode.confirmed;
if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
reject();
return;
return Promise.reject();
}
saveStyleCode.confirmed = true;
enableUpdateButton(false);
getStyleJson().then(json => {
return getStyleJson().then(json => {
if (!json) {
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
'https://github.com/openstyles/stylus/issues/195');
return;
}
chrome.runtime.sendMessage(
Object.assign(json, addProps, {
method: 'saveStyle',
reason: isNew ? 'install' : 'update',
}),
style => {
return API.installStyle(Object.assign(json, addProps))
.then(style => {
if (!isNew && style.updateUrl.includes('?')) {
enableUpdateButton(true);
} else {
sendEvent({type: 'styleInstalledChrome'});
}
}
);
resolve();
});
});
@ -216,25 +203,18 @@
function getResource(url, options) {
return new Promise(resolve => {
if (url.startsWith('#')) {
resolve(document.getElementById(url.slice(1)).textContent);
} else {
chrome.runtime.sendMessage(Object.assign({
return Promise.resolve(document.getElementById(url.slice(1)).textContent);
}
return API.download(Object.assign({
url,
method: 'download',
timeout: 60e3,
// USO can't handle POST requests for style json
body: null,
}, options), result => {
const error = result && result.__ERROR__;
if (error) {
}, options))
.catch(error => {
alert('Error' + (error ? '\n' + error : ''));
} else {
resolve(result);
}
});
}
throw error;
});
}
@ -257,12 +237,12 @@
if (codeElement && !codeElement.textContent.trim()) {
return style;
}
return getResource(getMeta('stylish-update-url')).then(code => new Promise(resolve => {
chrome.runtime.sendMessage({method: 'parseCss', code}, ({sections}) => {
style.sections = sections;
resolve(style);
return getResource(getMeta('stylish-update-url'))
.then(code => API.parseCss({code}))
.then(result => {
style.sections = result.sections;
return style;
});
}));
})
.then(tryFixMd5)
.catch(() => null);
@ -349,7 +329,7 @@
document.removeEventListener('stylishInstallChrome', onClick);
document.removeEventListener('stylishUpdateChrome', onClick);
try {
chrome.runtime.onMessage.removeListener(onMessage);
msg.off(onMessage);
} catch (e) {}
}
})();

View File

@ -18,26 +18,6 @@
}
</style>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/prefs.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/apply.js"></script>
<script src="edit/util.js"></script>
<script src="edit/regexp-tester.js"></script>
<script src="edit/applies-to-line-widget.js"></script>
<script src="edit/source-editor.js"></script>
<script src="edit/colorpicker-helper.js"></script>
<script src="edit/beautify.js"></script>
<script src="edit/sections.js"></script>
<script src="edit/show-keymap-help.js"></script>
<script src="edit/codemirror-editing-hooks.js"></script>
<script src="edit/edit.js"></script>
<script src="msgbox/msgbox.js" async></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<script src="vendor/codemirror/lib/codemirror.js"></script>
@ -46,6 +26,8 @@
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<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>
@ -80,6 +62,18 @@
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
<script src="js/promisify.js"></script>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/prefs.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="js/msg.js"></script>
<script src="js/worker-util.js"></script>
<script src="content/apply.js"></script>
<link href="edit/global-search.css" rel="stylesheet">
<script src="edit/global-search.js"></script>
@ -88,6 +82,25 @@
<link href="edit/codemirror-default.css" rel="stylesheet">
<script src="edit/codemirror-default.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>
<script src="edit/codemirror-factory.js"></script>
<script src="edit/colorpicker-helper.js"></script>
<script src="edit/beautify.js"></script>
<script src="edit/show-keymap-help.js"></script>
<script src="edit/refresh-on-view.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="edit/edit.js"></script>
<script src="msgbox/msgbox.js" async></script>
<script src="edit/linter.js"></script>
<script src="edit/linter-defaults.js"></script>
<script src="edit/linter-engines.js"></script>
@ -96,8 +109,6 @@
<script src="edit/linter-report.js"></script>
<script src="edit/linter-config-dialog.js"></script>
<script src="edit/editor-worker.js"></script>
<link id="cm-theme" rel="stylesheet">
<template data-id="appliesTo">
@ -133,8 +144,11 @@
<template data-id="section">
<div class="section">
<!-- not using DIV to make our CSS work for #sections > div:only-of-type .remove-section -->
<p class="deleted-section">
<button class="restore-section" i18n-text="sectionRestore"></button>
</p>
<label i18n-text="sectionCode" class="code-label"></label>
<br>
<div class="applies-to">
<label i18n-text="appliesLabel">
<a href="#" class="svg-inline-wrapper applies-to-help" tabindex="0">
@ -155,13 +169,6 @@
</div>
</template>
<!-- not using DIV to make our CSS work for #sections > div:only-of-type .remove-section -->
<template data-id="deletedSection">
<p class="deleted-section">
<button class="restore-section" i18n-text="sectionRestore"></button>
</p>
</template>
<template data-id="searchReplaceDialog">
<div id="search-replace-dialog">
<div data-type="main">
@ -277,7 +284,7 @@
<h1 id="heading">&nbsp;</h1> <!-- nbsp allocates the actual height which prevents page shift -->
<section id="basic-info">
<div id="basic-info-name">
<input id="name" class="style-contributor" spellcheck="false">
<input id="name" class="style-contributor" spellcheck="false" required>
<a id="url" target="_blank"><svg class="svg-icon"><use xlink:href="#svg-icon-external-link"/></svg></a>
</div>
<div id="basic-info-enabled">
@ -437,11 +444,15 @@
</div>
</div>
<section id="sections">
<h2><span id="sections-heading" i18n-text="styleSectionsTitle"></span>
<!--
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>
</h2> -->
</section>
<div id="help-popup">
<div class="title"></div><svg id="sections-help" class="svg-icon dismiss"><use xlink:href="#svg-icon-close"/></svg>

View File

@ -1,4 +1,6 @@
/* global regExpTester debounce messageBox CodeMirror template colorMimicry */
/* global regExpTester debounce messageBox CodeMirror template colorMimicry msg
$ $create t prefs tryCatch */
/* exported createAppliesToLineWidget */
'use strict';
function createAppliesToLineWidget(cm) {
@ -131,7 +133,7 @@ function createAppliesToLineWidget(cm) {
cm.on('change', onChange);
cm.on('optionChange', onOptionChange);
chrome.runtime.onMessage.addListener(onRuntimeMessage);
msg.onExtension(onRuntimeMessage);
requestAnimationFrame(updateWidgetStyle);
update();
@ -144,7 +146,7 @@ function createAppliesToLineWidget(cm) {
widgets.length = 0;
cm.off('change', onChange);
cm.off('optionChange', onOptionChange);
chrome.runtime.onMessage.removeListener(onRuntimeMessage);
msg.off(onRuntimeMessage);
actualStyle.remove();
}

View File

@ -1,10 +1,8 @@
/*
global CodeMirror loadScript css_beautify
global editors getSectionForChild showHelp
*/
/* global loadScript css_beautify showHelp prefs t $ $create */
/* exported beautify */
'use strict';
function beautify(event) {
function beautify(scope) {
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
.then(() => {
if (!window.css_beautify && window.exports) {
@ -22,9 +20,6 @@ function beautify(event) {
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
options.indent_char = tabs ? '\t' : ' ';
const section = getSectionForChild(event.target);
const scope = section ? [section.CodeMirror] : editors;
showHelp(t('styleBeautify'),
$create([
$create('.beautify-options', [

View File

@ -1,4 +1,4 @@
/* global CodeMirror prefs loadScript editor editors */
/* global CodeMirror prefs loadScript editor $ template */
'use strict';
@ -9,6 +9,7 @@
}
const defaults = {
autoCloseBrackets: prefs.get('editor.autoCloseBrackets'),
mode: 'css',
lineNumbers: true,
lineWrapping: prefs.get('editor.lineWrapping'),
@ -19,11 +20,10 @@
...(prefs.get('editor.linter') ? ['CodeMirror-lint-markers'] : []),
],
matchBrackets: true,
highlightSelectionMatches: {showToken: /[#.\-\w]/, annotateScrollbar: true},
hintOptions: {},
lintReportDelay: prefs.get('editor.lintReportDelay'),
styleActiveLine: true,
theme: 'default',
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'),
extraKeys: Object.assign(CodeMirror.defaults.extraKeys || {}, {
// independent of current keyMap
@ -228,69 +228,48 @@
return isBlank;
});
// doubleclick option
if (typeof editors !== 'undefined') {
const fn = (cm, repeat) =>
repeat === 'double' ?
{unit: selectTokenOnDoubleclick} :
{};
const configure = (_, enabled) => {
editors.forEach(cm => cm.setOption('configureMouse', enabled ? fn : null));
CodeMirror.defaults.configureMouse = enabled ? fn : null;
};
configure(null, prefs.get('editor.selectByTokens'));
prefs.subscribe(['editor.selectByTokens'], configure);
// editor commands
for (const name of ['save', 'toggleStyle', 'nextEditor', 'prevEditor']) {
CodeMirror.commands[name] = () => editor[name]();
}
function selectTokenOnDoubleclick(cm, pos) {
let {ch} = pos;
const {line, sticky} = pos;
const {text, styles} = cm.getLineHandle(line);
// CodeMirror convenience commands
Object.assign(CodeMirror.commands, {
toggleEditorFocus,
jumpToLine,
commentSelection,
});
const execAt = (rx, i) => (rx.lastIndex = i) && null || rx.exec(text);
const at = (rx, i) => (rx.lastIndex = i) && null || rx.test(text);
const atWord = ch => at(/\w/y, ch);
const atSpace = ch => at(/\s/y, ch);
const atTokenEnd = styles.indexOf(ch, 1);
ch += atTokenEnd < 0 ? 0 : sticky === 'before' && atWord(ch - 1) ? 0 : atSpace(ch + 1) ? 0 : 1;
ch = Math.min(text.length, ch);
const type = cm.getTokenTypeAt({line, ch: ch + (sticky === 'after' ? 1 : 0)});
if (atTokenEnd > 0) ch--;
const isCss = type && !/^(comment|string)/.test(type);
const isNumber = type === 'number';
const isSpace = atSpace(ch);
let wordChars =
isNumber ? /[-+\w.%]/y :
isCss ? /[-\w@]/y :
isSpace ? /\s/y :
atWord(ch) ? /\w/y : /[^\w\s]/y;
let a = ch;
while (a && at(wordChars, a)) a--;
a += !a && at(wordChars, a) || isCss && at(/[.!#@]/y, a) ? 0 : at(wordChars, a + 1);
let b, found;
if (isNumber) {
b = a + execAt(/[+-]?[\d.]+(e\d+)?|$/yi, a)[0].length;
found = b >= ch;
if (!found) {
a = b;
ch = a;
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});
}
if (!found) {
wordChars = isCss ? /[-\w]*/y : new RegExp(wordChars.source + '*', 'uy');
b = ch + execAt(wordChars, ch)[0].length;
function commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
}
return {
from: {line, ch: a},
to: {line, ch: b},
};
function toggleEditorFocus(cm) {
if (!cm) return;
if (cm.hasFocus()) {
setTimeout(() => cm.display.input.blur());
} else {
cm.focus();
}
}
})();
@ -371,8 +350,9 @@ CodeMirror.hint && (() => {
}
// USO vars in usercss mode editor
const list = Object.keys(editor.getStyle().usercssData.vars)
.filter(name => name.startsWith(leftPart));
const vars = editor.getStyle().usercssData.vars;
const list = vars ?
Object.keys(vars).filter(name => name.startsWith(leftPart)) : [];
return {
list,
from: {line, ch: prev},

View File

@ -1,698 +0,0 @@
/*
global CodeMirror loadScript
global editors editor styleId ownTabId
global save toggleStyle setupAutocomplete makeSectionVisible getSectionForChild
global getSectionsHashes
global messageBox
*/
'use strict';
onDOMscriptReady('/codemirror.js').then(() => {
const COMMANDS = {
save,
toggleStyle,
toggleEditorFocus,
jumpToLine,
nextEditor, prevEditor,
commentSelection,
};
const ORIGINAL_COMMANDS = {
insertTab: CodeMirror.commands.insertTab,
};
// reroute handling to nearest editor when keypress resolves to one of these commands
const REROUTED = new Set([
'save',
'toggleStyle',
'jumpToLine',
'nextEditor', 'prevEditor',
'toggleEditorFocus',
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
'colorpicker',
]);
Object.assign(CodeMirror, {
getOption,
setOption,
closestVisible,
});
Object.assign(CodeMirror.prototype, {
getSection,
rerouteHotkeys,
});
CodeMirror.defineInitHook(cm => {
if (!cm.display.wrapper.closest('#sections')) {
return;
}
if (prefs.get('editor.livePreview') && styleId) {
cm.on('changes', updatePreview);
}
if (prefs.get('editor.autocompleteOnTyping')) {
setupAutocomplete(cm);
}
const wrapper = cm.display.wrapper;
cm.on('blur', () => {
editors.lastActive = cm;
cm.rerouteHotkeys(true);
setTimeout(() => {
wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement));
});
});
cm.on('focus', () => {
cm.rerouteHotkeys(false);
wrapper.classList.add('CodeMirror-active');
});
});
new MutationObserver((mutations, observer) => {
if (!$('#sections')) {
return;
}
observer.disconnect();
prefs.subscribe(['editor.keyMap'], showHotkeyInTooltip);
addEventListener('showHotkeyInTooltip', showHotkeyInTooltip);
showHotkeyInTooltip();
// N.B. the onchange event listeners should be registered before setupLivePrefs()
$('#options').addEventListener('change', onOptionElementChanged);
setupLivePreview();
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
Object.assign(CodeMirror.commands, COMMANDS);
rerouteHotkeys(true);
}).observe(document, {childList: true, subtree: true});
return;
////////////////////////////////////////////////
function getOption(o) {
return CodeMirror.defaults[o];
}
function setOption(o, v) {
CodeMirror.defaults[o] = v;
if (editors.length > 4 && (o === 'theme' || o === 'lineWrapping')) {
throttleSetOption({key: o, value: v, index: 0});
return;
}
editors.forEach(editor => {
editor.setOption(o, v);
});
}
function throttleSetOption({
key,
value,
index,
timeStart = performance.now(),
cmStart = editors.lastActive || editors[0],
editorsCopy = editors.slice(),
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 ||
cm !== editors[index] && !editors.includes(cm)) {
continue;
}
cm.setOption(key, value);
if (performance.now() - t0 > THROTTLE_AFTER_MS) {
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 getSection() {
return this.display.wrapper.parentNode;
}
function nextEditor(cm) {
return nextPrevEditor(cm, 1);
}
function prevEditor(cm) {
return nextPrevEditor(cm, -1);
}
function nextPrevEditor(cm, direction) {
cm = editors[(editors.indexOf(cm) + direction + editors.length) % editors.length];
makeSectionVisible(cm);
cm.focus();
return cm;
}
function jumpToLine(cm) {
const cur = cm.getCursor();
refocusMinidialog(cm);
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();
}
}
function refocusMinidialog(cm) {
const section = cm.getSection();
if (!$('.CodeMirror-dialog', section)) {
return;
}
// close the currently opened minidialog
cm.focus();
// make sure to focus the input in newly opened minidialog
setTimeout(() => {
$('.CodeMirror-dialog', section).focus();
});
}
function onOptionElementChanged(event) {
const el = event.target;
let option = el.id.replace(/^editor\./, '');
if (!option) {
console.error('no "cm_option"', el);
return;
}
let value = el.type === 'checkbox' ? el.checked : el.value;
switch (option) {
case 'tabSize':
value = Number(value);
CodeMirror.setOption('indentUnit', value);
break;
case 'indentWithTabs':
CodeMirror.commands.insertTab = value ?
ORIGINAL_COMMANDS.insertTab :
CodeMirror.commands.insertSoftTab;
break;
case 'theme': {
const themeLink = $('#cm-theme');
// use non-localized 'default' internally
if (!value || value === 'default' || value === t('defaultTheme')) {
value = 'default';
if (prefs.get(el.id) !== value) {
prefs.set(el.id, value);
}
themeLink.href = '';
el.selectedIndex = 0;
break;
}
const url = chrome.runtime.getURL('vendor/codemirror/theme/' + value + '.css');
if (themeLink.href === url) {
// preloaded in initCodeMirror()
break;
}
// avoid flicker: wait for the second stylesheet to load, then apply the theme
document.head.appendChild($create('link#cm-theme2', {rel: 'stylesheet', href: url}));
setTimeout(() => {
CodeMirror.setOption(option, value);
themeLink.remove();
$('#cm-theme2').id = 'cm-theme';
}, 100);
return;
}
case 'autocompleteOnTyping':
editors.forEach(cm => setupAutocomplete(cm, el.checked));
return;
case 'autoCloseBrackets':
Promise.resolve(value && loadScript('/vendor/codemirror/addon/edit/closebrackets.js')).then(() => {
CodeMirror.setOption(option, value);
});
return;
case 'matchHighlight':
switch (value) {
case 'token':
case 'selection':
document.body.dataset[option] = value;
value = {showToken: value === 'token' && /[#.\-\w]/, annotateScrollbar: true};
break;
default:
value = null;
}
option = 'highlightSelectionMatches';
break;
case 'colorpicker':
return;
}
CodeMirror.setOption(option, value);
}
function buildThemeElement() {
const themeElement = $('#editor.theme');
const themeList = localStorage.codeMirrorThemes;
const optionsFromArray = options => {
const fragment = document.createDocumentFragment();
options.forEach(opt => fragment.appendChild($create('option', opt)));
themeElement.appendChild(fragment);
};
if (themeList) {
optionsFromArray(themeList.split(/\s+/));
} else {
// Chrome is starting up and shows our edit.html, but the background page isn't loaded yet
const theme = prefs.get('editor.theme');
optionsFromArray([theme === 'default' ? t('defaultTheme') : theme]);
getCodeMirrorThemes().then(() => {
const themes = (localStorage.codeMirrorThemes || '').split(/\s+/);
optionsFromArray(themes);
themeElement.selectedIndex = Math.max(0, themes.indexOf(theme));
});
}
}
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 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 rerouteHotkeys(enable, immediately) {
if (!immediately) {
debounce(rerouteHotkeys, 0, enable, true);
} else if (enable) {
document.addEventListener('keydown', rerouteHandler);
} else {
document.removeEventListener('keydown', rerouteHandler);
}
}
function rerouteHandler(event) {
const keyName = CodeMirror.keyName(event);
if (!keyName) {
return;
}
const rerouteCommand = name => {
if (REROUTED.has(name)) {
CodeMirror.commands[name](closestVisible(event.target));
return true;
}
};
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
event.preventDefault();
event.stopPropagation();
}
}
////////////////////////////////////////////////
// priority:
// 1. associated CM for applies-to element
// 2. last active if visible
// 3. first visible
function closestVisible(nearbyElement) {
const cm =
nearbyElement instanceof CodeMirror ? nearbyElement :
nearbyElement instanceof Node && (getSectionForChild(nearbyElement) || {}).CodeMirror ||
editors.lastActive;
if (nearbyElement instanceof Node && cm) {
const {left, top} = nearbyElement.getBoundingClientRect();
const bounds = cm.display.wrapper.getBoundingClientRect();
if (top >= 0 && top >= bounds.top &&
left >= 0 && left >= bounds.left) {
return cm;
}
}
// closest editor should have at least 2 lines visible
const lineHeight = editors[0].defaultTextHeight();
const scrollY = window.scrollY;
const windowBottom = scrollY + window.innerHeight - 2 * lineHeight;
const allSectionsContainerTop = scrollY + $('#sections').getBoundingClientRect().top;
const distances = [];
const alreadyInView = cm && offscreenDistance(null, cm) === 0;
return alreadyInView ? cm : findClosest();
function offscreenDistance(index, cm) {
if (index >= 0 && distances[index] !== undefined) {
return distances[index];
}
const section = (cm || editors[index]).getSection();
if (!section) {
return 1e9;
}
const top = allSectionsContainerTop + section.offsetTop;
if (top < scrollY + lineHeight) {
return Math.max(0, scrollY - top - lineHeight);
}
if (top < windowBottom) {
return 0;
}
const distance = top - windowBottom + section.offsetHeight;
if (index >= 0) {
distances[index] = distance;
}
return distance;
}
function findClosest() {
const last = editors.length - 1;
let a = 0;
let b = last;
let c;
let distance;
while (a < b - 1) {
c = (a + b) / 2 | 0;
distance = offscreenDistance(c);
if (!distance || !c) {
break;
}
const distancePrev = offscreenDistance(c - 1);
const distanceNext = c < last ? offscreenDistance(c + 1) : 1e20;
if (distancePrev <= distance && distance <= distanceNext) {
b = c;
} else {
a = c;
}
}
while (b && offscreenDistance(b - 1) <= offscreenDistance(b)) {
b--;
}
const cm = editors[b];
if (distances[b] > 0) {
makeSectionVisible(cm);
}
return cm;
}
}
////////////////////////////////////////////////
function getCodeMirrorThemes() {
if (!chrome.runtime.getPackageDirectoryEntry) {
const themes = [
chrome.i18n.getMessage('defaultTheme'),
/* populate-theme-start */
'3024-day',
'3024-night',
'abcdef',
'ambiance',
'ambiance-mobile',
'base16-dark',
'base16-light',
'bespin',
'blackboard',
'cobalt',
'colorforth',
'darcula',
'dracula',
'duotone-dark',
'duotone-light',
'eclipse',
'elegant',
'erlang-dark',
'gruvbox-dark',
'hopscotch',
'icecoder',
'idea',
'isotope',
'lesser-dark',
'liquibyte',
'lucario',
'material',
'mbo',
'mdn-like',
'midnight',
'monokai',
'neat',
'neo',
'night',
'oceanic-next',
'panda-syntax',
'paraiso-dark',
'paraiso-light',
'pastel-on-dark',
'railscasts',
'rubyblue',
'seti',
'shadowfox',
'solarized',
'ssms',
'the-matrix',
'tomorrow-night-bright',
'tomorrow-night-eighties',
'ttcn',
'twilight',
'vibrant-ink',
'xq-dark',
'xq-light',
'yeti',
'zenburn',
/* populate-theme-end */
];
localStorage.codeMirrorThemes = themes.join(' ');
return Promise.resolve(themes);
}
return new Promise(resolve => {
chrome.runtime.getPackageDirectoryEntry(rootDir => {
rootDir.getDirectory('vendor/codemirror/theme', {create: false}, themeDir => {
themeDir.createReader().readEntries(entries => {
const themes = [
chrome.i18n.getMessage('defaultTheme')
].concat(
entries.filter(entry => entry.isFile)
.sort((a, b) => (a.name < b.name ? -1 : 1))
.map(entry => entry.name.replace(/\.css$/, ''))
);
localStorage.codeMirrorThemes = themes.join(' ');
resolve(themes);
});
});
});
});
}
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 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 setupLivePreview() {
if (!prefs.get('editor.livePreview') && !editors.length) {
setTimeout(setupLivePreview);
return;
}
if (styleId) {
$('#editor.livePreview').onchange = livePreviewToggled;
return;
}
// wait for #preview-label's class to lose 'hidden' after the first save
new MutationObserver((_, observer) => {
if (!styleId) return;
observer.disconnect();
setupLivePreview();
livePreviewToggled();
}).observe($('#preview-label'), {
attributes: true,
attributeFilter: ['class'],
});
}
function livePreviewToggled() {
const me = this instanceof Node ? this : $('#editor.livePreview');
const previewing = me.checked;
editors.forEach(cm => cm[previewing ? 'on' : 'off']('changes', updatePreview));
const addRemove = EventTarget.prototype[previewing ? 'addEventListener' : 'removeEventListener'];
addRemove.call($('#enabled'), 'change', updatePreview);
if (!editor) {
for (const el of $$('#sections .applies-to')) {
addRemove.call(el, 'input', updatePreview);
}
toggleLivePreviewSectionsObserver(previewing);
}
if (!previewing || document.body.classList.contains('dirty')) {
updatePreview(null, previewing);
}
}
/**
* Observes newly added section elements, and sets these event listeners:
* 1. 'changes' on CodeMirror inside
* 2. 'input' on .applies-to inside
* The goal is to avoid listening to 'input' on the entire #sections tree,
* which would trigger updatePreview() twice on any keystroke -
* both for the synthetic event from CodeMirror and the original event.
* Side effects:
* two expando properties on #sections
* 1. __livePreviewObserver
* 2. __livePreviewObserverEnabled
* @param {Boolean} enable
*/
function toggleLivePreviewSectionsObserver(enable) {
const sections = $('#sections');
const observing = sections.__livePreviewObserverEnabled;
let mo = sections.__livePreviewObserver;
if (enable && !mo) {
sections.__livePreviewObserver = mo = new MutationObserver(mutations => {
for (const {addedNodes} of mutations) {
for (const node of addedNodes) {
const el = node.children && $('.applies-to', node);
if (el) el.addEventListener('input', updatePreview);
if (node.CodeMirror) node.CodeMirror.on('changes', updatePreview);
}
}
});
}
if (enable && !observing) {
mo.observe(sections, {childList: true});
sections.__livePreviewObserverEnabled = true;
} else if (!enable && observing) {
mo.disconnect();
sections.__livePreviewObserverEnabled = false;
}
}
function updatePreview(data, previewing) {
if (previewing !== true && previewing !== false) {
if (data instanceof Event && !data.target.matches('.style-contributor')) return;
debounce(updatePreview, data && data.id === 'enabled' ? 0 : 400, null, true);
return;
}
const errors = $('#preview-errors');
API.refreshAllTabs({
reason: 'editPreview',
tabId: ownTabId,
style: {
id: styleId,
enabled: $('#enabled').checked,
sections: previewing && (editor ? editors[0].getValue() : getSectionsHashes()),
},
}).then(() => {
errors.classList.add('hidden');
}).catch(err => {
if (Array.isArray(err)) err = err.join('\n');
if (err && editor && !isNaN(err.index)) {
const pos = editors[0].posFromIndex(err.index);
err = `${pos.line}:${pos.ch} ${err}`;
}
errors.classList.remove('hidden');
errors.onclick = () => messageBox.alert(String(err), 'pre');
});
}
});

287
edit/codemirror-factory.js Normal file
View File

@ -0,0 +1,287 @@
/* global CodeMirror loadScript rerouteHotkeys prefs $ debounce $create */
/* exported cmFactory */
'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.
*/
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));
});
CodeMirror.defineOption('indentWithTabs', prefs.get('editor.indentWithTabs'), (cm, value) => {
CodeMirror.commands.insertTab = value ?
INSERT_TAB_COMMAND :
INSERT_SOFT_TAB_COMMAND;
});
CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'), (cm, value) => {
const onOff = value ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked);
});
CodeMirror.defineOption('matchHighlight', prefs.get('editor.matchHighlight'), (cm, value) => {
if (value === 'token') {
cm.setOption('highlightSelectionMatches', {
showToken: /[#.\-\w]/,
annotateScrollbar: true
});
} else if (value === 'selection') {
cm.setOption('highlightSelectionMatches', {
showToken: false,
annotateScrollbar: true
});
} else {
cm.setOption('highlightSelectionMatches', null);
}
});
CodeMirror.defineOption('selectByTokens', prefs.get('editor.selectByTokens'), (cm, value) => {
cm.setOption('configureMouse', value ? configureMouseFn : null);
});
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 = '';
} 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';
});
}
}
}
// broadcast option
setOption(option, value);
});
return {create, destroy, setOption};
function configureMouseFn(cm, repeat) {
return repeat === 'double' ?
{unit: selectTokenOnDoubleclick} :
{};
}
function selectTokenOnDoubleclick(cm, pos) {
let {ch} = pos;
const {line, sticky} = pos;
const {text, styles} = cm.getLineHandle(line);
const execAt = (rx, i) => (rx.lastIndex = i) && null || rx.exec(text);
const at = (rx, i) => (rx.lastIndex = i) && null || rx.test(text);
const atWord = ch => at(/\w/y, ch);
const atSpace = ch => at(/\s/y, ch);
const atTokenEnd = styles.indexOf(ch, 1);
ch += atTokenEnd < 0 ? 0 : sticky === 'before' && atWord(ch - 1) ? 0 : atSpace(ch + 1) ? 0 : 1;
ch = Math.min(text.length, ch);
const type = cm.getTokenTypeAt({line, ch: ch + (sticky === 'after' ? 1 : 0)});
if (atTokenEnd > 0) ch--;
const isCss = type && !/^(comment|string)/.test(type);
const isNumber = type === 'number';
const isSpace = atSpace(ch);
let wordChars =
isNumber ? /[-+\w.%]/y :
isCss ? /[-\w@]/y :
isSpace ? /\s/y :
atWord(ch) ? /\w/y : /[^\w\s]/y;
let a = ch;
while (a && at(wordChars, a)) a--;
a += !a && at(wordChars, a) || isCss && at(/[.!#@]/y, a) ? 0 : at(wordChars, a + 1);
let b, found;
if (isNumber) {
b = a + execAt(/[+-]?[\d.]+(e\d+)?|$/yi, a)[0].length;
found = b >= ch;
if (!found) {
a = b;
ch = a;
}
}
if (!found) {
wordChars = isCss ? /[-\w]*/y : new RegExp(wordChars.source + '*', 'uy');
b = ch + execAt(wordChars, ch)[0].length;
}
return {
from: {line, ch: a},
to: {line, ch: b},
};
}
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;
}
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) {
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,
});
}
})();

View File

@ -1,7 +1,7 @@
/* global CodeMirror loadScript editors showHelp */
/* global CodeMirror showHelp cmFactory onDOMready $ $create prefs t */
'use strict';
onDOMscriptReady('/colorview.js').then(() => {
(() => {
onDOMready().then(() => {
$('#colorpicker-settings').onclick = configureColorpicker;
});
@ -20,7 +20,8 @@ onDOMscriptReady('/colorview.js').then(() => {
defaults.extraKeys[keyName] = 'colorpicker';
}
defaults.colorpicker = {
forceUpdate: editors.length > 0,
// FIXME: who uses this?
// forceUpdate: editor.getEditors().length > 0,
tooltip: t('colorpickerTooltip'),
popup: {
tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'),
@ -38,8 +39,7 @@ onDOMscriptReady('/colorview.js').then(() => {
delete defaults.extraKeys[keyName];
}
}
// on page load runs before CodeMirror.setOption is defined
editors.forEach(cm => cm.setOption('colorpicker', defaults.colorpicker));
cmFactory.setOption('colorpicker', defaults.colorpicker);
}
function registerHotkey(id, hotkey) {
@ -112,4 +112,4 @@ onDOMscriptReady('/colorview.js').then(() => {
}
input.focus();
}
});
})();

View File

@ -265,13 +265,10 @@ input:invalid {
}
/************ content ***********/
#sections > * {
margin: 0.7rem;
padding: 1rem 1rem .3rem;
margin: 0 0.7rem;
padding: 1rem;
}
#sections > *:first-child {
padding: 0 1rem .3rem;
}
#sections > *:not(:first-child) {
#sections > :not(:first-child) {
border-top: 2px solid hsl(0, 0%, 80%);
}
.add-section:after {
@ -288,7 +285,7 @@ input:invalid {
flex-wrap: wrap;
}
.edit-actions button {
margin: 0 .2rem .5rem 0;
margin-right: .2rem;
}
.dirty > label::before {
content: "*";
@ -312,6 +309,25 @@ input:invalid {
.section:only-of-type .move-section-down {
display: none;
}
.section .CodeMirror {
margin-bottom: .875rem;
}
/* deleted section */
.deleted-section {
margin: 0;
}
.section .deleted-section {
display: none;
}
.section.removed .deleted-section {
display: block;
}
.section.removed .code-label,
.section.removed .applies-to,
.section.removed .edit-actions,
.section.removed .CodeMirror {
display: none;
}
.move-section-up:after {
content: "";
display: block;

View File

@ -1,82 +1,244 @@
/*
global CodeMirror loadScript
global createSourceEditor
global closeCurrentTab regExpTester messageBox
global setupCodeMirror
global beautify
global initWithSectionStyle addSections removeSection getSectionsHashes
global sectionsToMozFormat
global moveFocus editorWorker
*/
/* global CodeMirror onDOMready prefs setupLivePrefs $ $$ $create t tHTML
createSourceEditor queryTabs sessionStorageHash getOwnTab FIREFOX API tryCatch
closeCurrentTab messageBox debounce workerUtil
beautify ignoreChromeError
moveFocus msg createSectionsEditor rerouteHotkeys */
/* exported showCodeMirrorPopup editorWorker toggleContextMenuDelete */
'use strict';
let styleId = null;
// only the actually dirty items here
let dirty = {};
// array of all CodeMirror instances
const editors = [];
const editorWorker = workerUtil.createWorker({
url: '/edit/editor-worker.js'
});
let saveSizeOnClose;
let ownTabId;
// direct & reverse mapping of @-moz-document keywords and internal property names
const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'};
const CssToProperty = {'url': 'urls', 'url-prefix': 'urlPrefixes', 'domain': 'domains', 'regexp': 'regexps'};
const CssToProperty = Object.entries(propertyToCss)
.reduce((o, v) => {
o[v[1]] = v[0];
return o;
}, {});
let editor;
document.addEventListener('visibilitychange', beforeUnload);
chrome.runtime.onMessage.addListener(onRuntimeMessage);
window.addEventListener('beforeunload', beforeUnload);
msg.onExtension(onRuntimeMessage);
preinit();
Promise.all([
(() => {
onDOMready().then(() => {
prefs.subscribe(['editor.keyMap'], showHotkeyInTooltip);
addEventListener('showHotkeyInTooltip', showHotkeyInTooltip);
showHotkeyInTooltip();
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
});
initEditor();
function getCodeMirrorThemes() {
if (!chrome.runtime.getPackageDirectoryEntry) {
const themes = [
chrome.i18n.getMessage('defaultTheme'),
/* populate-theme-start */
'3024-day',
'3024-night',
'abcdef',
'ambiance',
'ambiance-mobile',
'base16-dark',
'base16-light',
'bespin',
'blackboard',
'cobalt',
'colorforth',
'darcula',
'dracula',
'duotone-dark',
'duotone-light',
'eclipse',
'elegant',
'erlang-dark',
'gruvbox-dark',
'hopscotch',
'icecoder',
'idea',
'isotope',
'lesser-dark',
'liquibyte',
'lucario',
'material',
'mbo',
'mdn-like',
'midnight',
'monokai',
'neat',
'neo',
'night',
'oceanic-next',
'panda-syntax',
'paraiso-dark',
'paraiso-light',
'pastel-on-dark',
'railscasts',
'rubyblue',
'seti',
'shadowfox',
'solarized',
'ssms',
'the-matrix',
'tomorrow-night-bright',
'tomorrow-night-eighties',
'ttcn',
'twilight',
'vibrant-ink',
'xq-dark',
'xq-light',
'yeti',
'zenburn',
/* populate-theme-end */
];
localStorage.codeMirrorThemes = themes.join(' ');
return Promise.resolve(themes);
}
return new Promise(resolve => {
chrome.runtime.getPackageDirectoryEntry(rootDir => {
rootDir.getDirectory('vendor/codemirror/theme', {create: false}, themeDir => {
themeDir.createReader().readEntries(entries => {
const themes = [
chrome.i18n.getMessage('defaultTheme')
].concat(
entries.filter(entry => entry.isFile)
.sort((a, b) => (a.name < b.name ? -1 : 1))
.map(entry => entry.name.replace(/\.css$/, ''))
);
localStorage.codeMirrorThemes = themes.join(' ');
resolve(themes);
});
});
});
});
}
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 buildThemeElement() {
const themeElement = $('#editor.theme');
const themeList = localStorage.codeMirrorThemes;
const optionsFromArray = options => {
const fragment = document.createDocumentFragment();
options.forEach(opt => fragment.appendChild($create('option', opt)));
themeElement.appendChild(fragment);
};
if (themeList) {
optionsFromArray(themeList.split(/\s+/));
} else {
// Chrome is starting up and shows our edit.html, but the background page isn't loaded yet
const theme = prefs.get('editor.theme');
optionsFromArray([theme === 'default' ? t('defaultTheme') : theme]);
getCodeMirrorThemes().then(() => {
const themes = (localStorage.codeMirrorThemes || '').split(/\s+/);
optionsFromArray(themes);
themeElement.selectedIndex = Math.max(0, themes.indexOf(theme));
});
}
}
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 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 initEditor() {
return Promise.all([
initStyleData(),
onDOMready(),
prefs.initializing,
])
.then(([style]) => {
const usercss = isUsercss(style);
$('#heading').textContent = t(styleId ? 'editStyleHeading' : 'addStyleTitle');
$('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#name').placeholder = t(usercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
$('#name').title = usercss ? t('usercssReplaceTemplateName') : '';
$('#preview-label').classList.toggle('hidden', !styleId);
$('#preview-label').classList.toggle('hidden', !style.id);
$('#beautify').onclick = beautify;
$('#beautify').onclick = () => beautify(editor.getEditors());
$('#lint').addEventListener('scroll', hideLintHeaderOnScroll, {passive: true});
window.addEventListener('resize', () => debounce(rememberWindowSize, 100));
if (usercss) {
editor = createSourceEditor(style);
} else {
initWithSectionStyle(style);
document.addEventListener('wheel', scrollEntirePageOnCtrlShift);
editor = usercss ? createSourceEditor(style) : createSectionsEditor(style);
if (editor.ready) {
return editor.ready();
}
});
}
})();
function preinit() {
// make querySelectorAll enumeration code readable
['forEach', 'some', 'indexOf', 'map'].forEach(method => {
NodeList.prototype[method] = Array.prototype[method];
});
// eslint-disable-next-line no-extend-native
Object.defineProperties(Array.prototype, {
last: {
get() {
return this[this.length - 1];
},
},
rotate: {
value: function (amount) {
// negative amount == rotate left
const r = this.slice(-amount, this.length);
Array.prototype.push.apply(r, this.slice(0, this.length - r.length));
return r;
},
},
});
// preload the theme so that CodeMirror can calculate its metrics in DOMContentLoaded->setupLivePrefs()
new MutationObserver((mutations, observer) => {
const themeElement = $('#cm-theme');
@ -114,7 +276,7 @@ function preinit() {
}
getOwnTab().then(tab => {
ownTabId = tab.id;
const ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStorageHash('manageStylesHistory').value[ownTabId] === location.href) {
@ -156,37 +318,28 @@ function preinit() {
function onRuntimeMessage(request) {
switch (request.method) {
case 'styleUpdated':
if (styleId && styleId === request.style.id &&
request.reason !== 'editPreview' &&
request.reason !== 'editSave' &&
request.reason !== 'config') {
// code-less style from notifyAllTabs
const {sections, id} = request.style;
((sections[0] || {}).code === null
? API.getStyles({id})
: Promise.resolve([request.style])
).then(([style]) => {
if (isUsercss(style)) {
editor.replaceStyle(style, request.codeIsUpdated);
} else {
initWithSectionStyle(style, request.codeIsUpdated);
}
if (
editor.getStyleId() === 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);
});
}
break;
case 'styleDeleted':
if (styleId === request.id || editor && editor.getStyle().id === request.id) {
if (editor.getStyleId() === request.style.id) {
document.removeEventListener('visibilitychange', beforeUnload);
window.onbeforeunload = null;
document.removeEventListener('beforeunload', beforeUnload);
closeCurrentTab();
break;
}
break;
case 'prefChanged':
if ('editor.smartIndent' in request.prefs) {
CodeMirror.setOption('smartIndent', request.prefs['editor.smartIndent']);
}
break;
case 'editDeleteText':
document.execCommand('delete');
break;
@ -200,7 +353,7 @@ function onRuntimeMessage(request) {
* > 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() {
function beforeUnload(e) {
if (saveSizeOnClose) rememberWindowSize();
const activeElement = document.activeElement;
if (activeElement) {
@ -209,10 +362,9 @@ function beforeUnload() {
// refocus if unloading was canceled
setTimeout(() => activeElement.focus());
}
const isDirty = editor ? editor.isDirty() : !isCleanGlobal();
if (isDirty) {
if (editor && editor.isDirty()) {
// neither confirm() nor custom messages work in modern browsers but just in case
return t('styleChangesNotSaved');
e.returnValue = t('styleChangesNotSaved');
}
}
@ -228,7 +380,6 @@ function initStyleData() {
const params = new URLSearchParams(location.search.replace(/^\?/, ''));
const id = Number(params.get('id'));
const createEmptyStyle = () => ({
id: null,
name: params.get('domain') ||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'',
@ -244,15 +395,14 @@ function initStyleData() {
});
return fetchStyle()
.then(style => {
styleId = style.id;
if (styleId) sessionStorage.justEditedStyleId = styleId;
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 (!styleId) {
if (!style.id) {
history.replaceState({}, document.title, location.pathname);
}
return style;
@ -260,268 +410,12 @@ function initStyleData() {
function fetchStyle() {
if (id) {
return API.getStyleFromDB(id);
return API.getStyle(id);
}
return Promise.resolve(createEmptyStyle());
}
}
function initHooks() {
if (initHooks.alreadyDone) {
return;
}
initHooks.alreadyDone = true;
$$('#header .style-contributor').forEach(node => {
node.addEventListener('change', onChange);
node.addEventListener('input', onChange);
});
$('#to-mozilla').addEventListener('click', showMozillaFormat, false);
$('#to-mozilla-help').addEventListener('click', showToMozillaHelp, false);
$('#from-mozilla').addEventListener('click', fromMozillaFormat);
$('#save-button').addEventListener('click', save, false);
$('#sections-help').addEventListener('click', showSectionHelp, false);
if (!FIREFOX) {
$$([
'input:not([type])',
'input[type="text"]',
'input[type="search"]',
'input[type="number"]',
].join(','))
.forEach(e => e.addEventListener('mousedown', toggleContextMenuDelete));
}
}
function onChange(event) {
const node = event.target;
if ('savedValue' in node) {
const currentValue = node.type === 'checkbox' ? node.checked : node.value;
setCleanItem(node, node.savedValue === currentValue);
} else {
// the manually added section's applies-to is dirty only when the value is non-empty
setCleanItem(node, node.localName !== 'input' || !node.value.trim());
// only valid when actually saved
delete node.savedValue;
}
updateTitle();
}
// Set .dirty on stylesheet contributors that have changed
function setDirtyClass(node, isDirty) {
node.classList.toggle('dirty', isDirty);
}
function setCleanItem(node, isClean) {
if (!node.id) {
node.id = Date.now().toString(32).substr(-6);
}
if (isClean) {
delete dirty[node.id];
// code sections have .CodeMirror property
if (node.CodeMirror) {
node.savedValue = node.CodeMirror.changeGeneration();
} else {
node.savedValue = node.type === 'checkbox' ? node.checked : node.value;
}
} else {
dirty[node.id] = true;
}
setDirtyClass(node, !isClean);
}
function isCleanGlobal() {
const clean = Object.keys(dirty).length === 0;
setDirtyClass(document.body, !clean);
return clean;
}
function setCleanGlobal() {
setCleanItem($('#sections'), true);
$$('#header, #sections > .section').forEach(setCleanSection);
// forget the dirty applies-to ids from a deleted section after the style was saved
dirty = {};
}
function setCleanSection(section) {
$$('.style-contributor', section).forEach(node => setCleanItem(node, true));
setCleanItem(section, true);
updateTitle();
}
function toggleStyle() {
$('#enabled').dispatchEvent(new MouseEvent('click', {bubbles: true}));
}
function save() {
if (!validate()) {
return;
}
API.saveStyle({
id: styleId,
name: $('#name').value.trim(),
enabled: $('#enabled').checked,
reason: 'editSave',
sections: getSectionsHashes()
})
.then(style => {
styleId = style.id;
sessionStorage.justEditedStyleId = styleId;
setCleanGlobal();
// Go from new style URL to edit style URL
if (location.href.indexOf('id=') === -1) {
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
$('#heading').textContent = t('editStyleHeading');
}
updateTitle();
$('#preview-label').classList.remove('hidden');
});
}
function validate() {
const name = $('#name').value.trim();
if (!name) {
$('#name').focus();
messageBox.alert(t('styleMissingName'));
return false;
}
if ($$('.applies-to-list li:not(.applies-to-everything)')
.some(li => {
const type = $('[name=applies-type]', li).value;
const value = $('[name=applies-value]', li);
const rx = value.value.trim();
if (type === 'regexp' && rx && !tryRegExp(rx)) {
value.focus();
value.select();
return true;
}
})) {
messageBox.alert(t('styleBadRegexp'));
return false;
}
return true;
}
function updateTitle() {
const name = $('#name').savedValue;
const clean = isCleanGlobal();
const title = styleId === null ? t('addStyleTitle') : name;
document.title = (clean ? '' : '* ') + title;
window.onbeforeunload = clean ? null : beforeUnload;
$('#save-button').disabled = clean;
}
function showMozillaFormat() {
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
popup.codebox.setValue(toMozillaFormat());
popup.codebox.execCommand('selectAll');
}
function toMozillaFormat() {
return sectionsToMozFormat({sections: getSectionsHashes()});
}
function fromMozillaFormat() {
const popup = showCodeMirrorPopup(t('styleFromMozillaFormatPrompt'),
$create('.buttons', [
$create('button', {
name: 'import-replace',
textContent: t('importReplaceLabel'),
title: 'Ctrl-Shift-Enter:\n' + t('importReplaceTooltip'),
onclick: () => doImport({replaceOldStyle: true}),
}),
$create('button', {
name: 'import-append',
textContent: t('importAppendLabel'),
title: 'Ctrl-Enter:\n' + t('importAppendTooltip'),
onclick: doImport,
}),
]));
const contents = $('.contents', popup);
contents.insertBefore(popup.codebox.display.wrapper, contents.firstElementChild);
popup.codebox.focus();
popup.codebox.on('changes', cm => {
popup.classList.toggle('ready', !cm.isBlank());
cm.markClean();
});
// overwrite default extraKeys as those are inapplicable in popup context
popup.codebox.options.extraKeys = {
'Ctrl-Enter': doImport,
'Shift-Ctrl-Enter': () => doImport({replaceOldStyle: true}),
};
function doImport({replaceOldStyle = false}) {
lockPageUI(true);
editorWorker.parseMozFormat({code: popup.codebox.getValue().trim()})
.then(({sections, errors}) => {
// shouldn't happen but just in case
if (!sections.length && errors.length) {
return Promise.reject(errors);
}
// show the errors in case linting is disabled or stylelint misses what csslint has found
if (errors.length && prefs.get('editor.linter') !== 'csslint') {
showError(errors);
}
removeOldSections(replaceOldStyle);
return addSections(sections, div => setCleanItem(div, false));
})
.then(() => {
$('.dismiss').dispatchEvent(new Event('click'));
})
.catch(showError)
.then(() => lockPageUI(false));
}
function removeOldSections(removeAll) {
let toRemove;
if (removeAll) {
toRemove = editors.slice().reverse();
} else if (editors.last.isBlank() && $('.applies-to-everything', editors.last.getSection())) {
toRemove = [editors.last];
} else {
return;
}
toRemove.forEach(cm => removeSection({target: cm.getSection()}));
}
function lockPageUI(locked) {
document.documentElement.style.pointerEvents = locked ? 'none' : '';
if (popup.codebox) {
popup.classList.toggle('ready', locked ? false : !popup.codebox.isBlank());
popup.codebox.options.readOnly = locked;
popup.codebox.display.wrapper.style.opacity = locked ? '.5' : '';
}
}
function showError(errors) {
messageBox({
className: 'center danger',
title: t('styleFromMozillaFormatError'),
contents: $create('pre', Array.isArray(errors) ? errors.join('\n') : errors),
buttons: [t('confirmClose')],
});
}
}
function showSectionHelp(event) {
event.preventDefault();
showHelp(t('styleSectionsTitle'), t('sectionHelp'));
}
function showAppliesToHelp(event) {
event.preventDefault();
showHelp(t('appliesLabel'), t('appliesHelp'));
}
function showToMozillaHelp(event) {
event.preventDefault();
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
}
function showHelp(title = '', body) {
const div = $('#help-popup');
div.className = '';
@ -594,7 +488,7 @@ function showCodeMirrorPopup(title, html, options) {
keyMap: prefs.get('editor.keyMap')
}, options));
cm.focus();
cm.rerouteHotkeys(false);
rerouteHotkeys(false);
document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto';
@ -613,36 +507,13 @@ function showCodeMirrorPopup(title, html, options) {
window.removeEventListener('closeHelp', _);
window.removeEventListener('keydown', onKeyDown, true);
document.documentElement.style.removeProperty('pointer-events');
cm.rerouteHotkeys(true);
rerouteHotkeys(true);
cm = popup.codebox = null;
});
return popup;
}
function setGlobalProgress(done, total) {
const progressElement = $('#global-progress') ||
total && document.body.appendChild($create('#global-progress'));
if (total) {
const progress = (done / Math.max(done, total) * 100).toFixed(1);
progressElement.style.borderLeftWidth = progress + 'vw';
setTimeout(() => {
progressElement.title = progress + '%';
});
} else {
$.remove(progressElement);
}
}
function scrollEntirePageOnCtrlShift(event) {
// make Shift-Ctrl-Wheel scroll entire page even when mouse is over a code editor
if (event.shiftKey && event.ctrlKey && !event.altKey && !event.metaKey) {
// Chrome scrolls horizontally when Shift is pressed but on some PCs this might be different
window.scrollBy(0, event.deltaX || event.deltaY);
event.preventDefault();
}
}
function hideLintHeaderOnScroll() {
// workaround part2 for the <details> not showing its toggle icon: hide <summary> on scroll
const newOpacity = this.scrollTop === 0 ? '' : '0';

View File

@ -1,118 +0,0 @@
/* global importScripts parseMozFormat parserlib CSSLint require */
'use strict';
createAPI({
csslint: (code, config) => {
loadParserLib();
loadScript(['/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});
},
parseMozFormat: data => {
loadParserLib();
loadScript(['/js/moz-parser.js']);
return parseMozFormat(data);
},
getStylelintRules,
getCsslintRules
});
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;
}
}
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);
}
}
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;
}
function loadParserLib() {
if (typeof parserlib !== 'undefined') {
return;
}
importScripts('/vendor-overwrites/csslint/parserlib.js');
parserlib.css.Tokens[parserlib.css.Tokens.COMMENT].hide = false;
}
const loadedUrls = new Set();
function loadScript(urls) {
urls = urls.filter(u => !loadedUrls.has(u));
importScripts(...urls);
urls.forEach(u => loadedUrls.add(u));
}
function createAPI(methods) {
self.onmessage = e => {
const message = e.data;
Promise.resolve()
.then(() => methods[message.action](...message.args))
.then(result => ({
id: message.id,
error: false,
data: result
}))
.catch(err => ({
id: message.id,
error: true,
data: cloneError(err)
}))
.then(data => self.postMessage(data));
};
}
function cloneError(err) {
return Object.assign({
name: err.name,
stack: err.stack,
message: err.message,
lineNumber: err.lineNumber,
columnNumber: err.columnNumber,
fileName: err.fileName
}, err);
}

View File

@ -1,39 +1,88 @@
/* global importScripts workerUtil CSSLint require metaParser */
'use strict';
// eslint-disable-next-line no-var
var editorWorker = (() => {
let worker;
return new Proxy({}, {
get: (target, prop) =>
(...args) => {
if (!worker) {
worker = createWorker();
}
return worker.invoke(prop, args);
}
importScripts('/js/worker-util.js');
const {createAPI, loadScript} = workerUtil;
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 =>
({
code: err.code,
args: err.args,
message: err.message,
index: err.index
})
);
return result;
},
getStylelintRules,
getCsslintRules
});
function createWorker() {
let id = 0;
const pendingResponse = new Map();
const worker = new Worker('/edit/editor-worker-body.js');
worker.onmessage = e => {
const message = e.data;
pendingResponse.get(message.id)[message.error ? 'reject' : 'resolve'](message.data);
pendingResponse.delete(message.id);
};
return {invoke};
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;
}
}
return output;
});
}
function invoke(action, args) {
return new Promise((resolve, reject) => {
pendingResponse.set(id, {resolve, reject});
worker.postMessage({
id,
action,
args
});
id++;
});
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);
}
}
})();
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;
}

View File

@ -1,6 +1,5 @@
/* global CodeMirror editors makeSectionVisible */
/* global focusAccessibility */
/* global colorMimicry */
/* global CodeMirror focusAccessibility colorMimicry editor
onDOMready $ $$ $create t debounce tryRegExp stringAsRegExp template */
'use strict';
onDOMready().then(() => {
@ -207,12 +206,12 @@ onDOMready().then(() => {
}
const cmFocused = document.activeElement && document.activeElement.closest('.CodeMirror');
state.activeAppliesTo = $(`.${APPLIES_VALUE_CLASS}:focus, .${APPLIES_VALUE_CLASS}.${TARGET_CLASS}`);
state.cmStart = CodeMirror.closestVisible(
state.cmStart = editor.closestVisible(
cmFocused && document.activeElement ||
state.activeAppliesTo ||
state.cm);
const cmExtra = $('body > :not(#sections) .CodeMirror');
state.editors = cmExtra ? [cmExtra.CodeMirror] : editors;
state.editors = cmExtra ? [cmExtra.CodeMirror] : editor.getEditors();
}
@ -291,7 +290,7 @@ onDOMready().then(() => {
function doSearchInApplies(cm, canAdvance) {
if (!state.searchInApplies) return;
const inputs = [...cm.getSection().getElementsByClassName(APPLIES_VALUE_CLASS)];
const inputs = editor.getSearchableInputs(cm);
if (state.reverse) inputs.reverse();
inputs.splice(0, inputs.indexOf(state.activeAppliesTo));
for (const input of inputs) {
@ -314,7 +313,7 @@ onDOMready().then(() => {
});
const canFocus = !state.dialog || !state.dialog.contains(document.activeElement);
makeTargetVisible(!canFocus && input);
makeSectionVisible(cm);
editor.scrollToEditor(cm);
if (canFocus) input.focus();
state.cm = cm;
clearMarker();
@ -778,7 +777,7 @@ onDOMready().then(() => {
cm.scrollIntoView(searchCursor.pos, SCROLL_REVEAL_MIN_PX);
// scroll to the editor itself
makeSectionVisible(cm);
editor.scrollToEditor(cm);
// focus or expose as the current search target
clearMarker();

View File

@ -1,4 +1,6 @@
/* global memoize editorWorker showCodeMirrorPopup loadScript messageBox LINTER_DEFAULTS*/
/* global memoize editorWorker showCodeMirrorPopup loadScript messageBox
LINTER_DEFAULTS rerouteHotkeys $ $create $createLink tryJSONparse t
chromeSync */
'use strict';
(() => {
@ -50,10 +52,10 @@
});
cm.on('changes', updateButtonState);
cm.rerouteHotkeys(false);
rerouteHotkeys(false);
window.addEventListener('closeHelp', function _() {
window.removeEventListener('closeHelp', _);
cm.rerouteHotkeys(true);
rerouteHotkeys(true);
cm = null;
});

View File

@ -1,7 +1,7 @@
/* exported LINTER_DEFAULTS */
'use strict';
// eslint-disable-next-line no-var
var LINTER_DEFAULTS = (() => {
const LINTER_DEFAULTS = (() => {
const SEVERITY = {severity: 'warning'};
const STYLELINT = {
// 'sugarss' is a indent-based syntax like Sass or Stylus

View File

@ -1,4 +1,4 @@
/* global LINTER_DEFAULTS linter editorWorker */
/* global LINTER_DEFAULTS linter editorWorker prefs chromeSync */
'use strict';
(() => {

View File

@ -1,4 +1,5 @@
/* global showHelp editorWorker memoize */
/* global showHelp editorWorker memoize $ $create $createLink t */
/* exported createLinterHelpDialog */
'use strict';
function createLinterHelpDialog(getIssues) {

View File

@ -1,4 +1,5 @@
/* global linter */
/* global linter editorWorker */
/* exported createMetaCompiler */
'use strict';
function createMetaCompiler(cm) {
@ -18,25 +19,23 @@ function createMetaCompiler(cm) {
if (match[0] === meta && match.index === metaIndex) {
return cache;
}
return API.parseUsercss({sourceCode: match[0], metaOnly: true})
.then(result => result.usercssData)
.then(result => {
return editorWorker.metalint(match[0])
.then(({metadata, errors}) => {
if (errors.every(err => err.code === 'unknownMeta')) {
for (const cb of updateListeners) {
cb(result);
cb(metadata);
}
meta = match[0];
metaIndex = match.index;
cache = [];
return cache;
}, err => {
meta = match[0];
metaIndex = match.index;
cache = [{
}
cache = errors.map(err =>
({
from: cm.posFromIndex((err.index || 0) + match.index),
to: cm.posFromIndex((err.index || 0) + match.index),
message: err.message,
severity: 'error'
}];
message: err.code && chrome.i18n.getMessage(`meta_${err.code}`, err.args) || err.message,
severity: err.code === 'unknownMeta' ? 'warning' : 'error'
})
);
meta = match[0];
metaIndex = match.index;
return cache;
});
});

View File

@ -1,7 +1,6 @@
/* global linter editors clipString createLinterHelpDialog makeSectionVisible */
/* global linter editor clipString createLinterHelpDialog $ $create */
'use strict';
// eslint-disable-next-line no-var
Object.assign(linter, (() => {
const tables = new Map();
const helpDialog = createLinterHelpDialog(getIssues);
@ -16,13 +15,9 @@ Object.assign(linter, (() => {
table = createTable(cm);
tables.set(cm, table);
const container = $('.lint-report-container');
if (typeof editor === 'object') {
container.append(table.element);
} else {
const nextSibling = findNextSibling(tables, cm);
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
}
}
table.updateCaption();
table.updateAnnotations(annotations);
updateCount();
@ -57,6 +52,7 @@ Object.assign(linter, (() => {
}
function findNextSibling(tables, cm) {
const editors = editor.getEditors();
let i = editors.indexOf(cm) + 1;
while (i < editors.length) {
if (tables.has(editors[i])) {
@ -85,8 +81,7 @@ Object.assign(linter, (() => {
};
function updateCaption() {
caption.textContent = typeof editor === 'object' ?
'' : `${t('sectionCode')} ${editors.indexOf(cm) + 1}`;
caption.textContent = editor.getEditorTitle(cm);
}
function updateAnnotations(lines) {
@ -158,7 +153,7 @@ Object.assign(linter, (() => {
}
function gotoLintIssue(cm, anno) {
makeSectionVisible(cm);
editor.scrollToEditor(cm);
cm.focus();
cm.setSelection(anno.from);
}

View File

@ -1,7 +1,8 @@
/* global prefs */
'use strict';
// eslint-disable-next-line no-var
var linter = (() => {
/* exported linter */
const linter = (() => {
const lintingUpdatedListeners = [];
const unhookListeners = [];
const linters = [];

73
edit/live-preview.js Normal file
View File

@ -0,0 +1,73 @@
/* global messageBox editor $ prefs */
/* exported createLivePreview */
'use strict';
function createLivePreview(preprocess) {
let data;
let previewer;
let enabled = prefs.get('editor.livePreview');
const label = $('#preview-label');
const errorContainer = $('#preview-errors');
prefs.subscribe(['editor.livePreview'], (key, value) => {
if (value && data && data.id && data.enabled) {
previewer = createPreviewer();
previewer.update(data);
}
if (!value && previewer) {
previewer.disconnect();
previewer = null;
}
enabled = value;
});
return {update, show};
function show(state) {
label.classList.toggle('hidden', !state);
}
function update(_data) {
data = _data;
if (!previewer) {
if (!data.id || !data.enabled || !enabled) {
return;
}
previewer = createPreviewer();
}
previewer.update(data);
}
function createPreviewer() {
const port = chrome.runtime.connect({
name: 'livePreview'
});
port.onDisconnect.addListener(err => {
throw err;
});
return {update, disconnect};
function update(data) {
Promise.resolve()
.then(() => preprocess ? preprocess(data) : data)
.then(data => port.postMessage(data))
.then(
() => errorContainer.classList.add('hidden'),
err => {
if (Array.isArray(err)) {
err = err.join('\n');
} else if (err && err.index !== undefined) {
// 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 || String(err)}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => messageBox.alert(err.message || String(err), 'pre');
}
);
}
function disconnect() {
port.disconnect();
}
}
}

View File

@ -1,4 +1,4 @@
/* global CodeMirror */
/* global CodeMirror prefs */
'use strict';
(() => {

27
edit/refresh-on-view.js Normal file
View File

@ -0,0 +1,27 @@
/* global CodeMirror */
/*
Initialization of the multi-sections editor is slow if there are many editors
e.g. https://github.com/openstyles/stylus/issues/178. So we only refresh the
editor when they were scroll into view.
*/
'use strict';
CodeMirror.defineExtension('refreshOnView', function () {
const cm = this;
if (typeof IntersectionObserver === 'undefined') {
// uh
cm.refresh();
return;
}
const wrapper = cm.display.wrapper;
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
// wrapper.style.visibility = 'visible';
cm.refresh();
observer.disconnect();
}
}
});
observer.observe(wrapper);
});

View File

@ -1,8 +1,8 @@
/* global showHelp */
/* global showHelp $ $create tryRegExp queryTabs URLS t template openURL */
/* exported regExpTester */
'use strict';
// eslint-disable-next-line no-var
var regExpTester = (() => {
const regExpTester = (() => {
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const cachedRegexps = new Map();
@ -58,7 +58,7 @@ var regExpTester = (() => {
const rxData = Object.assign({text}, cachedRegexps.get(text));
if (!rxData.urls) {
cachedRegexps.set(text, Object.assign(rxData, {
// imitate buggy Stylish-for-chrome, see detectSloppyRegexps()
// imitate buggy Stylish-for-chrome
rx: tryRegExp('^' + text + '$'),
urls: new Map(),
}));

48
edit/reroute-hotkeys.js Normal file
View File

@ -0,0 +1,48 @@
/* global CodeMirror editor debounce */
/* exported rerouteHotkeys */
'use strict';
const rerouteHotkeys = (() => {
// reroute handling to nearest editor when keypress resolves to one of these commands
const REROUTED = new Set([
'save',
'toggleStyle',
'jumpToLine',
'nextEditor', 'prevEditor',
'toggleEditorFocus',
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
'colorpicker',
]);
return rerouteHotkeys;
// note that this function relies on `editor`. Calling this function before
// the editor is initialized may throw an error.
function rerouteHotkeys(enable, immediately) {
if (!immediately) {
debounce(rerouteHotkeys, 0, enable, true);
} else if (enable) {
document.addEventListener('keydown', rerouteHandler);
} else {
document.removeEventListener('keydown', rerouteHandler);
}
}
function rerouteHandler(event) {
const keyName = CodeMirror.keyName(event);
if (!keyName) {
return;
}
const rerouteCommand = name => {
if (REROUTED.has(name)) {
CodeMirror.commands[name](editor.closestVisible(event.target));
return true;
}
};
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
event.preventDefault();
event.stopPropagation();
}
}
})();

View File

@ -0,0 +1,409 @@
/* global template cmFactory $ propertyToCss CssToProperty linter regExpTester
FIREFOX toggleContextMenuDelete beautify showHelp t tryRegExp */
/* exported createSection */
'use strict';
function createResizeGrip(cm) {
const wrapper = cm.display.wrapper;
wrapper.classList.add('resize-grip-enabled');
const resizeGrip = template.resizeGrip.cloneNode(true);
wrapper.appendChild(resizeGrip);
let lastClickTime = 0;
resizeGrip.onmousedown = event => {
if (event.button !== 0) {
return;
}
event.preventDefault();
if (Date.now() - lastClickTime < 500) {
lastClickTime = 0;
toggleSectionHeight(cm);
return;
}
lastClickTime = Date.now();
const minHeight = cm.defaultTextHeight() +
/* .CodeMirror-lines padding */
cm.display.lineDiv.offsetParent.offsetTop +
/* borders */
wrapper.offsetHeight - wrapper.clientHeight;
wrapper.style.pointerEvents = 'none';
document.body.style.cursor = 's-resize';
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', resizeStop);
function resize(e) {
const cmPageY = wrapper.getBoundingClientRect().top + window.scrollY;
const height = Math.max(minHeight, e.pageY - cmPageY);
if (height !== wrapper.clientHeight) {
cm.setSize(null, height);
}
}
function resizeStop() {
document.removeEventListener('mouseup', resizeStop);
document.removeEventListener('mousemove', resize);
wrapper.style.pointerEvents = '';
document.body.style.cursor = '';
}
};
function toggleSectionHeight(cm) {
if (cm.state.toggleHeightSaved) {
// restore previous size
cm.setSize(null, cm.state.toggleHeightSaved);
cm.state.toggleHeightSaved = 0;
} else {
// maximize
const wrapper = cm.display.wrapper;
const allBounds = $('#sections').getBoundingClientRect();
const pageExtrasHeight = allBounds.top + window.scrollY +
parseFloat(getComputedStyle($('#sections')).paddingBottom);
const sectionEl = wrapper.parentNode;
const sectionExtrasHeight = sectionEl.clientHeight - wrapper.offsetHeight;
cm.state.toggleHeightSaved = wrapper.clientHeight;
cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight);
const bounds = sectionEl.getBoundingClientRect();
if (bounds.top < 0 || bounds.bottom > window.innerHeight) {
window.scrollBy(0, bounds.top);
}
}
}
}
function createSection({
// data model
originalSection,
dirty,
// util
nextEditor,
prevEditor,
genId,
// emit events
// TODO: better names like `onRemoved`? Or make a real event emitter.
showMozillaFormatImport,
removeSection,
insertSectionAfter,
moveSectionUp,
moveSectionDown,
restoreSection,
}) {
const sectionId = genId();
const el = template.section.cloneNode(true);
const cm = cmFactory.create(wrapper => {
el.insertBefore(wrapper, $('.code-label', el).nextSibling);
}, {value: originalSection.code});
const changeListeners = new Set();
const appliesToContainer = $('.applies-to-list', el);
const appliesTo = [];
for (const [key, fnName] of Object.entries(propertyToCss)) {
if (originalSection[key]) {
originalSection[key].forEach(value =>
insertApplyAfter({type: fnName, value})
);
}
}
if (!appliesTo.length) {
insertApplyAfter({all: true});
}
let changeGeneration = cm.changeGeneration();
let removed = false;
registerEvents();
updateRegexpTester();
createResizeGrip(cm);
linter.enableForEditor(cm);
const section = {
id: sectionId,
el,
cm,
render,
getModel,
remove,
destroy,
restore,
isRemoved: () => removed,
onChange,
off,
appliesTo
};
return section;
function onChange(fn) {
changeListeners.add(fn);
}
function off(fn) {
changeListeners.delete(fn);
}
function emitSectionChange() {
for (const fn of changeListeners) {
fn();
}
}
function getModel() {
const section = {
code: cm.getValue()
};
for (const apply of appliesTo) {
if (apply.all) {
continue;
}
const key = CssToProperty[apply.getType()];
if (!section[key]) {
section[key] = [];
}
section[key].push(apply.getValue());
}
return section;
}
function registerEvents() {
cm.on('changes', () => {
const newGeneration = cm.changeGeneration();
dirty.modify(`section.${sectionId}.code`, changeGeneration, newGeneration);
changeGeneration = newGeneration;
emitSectionChange();
});
cm.on('paste', (cm, event) => {
const text = event.clipboardData.getData('text') || '';
if (
text.includes('@-moz-document') &&
text.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, '')
.match(/@-moz-document[\s\r\n]+(url|url-prefix|domain|regexp)\(/)
) {
event.preventDefault();
showMozillaFormatImport(text);
}
});
if (!FIREFOX) {
cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event));
}
cm.display.wrapper.addEventListener('keydown', event =>
handleKeydown(cm, event), true);
$('.applies-to-help', el).addEventListener('click', showAppliesToHelp);
$('.remove-section', el).addEventListener('click', () => removeSection(section));
$('.add-section', el).addEventListener('click', () => insertSectionAfter(undefined, section));
$('.clone-section', el).addEventListener('click', () => insertSectionAfter(getModel(), section));
$('.move-section-up', el).addEventListener('click', () => moveSectionUp(section));
$('.move-section-down', el).addEventListener('click', () => moveSectionDown(section));
$('.beautify-section', el).addEventListener('click', () => beautify([cm]));
$('.restore-section', el).addEventListener('click', () => restoreSection(section));
$('.test-regexp', el).addEventListener('click', () => {
regExpTester.toggle();
updateRegexpTester();
});
}
function handleKeydown(cm, event) {
const key = event.which;
if (key < 37 || key > 40 || event.shiftKey || event.altKey || event.metaKey) {
return;
}
const {line, ch} = cm.getCursor();
switch (key) {
case 37:
// arrow Left
if (line || ch) {
return;
}
// fallthrough to arrow Up
case 38:
// arrow Up
cm = line === 0 && prevEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(cm.doc.size - 1, key === 37 ? 1e20 : ch);
break;
case 39:
// arrow Right
if (line < cm.doc.size - 1 || ch < cm.getLine(line).length - 1) {
return;
}
// fallthrough to arrow Down
case 40:
// arrow Down
cm = line === cm.doc.size - 1 && nextEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(0, 0);
break;
}
// FIXME: what is this?
// const animation = (cm.getSection().firstElementChild.getAnimations() || [])[0];
// if (animation) {
// animation.playbackRate = -1;
// animation.currentTime = 2000;
// animation.play();
// }
}
function showAppliesToHelp(event) {
event.preventDefault();
showHelp(t('appliesLabel'), t('appliesHelp'));
}
function remove() {
linter.disableForEditor(cm);
el.classList.add('removed');
removed = true;
appliesTo.forEach(a => a.remove());
}
function destroy() {
cmFactory.destroy(cm);
}
function restore() {
linter.enableForEditor(cm);
el.classList.remove('removed');
removed = false;
appliesTo.forEach(a => a.restore());
render();
}
function render() {
cm.refresh();
}
function updateRegexpTester() {
const regexps = appliesTo.filter(a => a.getType() === 'regexp')
.map(a => a.getValue());
if (regexps.length) {
el.classList.add('has-regexp');
regExpTester.update(regexps);
} else {
el.classList.remove('has-regexp');
regExpTester.toggle(false);
}
}
function insertApplyAfter(init, base) {
const apply = createApply(init);
if (base) {
const index = appliesTo.indexOf(base);
appliesTo.splice(index + 1, 0, apply);
appliesToContainer.insertBefore(apply.el, base.el.nextSibling);
} else {
appliesTo.push(apply);
appliesToContainer.appendChild(apply.el);
}
dirty.add(apply, apply);
if (appliesTo.length > 1 && appliesTo[0].all) {
removeApply(appliesTo[0]);
}
emitSectionChange();
}
function removeApply(apply) {
const index = appliesTo.indexOf(apply);
appliesTo.splice(index, 1);
apply.remove();
apply.el.remove();
dirty.remove(apply, apply);
if (!appliesTo.length) {
insertApplyAfter({all: true});
}
emitSectionChange();
}
function createApply({type = 'url', value, all = false}) {
const applyId = genId();
const dirtyPrefix = `section.${sectionId}.apply.${applyId}`;
const el = all ? template.appliesToEverything.cloneNode(true) :
template.appliesTo.cloneNode(true);
const selectEl = !all && $('.applies-type', el);
if (selectEl) {
selectEl.value = type;
selectEl.addEventListener('change', () => {
const oldType = type;
dirty.modify(`${dirtyPrefix}.type`, type, selectEl.value);
type = selectEl.value;
if (oldType === 'regexp' || type === 'regexp') {
updateRegexpTester();
}
emitSectionChange();
validate();
});
}
const valueEl = !all && $('.applies-value', el);
if (valueEl) {
valueEl.value = value;
valueEl.addEventListener('input', () => {
dirty.modify(`${dirtyPrefix}.value`, value, valueEl.value);
value = valueEl.value;
if (type === 'regexp') {
updateRegexpTester();
}
emitSectionChange();
});
valueEl.addEventListener('change', validate);
}
restore();
const apply = {
id: applyId,
all,
remove,
restore,
el,
getType: () => type,
getValue: () => value,
valueEl // used by validator
};
const removeButton = $('.remove-applies-to', el);
if (removeButton) {
removeButton.addEventListener('click', e => {
e.preventDefault();
removeApply(apply);
});
}
$('.add-applies-to', el).addEventListener('click', e => {
e.preventDefault();
insertApplyAfter({type, value: ''}, apply);
});
return apply;
function validate() {
if (type !== 'regexp' || tryRegExp(value)) {
valueEl.setCustomValidity('');
} else {
valueEl.setCustomValidity(t('styleBadRegexp'));
setTimeout(() => valueEl.reportValidity());
}
}
function remove() {
if (all) {
return;
}
dirty.remove(`${dirtyPrefix}.type`, type);
dirty.remove(`${dirtyPrefix}.value`, value);
}
function restore() {
if (all) {
return;
}
dirty.add(`${dirtyPrefix}.type`, type);
dirty.add(`${dirtyPrefix}.value`, value);
}
}
}

594
edit/sections-editor.js Normal file
View File

@ -0,0 +1,594 @@
/* global dirtyReporter showHelp toggleContextMenuDelete createSection
CodeMirror linter createLivePreview showCodeMirrorPopup
sectionsToMozFormat messageBox clipString
rerouteHotkeys $ $$ $create t FIREFOX API
debounce */
/* exported createSectionsEditor */
'use strict';
function createSectionsEditor(style) {
let INC_ID = 0; // an increment id that is used by various object to track the order
const dirty = dirtyReporter();
dirty.onChange(updateTitle);
const container = $('#sections');
const sections = [];
const nameEl = $('#name');
nameEl.addEventListener('input', () => {
dirty.modify('name', style.name, nameEl.value);
style.name = nameEl.value;
updateTitle();
});
const enabledEl = $('#enabled');
enabledEl.addEventListener('change', () => {
dirty.modify('enabled', style.enabled, enabledEl.checked);
style.enabled = enabledEl.checked;
updateLivePreview();
});
$('#to-mozilla').addEventListener('click', showMozillaFormat);
$('#to-mozilla-help').addEventListener('click', showToMozillaHelp);
$('#from-mozilla').addEventListener('click', () => showMozillaFormatImport());
$('#save-button').addEventListener('click', saveStyle);
document.addEventListener('wheel', scrollEntirePageOnCtrlShift);
if (!FIREFOX) {
$$([
'input:not([type])',
'input[type="text"]',
'input[type="search"]',
'input[type="number"]',
].join(','))
.forEach(e => e.addEventListener('mousedown', toggleContextMenuDelete));
}
let sectionOrder = '';
const initializing = new Promise(resolve => initSection({
sections: style.sections.slice(),
done:() => {
// FIXME: implement this with CSS?
// https://github.com/openstyles/stylus/commit/2895ce11e271788df0e4f7314b3b981fde086574
dirty.clear();
rerouteHotkeys(true);
resolve();
updateHeader();
}
}));
const livePreview = createLivePreview();
livePreview.show(Boolean(style.id));
return {
ready: () => initializing,
replaceStyle,
isDirty: dirty.isDirty,
getStyle: () => style,
getEditors,
scrollToEditor,
getStyleId: () => style.id,
getEditorTitle: cm => {
const index = sections.filter(s => !s.isRemoved()).findIndex(s => s.cm === cm);
return `${t('sectionCode')} ${index + 1}`;
},
save: saveStyle,
toggleStyle,
nextEditor,
prevEditor,
closestVisible,
getSearchableInputs,
};
function genId() {
return INC_ID++;
}
function setGlobalProgress(done, total) {
const progressElement = $('#global-progress') ||
total && document.body.appendChild($create('#global-progress'));
if (total) {
const progress = (done / Math.max(done, total) * 100).toFixed(1);
progressElement.style.borderLeftWidth = progress + 'vw';
setTimeout(() => {
progressElement.title = progress + '%';
});
} else {
$.remove(progressElement);
}
}
function showToMozillaHelp(event) {
event.preventDefault();
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
}
function getSearchableInputs(cm) {
return sections.find(s => s.cm === cm).appliesTo.map(a => a.valueEl).filter(Boolean);
}
// priority:
// 1. associated CM for applies-to element
// 2. last active if visible
// 3. first visible
function closestVisible(nearbyElement) {
const cm =
nearbyElement instanceof CodeMirror ? nearbyElement :
nearbyElement instanceof Node &&
(nearbyElement.closest('#sections > .section') || {}).CodeMirror ||
getLastActivatedEditor();
if (nearbyElement instanceof Node && cm) {
const {left, top} = nearbyElement.getBoundingClientRect();
const bounds = cm.display.wrapper.getBoundingClientRect();
if (top >= 0 && top >= bounds.top &&
left >= 0 && left >= bounds.left) {
return cm;
}
}
// closest editor should have at least 2 lines visible
const lineHeight = sections[0].cm.defaultTextHeight();
const scrollY = window.scrollY;
const windowBottom = scrollY + window.innerHeight - 2 * lineHeight;
const allSectionsContainerTop = scrollY + $('#sections').getBoundingClientRect().top;
const distances = [];
const alreadyInView = cm && offscreenDistance(null, cm) === 0;
return alreadyInView ? cm : findClosest();
function offscreenDistance(index, cm) {
if (index >= 0 && distances[index] !== undefined) {
return distances[index];
}
const section = cm.display.wrapper.closest('.section');
if (!section) {
return 1e9;
}
const top = allSectionsContainerTop + section.offsetTop;
if (top < scrollY + lineHeight) {
return Math.max(0, scrollY - top - lineHeight);
}
if (top < windowBottom) {
return 0;
}
const distance = top - windowBottom + section.offsetHeight;
if (index >= 0) {
distances[index] = distance;
}
return distance;
}
function findClosest() {
const editors = getEditors();
const last = editors.length - 1;
let a = 0;
let b = last;
let c;
let distance;
while (a < b - 1) {
c = (a + b) / 2 | 0;
distance = offscreenDistance(c);
if (!distance || !c) {
break;
}
const distancePrev = offscreenDistance(c - 1);
const distanceNext = c < last ? offscreenDistance(c + 1) : 1e20;
if (distancePrev <= distance && distance <= distanceNext) {
b = c;
} else {
a = c;
}
}
while (b && offscreenDistance(b - 1) <= offscreenDistance(b)) {
b--;
}
const cm = editors[b];
if (distances[b] > 0) {
scrollToEditor(cm);
}
return cm;
}
}
function getEditors() {
return sections.filter(s => !s.isRemoved()).map(s => s.cm);
}
function toggleStyle() {
const newValue = !style.enabled;
dirty.modify('enabled', style.enabled, newValue);
style.enabled = newValue;
enabledEl.checked = newValue;
}
function nextEditor(cm, cycle = true) {
if (!cycle) {
for (const section of sections) {
if (section.isRemoved()) {
continue;
}
if (cm === section.cm) {
return;
}
break;
}
}
return nextPrevEditor(cm, 1);
}
function prevEditor(cm, cycle = true) {
if (!cycle) {
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].isRemoved()) {
continue;
}
if (cm === sections[i].cm) {
return;
}
break;
}
}
return nextPrevEditor(cm, -1);
}
function nextPrevEditor(cm, direction) {
const editors = getEditors();
cm = editors[(editors.indexOf(cm) + direction + editors.length) % editors.length];
scrollToEditor(cm);
cm.focus();
return cm;
}
function scrollToEditor(cm) {
const section = sections.find(s => s.cm === cm).el;
const bounds = section.getBoundingClientRect();
if (
(bounds.bottom > window.innerHeight && bounds.top > 0) ||
(bounds.top < 0 && bounds.bottom < window.innerHeight)
) {
if (bounds.top < 0) {
window.scrollBy(0, bounds.top - 1);
} else {
window.scrollBy(0, bounds.bottom - window.innerHeight + 1);
}
}
}
function getLastActivatedEditor() {
let result;
for (const section of sections) {
if (section.isRemoved()) {
continue;
}
// .lastActive is initiated by codemirror-factory
if (!result || section.cm.lastActive > result.lastActive) {
result = section.cm;
}
}
return result;
}
function scrollEntirePageOnCtrlShift(event) {
// make Shift-Ctrl-Wheel scroll entire page even when mouse is over a code editor
if (event.shiftKey && event.ctrlKey && !event.altKey && !event.metaKey) {
// Chrome scrolls horizontally when Shift is pressed but on some PCs this might be different
window.scrollBy(0, event.deltaX || event.deltaY);
event.preventDefault();
}
}
function showMozillaFormat() {
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
popup.codebox.setValue(sectionsToMozFormat(getModel()));
popup.codebox.execCommand('selectAll');
}
function showMozillaFormatImport(text = '') {
const popup = showCodeMirrorPopup(t('styleFromMozillaFormatPrompt'),
$create('.buttons', [
$create('button', {
name: 'import-replace',
textContent: t('importReplaceLabel'),
title: 'Ctrl-Shift-Enter:\n' + t('importReplaceTooltip'),
onclick: () => doImport({replaceOldStyle: true}),
}),
$create('button', {
name: 'import-append',
textContent: t('importAppendLabel'),
title: 'Ctrl-Enter:\n' + t('importAppendTooltip'),
onclick: doImport,
}),
]));
const contents = $('.contents', popup);
contents.insertBefore(popup.codebox.display.wrapper, contents.firstElementChild);
popup.codebox.focus();
popup.codebox.on('changes', cm => {
popup.classList.toggle('ready', !cm.isBlank());
cm.markClean();
});
if (text) {
popup.codebox.setValue(text);
popup.codebox.clearHistory();
popup.codebox.markClean();
}
// overwrite default extraKeys as those are inapplicable in popup context
popup.codebox.options.extraKeys = {
'Ctrl-Enter': doImport,
'Shift-Ctrl-Enter': () => doImport({replaceOldStyle: true}),
};
function doImport({replaceOldStyle = false}) {
lockPageUI(true);
API.parseCss({code: popup.codebox.getValue().trim()})
.then(({sections, errors}) => {
// shouldn't happen but just in case
if (!sections.length || errors.length) {
throw errors;
}
if (replaceOldStyle) {
return replaceSections(sections);
}
return new Promise(resolve => initSection({sections, done: resolve, focusOn: false}));
})
.then(() => {
$('.dismiss').dispatchEvent(new Event('click'));
})
.catch(showError)
.then(() => lockPageUI(false));
}
function lockPageUI(locked) {
document.documentElement.style.pointerEvents = locked ? 'none' : '';
if (popup.codebox) {
popup.classList.toggle('ready', locked ? false : !popup.codebox.isBlank());
popup.codebox.options.readOnly = locked;
popup.codebox.display.wrapper.style.opacity = locked ? '.5' : '';
}
}
function showError(errors) {
messageBox({
className: 'center danger',
title: t('styleFromMozillaFormatError'),
contents: $create('pre', Array.isArray(errors) ? errors.join('\n') : errors),
buttons: [t('confirmClose')],
});
}
}
function updateSectionOrder() {
const oldOrder = sectionOrder;
const validSections = sections.filter(s => !s.isRemoved());
sectionOrder = validSections.map(s => s.id).join(',');
dirty.modify('sectionOrder', oldOrder, sectionOrder);
container.dataset.sectionCount = validSections.length;
linter.refreshReport();
}
function getModel() {
return Object.assign({}, style, {
sections: sections.filter(s => !s.isRemoved()).map(s => s.getModel())
});
}
function validate() {
if (!nameEl.reportValidity()) {
messageBox.alert(t('styleMissingName'));
return false;
}
for (const section of sections) {
for (const apply of section.appliesTo) {
if (apply.getType() !== 'regexp') {
continue;
}
if (!apply.valueEl.reportValidity()) {
messageBox.alert(t('styleBadRegexp'));
return false;
}
}
}
return true;
}
function saveStyle() {
if (!dirty.isDirty()) {
return;
}
const newStyle = getModel();
if (!validate(newStyle)) {
return;
}
API.editSave(newStyle)
.then(newStyle => {
destroyRemovedSections();
sessionStorage.justEditedStyleId = newStyle.id;
replaceStyle(newStyle, false);
});
}
function destroyRemovedSections() {
for (let i = 0; i < sections.length;) {
if (!sections[i].isRemoved()) {
i++;
continue;
}
sections[i].destroy();
sections[i].el.remove();
sections.splice(i, 1);
}
}
function updateHeader() {
nameEl.value = style.name || '';
enabledEl.checked = style.enabled !== false;
$('#url').href = style.url || '';
updateTitle();
}
function updateLivePreview() {
debounce(_updateLivePreview, 200);
}
function _updateLivePreview() {
livePreview.update(getModel());
}
function updateTitle() {
const name = style.name;
const clean = !dirty.isDirty();
const title = !style.id ? t('addStyleTitle') : name;
document.title = (clean ? '' : '* ') + title;
$('#save-button').disabled = clean;
}
function initSection({
sections: originalSections,
total = originalSections.length,
focusOn = 0,
done
}) {
container.classList.add('hidden');
chunk();
function chunk() {
if (!originalSections.length) {
setGlobalProgress();
if (focusOn !== false) {
sections[focusOn].cm.focus();
}
container.classList.remove('hidden');
for (const section of sections) {
section.cm.refreshOnView();
}
if (done) {
done();
}
return;
}
const t0 = performance.now();
while (originalSections.length && performance.now() - t0 < 100) {
insertSectionAfter(originalSections.shift());
}
setGlobalProgress(total - originalSections.length, total);
setTimeout(chunk);
}
}
function removeSection(section) {
if (sections.every(s => s.isRemoved() || s === section)) {
// TODO: hide remove button when `#sections[data-section-count=1]`
throw new Error('Cannot remove last section');
}
if (section.cm.isBlank()) {
const index = sections.indexOf(section);
sections.splice(index, 1);
section.el.remove();
section.remove();
section.destroy();
} else {
const lines = [];
const MAX_LINES = 10;
section.cm.doc.iter(0, MAX_LINES + 1, ({text}) => lines.push(text) && false);
const title = t('sectionCode') + '\n' +
'-'.repeat(20) + '\n' +
lines.slice(0, MAX_LINES).map(s => clipString(s, 100)).join('\n') +
(lines.length > MAX_LINES ? '\n...' : '');
$('.deleted-section', section.el).title = title;
section.remove();
}
dirty.remove(section, section);
updateSectionOrder();
section.off(updateLivePreview);
updateLivePreview();
}
function restoreSection(section) {
section.restore();
updateSectionOrder();
section.onChange(updateLivePreview);
updateLivePreview();
}
function insertSectionAfter(init, base) {
if (!init) {
init = {code: '', urlPrefixes: ['http://example.com']};
}
const section = createSection({
originalSection: init,
genId,
dirty,
showMozillaFormatImport,
removeSection,
restoreSection,
insertSectionAfter,
moveSectionUp,
moveSectionDown,
prevEditor,
nextEditor
});
if (base) {
const index = sections.indexOf(base);
sections.splice(index + 1, 0, section);
container.insertBefore(section.el, base.el.nextSibling);
} else {
sections.push(section);
container.appendChild(section.el);
}
section.render();
updateSectionOrder();
section.onChange(updateLivePreview);
updateLivePreview();
}
function moveSectionUp(section) {
const index = sections.indexOf(section);
if (index === 0) {
return;
}
container.insertBefore(section.el, sections[index - 1].el);
sections[index] = sections[index - 1];
sections[index - 1] = section;
updateSectionOrder();
}
function moveSectionDown(section) {
const index = sections.indexOf(section);
if (index === sections.length - 1) {
return;
}
container.insertBefore(sections[index + 1].el, section.el);
sections[index] = sections[index + 1];
sections[index + 1] = section;
updateSectionOrder();
}
function replaceSections(originalSections) {
for (const section of sections) {
section.remove(true);
}
sections.length = 0;
container.textContent = '';
return new Promise(resolve => initSection({sections: originalSections, done: resolve}));
}
function replaceStyle(newStyle, codeIsUpdated) {
// FIXME: avoid recreating all editors?
reinit().then(() => {
Object.assign(style, newStyle);
updateHeader();
dirty.clear();
// Go from new style URL to edit style URL
if (location.href.indexOf('id=') === -1 && style.id) {
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
$('#heading').textContent = t('editStyleHeading');
}
livePreview.show(Boolean(style.id));
});
function reinit() {
if (codeIsUpdated !== false) {
return replaceSections(newStyle.sections.slice());
}
return Promise.resolve();
}
}
}

View File

@ -1,582 +0,0 @@
/*
global CodeMirror
global editors propertyToCss CssToProperty
global onChange initHooks setCleanGlobal
global fromMozillaFormat maximizeCodeHeight toggleContextMenuDelete
global setCleanItem updateTitle
global showAppliesToHelp beautify regExpTester setGlobalProgress setCleanSection
global clipString linter
*/
'use strict';
function initWithSectionStyle(style, codeIsUpdated) {
$('#name').value = style.name || '';
$('#enabled').checked = style.enabled !== false;
$('#url').href = style.url || '';
if (codeIsUpdated !== false) {
editors.length = 0;
$('#sections').textContent = '';
addSections(style.sections.length ? style.sections : [{code: ''}]);
initHooks();
}
setCleanGlobal();
updateTitle();
}
function addSections(sections, onAdded = () => {}) {
if (addSections.running) {
console.error('addSections cannot be re-entered: please report to the developers');
// TODO: handle this properly e.g. on update/import
return;
}
addSections.running = true;
maximizeCodeHeight.stats = null;
// make a shallow copy since we might run asynchronously
// and the original array might get modified
sections = sections.slice();
const t0 = performance.now();
const divs = [];
let index = 0;
return new Promise(function run(resolve) {
while (index < sections.length) {
const div = addSection(null, sections[index]);
maximizeCodeHeight(div, index === sections.length - 1);
onAdded(div, index);
divs.push(div);
maybeFocusFirstCM();
index++;
const elapsed = performance.now() - t0;
if (elapsed > 500) {
setGlobalProgress(index, sections.length);
}
if (elapsed > 100) {
// after 100ms the sections are added asynchronously
setTimeout(run, 0, resolve);
return;
}
}
editors.last.state.renderLintReportNow = true;
addSections.running = false;
setGlobalProgress();
resolve(divs);
});
function maybeFocusFirstCM() {
const isPageLocked = document.documentElement.style.pointerEvents;
if (divs[0] && (isPageLocked ? divs.length === sections.length : index === 0)) {
makeSectionVisible(divs[0].CodeMirror);
setTimeout(() => {
if ((document.activeElement || {}).localName !== 'input') {
divs[0].CodeMirror.focus();
}
});
}
}
}
function addSection(event, section) {
const div = template.section.cloneNode(true);
$('.applies-to-help', div).addEventListener('click', showAppliesToHelp);
$('.remove-section', div).addEventListener('click', removeSection);
$('.add-section', div).addEventListener('click', addSection);
$('.clone-section', div).addEventListener('click', cloneSection);
$('.move-section-up', div).addEventListener('click', moveSection);
$('.move-section-down', div).addEventListener('click', moveSection);
$('.beautify-section', div).addEventListener('click', beautify);
const code = (section || {}).code || '';
const appliesTo = $('.applies-to-list', div);
let appliesToAdded = false;
if (section) {
for (const i in propertyToCss) {
if (section[i]) {
section[i].forEach(url => {
addAppliesTo(appliesTo, propertyToCss[i], url);
appliesToAdded = true;
});
}
}
}
if (!appliesToAdded) {
addAppliesTo(appliesTo, event && 'url-prefix', '');
}
appliesTo.addEventListener('change', onChange);
appliesTo.addEventListener('input', onChange);
toggleTestRegExpVisibility();
appliesTo.addEventListener('change', toggleTestRegExpVisibility);
$('.test-regexp', div).onclick = () => {
regExpTester.toggle();
regExpTester.update(getRegExps());
};
function getRegExps() {
return [...appliesTo.children]
.map(item =>
!item.matches('.applies-to-everything') &&
$('.applies-type', item).value === 'regexp' &&
$('.applies-value', item).value.trim()
)
.filter(item => item);
}
function toggleTestRegExpVisibility() {
const show = getRegExps().length > 0;
div.classList.toggle('has-regexp', show);
appliesTo.oninput = appliesTo.oninput || show && (event => {
if (event.target.matches('.applies-value') &&
$('.applies-type', event.target.closest('.applies-to-item')).value === 'regexp') {
regExpTester.update(getRegExps());
}
});
}
const sections = $('#sections');
let cm;
if (event) {
let clickedSection = event && getSectionForChild(event.target, {includeDeleted: true});
clickedSection.insertAdjacentElement('afterend', div);
while (clickedSection && !clickedSection.matches('.section')) {
clickedSection = clickedSection.previousElementSibling;
}
const newIndex = getSections().indexOf(clickedSection) + 1;
cm = setupCodeMirror(div, code, newIndex);
makeSectionVisible(cm);
cm.focus();
} else {
sections.appendChild(div);
cm = setupCodeMirror(div, code);
}
linter.enableForEditor(cm);
linter.refreshReport();
div.CodeMirror = cm;
setCleanSection(div);
return div;
}
// may be invoked as a DOM listener
function addAppliesTo(list, type, value) {
let clickedItem;
if (this instanceof Node) {
clickedItem = this.closest('.applies-to-item');
list = this.closest('.applies-to-list');
// dummy <a> wrapper was clicked
if (arguments[0] instanceof Event) arguments[0].preventDefault();
}
const showingEverything = $('.applies-to-everything', list);
// blow away 'Everything' if it's there
if (showingEverything) {
list.removeChild(list.firstChild);
}
let item, toFocus;
// a section is added with known applies-to
if (type) {
item = template.appliesTo.cloneNode(true);
$('[name=applies-type]', item).value = type;
$('[name=applies-value]', item).value = value;
$('.remove-applies-to', item).addEventListener('click', removeAppliesTo);
// a "+" button was clicked
} else if (showingEverything || clickedItem) {
item = template.appliesTo.cloneNode(true);
toFocus = $('[name=applies-type]', item);
if (clickedItem) {
$('[name=applies-type]', item).value = $('[name="applies-type"]', clickedItem).value;
}
$('.remove-applies-to', item).addEventListener('click', removeAppliesTo);
// a global section is added
} else {
item = template.appliesToEverything.cloneNode(true);
}
$('.add-applies-to', item).addEventListener('click', addAppliesTo);
list.insertBefore(item, clickedItem && clickedItem.nextElementSibling);
if (toFocus) toFocus.focus();
}
function cloneSection(event) {
const section = getSectionForChild(event.target);
addSection(event, getSectionsHashes([section]).pop());
setCleanItem($('#sections'), false);
updateTitle();
}
function moveSection(event) {
const section = getSectionForChild(event.target);
const dir = event.target.closest('.move-section-up') ? -1 : 1;
const cm = section.CodeMirror;
const index = editors.indexOf(cm);
const newIndex = (index + dir + editors.length) % editors.length;
const currentNextEl = section.nextElementSibling;
const newSection = editors[newIndex].getSection();
newSection.insertAdjacentElement('afterend', section);
section.parentNode.insertBefore(newSection, currentNextEl || null);
cm.focus();
editors[index] = editors[newIndex];
editors[newIndex] = cm;
setCleanItem($('#sections'), false);
updateTitle();
}
function setupCodeMirror(sectionDiv, code, index = editors.length) {
const cm = CodeMirror(wrapper => {
$('.code-label', sectionDiv).insertAdjacentElement('afterend', wrapper);
}, {
value: code,
});
const wrapper = cm.display.wrapper;
let onChangeTimer;
cm.on('changes', (cm, changes) => {
clearTimeout(onChangeTimer);
onChangeTimer = setTimeout(indicateCodeChange, 200, cm, changes);
});
wrapper.addEventListener('keydown', event => nextPrevEditorOnKeydown(cm, event), true);
cm.on('paste', (cm, event) => {
const text = event.clipboardData.getData('text') || '';
if (
text.includes('@-moz-document') &&
text.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, '')
.match(/@-moz-document[\s\r\n]+(url|url-prefix|domain|regexp)\(/)
) {
event.preventDefault();
fromMozillaFormat();
$('#help-popup').codebox.setValue(text);
$('#help-popup').codebox.clearHistory();
$('#help-popup').codebox.markClean();
}
if (editors.length === 1) {
setTimeout(() => {
if (cm.display.sizer.clientHeight > cm.display.wrapper.clientHeight) {
maximizeCodeHeight.stats = null;
maximizeCodeHeight(cm.getSection(), true);
}
});
}
});
if (!FIREFOX) {
cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event));
}
wrapper.classList.add('resize-grip-enabled');
let lastClickTime = 0;
const resizeGrip = wrapper.appendChild(template.resizeGrip.cloneNode(true));
resizeGrip.onmousedown = event => {
if (event.button !== 0) {
return;
}
event.preventDefault();
if (Date.now() - lastClickTime < 500) {
lastClickTime = 0;
toggleSectionHeight(cm);
return;
}
lastClickTime = Date.now();
const minHeight = cm.defaultTextHeight() +
/* .CodeMirror-lines padding */
cm.display.lineDiv.offsetParent.offsetTop +
/* borders */
wrapper.offsetHeight - wrapper.clientHeight;
wrapper.style.pointerEvents = 'none';
document.body.style.cursor = 's-resize';
function resize(e) {
const cmPageY = wrapper.getBoundingClientRect().top + window.scrollY;
const height = Math.max(minHeight, e.pageY - cmPageY);
if (height !== wrapper.clientHeight) {
cm.setSize(null, height);
}
}
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', function resizeStop() {
document.removeEventListener('mouseup', resizeStop);
document.removeEventListener('mousemove', resize);
wrapper.style.pointerEvents = '';
document.body.style.cursor = '';
});
};
editors.splice(index, 0, cm);
return cm;
}
function indicateCodeChange(cm) {
const section = cm.getSection();
setCleanItem(section, cm.isClean(section.savedValue));
updateTitle();
}
function setupAutocomplete(cm, enable = true) {
const onOff = enable ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked);
}
function autocompleteOnTyping(cm, [info], debounced) {
if (
cm.state.completionActive ||
info.origin && !info.origin.includes('input') ||
!info.text.last
) {
return;
}
if (cm.state.autocompletePicked) {
cm.state.autocompletePicked = false;
return;
}
if (!debounced) {
debounce(autocompleteOnTyping, 100, cm, [info], true);
return;
}
if (info.text.last.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;
}
function nextPrevEditorOnKeydown(cm, event) {
const key = event.which;
if (key < 37 || key > 40 || event.shiftKey || event.altKey || event.metaKey) {
return;
}
const {line, ch} = cm.getCursor();
switch (key) {
case 37:
// arrow Left
if (line || ch) {
return;
}
// fallthrough to arrow Up
case 38:
// arrow Up
if (line > 0 || cm === editors[0]) {
return;
}
event.preventDefault();
event.stopPropagation();
cm = CodeMirror.commands.prevEditor(cm);
cm.setCursor(cm.doc.size - 1, key === 37 ? 1e20 : ch);
break;
case 39:
// arrow Right
if (line < cm.doc.size - 1 || ch < cm.getLine(line).length - 1) {
return;
}
// fallthrough to arrow Down
case 40:
// arrow Down
if (line < cm.doc.size - 1 || cm === editors.last) {
return;
}
event.preventDefault();
event.stopPropagation();
cm = CodeMirror.commands.nextEditor(cm);
cm.setCursor(0, 0);
break;
}
const animation = (cm.getSection().firstElementChild.getAnimations() || [])[0];
if (animation) {
animation.playbackRate = -1;
animation.currentTime = 2000;
animation.play();
}
}
function toggleSectionHeight(cm) {
if (cm.state.toggleHeightSaved) {
// restore previous size
cm.setSize(null, cm.state.toggleHeightSaved);
cm.state.toggleHeightSaved = 0;
} else {
// maximize
const wrapper = cm.display.wrapper;
const allBounds = $('#sections').getBoundingClientRect();
const pageExtrasHeight = allBounds.top + window.scrollY +
parseFloat(getComputedStyle($('#sections')).paddingBottom);
const sectionExtrasHeight = cm.getSection().clientHeight - wrapper.offsetHeight;
cm.state.toggleHeightSaved = wrapper.clientHeight;
cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight);
const bounds = cm.getSection().getBoundingClientRect();
if (bounds.top < 0 || bounds.bottom > window.innerHeight) {
window.scrollBy(0, bounds.top);
}
}
}
function getSectionForChild(el, {includeDeleted} = {}) {
return el.closest(`#sections > ${includeDeleted ? '*' : '.section'}`);
}
function getSections() {
return $$('#sections > .section');
}
function getSectionsHashes(elements = getSections()) {
const sections = [];
for (const div of elements) {
const meta = {urls: [], urlPrefixes: [], domains: [], regexps: []};
for (const li of $('.applies-to-list', div).childNodes) {
if (li.className === template.appliesToEverything.className) {
break;
}
const type = $('[name=applies-type]', li).value;
const value = $('[name=applies-value]', li).value;
if (type && value) {
meta[CssToProperty[type]].push(value);
}
}
const code = div.CodeMirror.getValue();
if (/^\s*$/.test(code) && Object.keys(meta).length === 0) {
continue;
}
meta.code = code;
sections.push(meta);
}
return sections;
}
function removeAppliesTo(event) {
event.preventDefault();
const appliesTo = event.target.closest('.applies-to-item');
const appliesToList = appliesTo.closest('.applies-to-list');
removeAreaAndSetDirty(appliesTo);
if (!appliesToList.hasChildNodes()) {
addAppliesTo(appliesToList);
}
}
function removeSection(event) {
const section = getSectionForChild(event.target);
const cm = section.CodeMirror;
if (event instanceof Event && (!cm.isClean() || !cm.isBlank())) {
const stub = template.deletedSection.cloneNode(true);
const MAX_LINES = 10;
const lines = [];
cm.doc.iter(0, MAX_LINES + 1, ({text}) => lines.push(text) && false);
stub.title = t('sectionCode') + '\n' +
'-'.repeat(20) + '\n' +
lines.slice(0, MAX_LINES).map(s => clipString(s, 100)).join('\n') +
(lines.length > MAX_LINES ? '\n...' : '');
$('.restore-section', stub).onclick = () => {
let el = stub;
while (el && !el.matches('.section')) {
el = el.previousElementSibling;
}
const index = el ? editors.indexOf(el) + 1 : 0;
editors.splice(index, 0, cm);
stub.parentNode.replaceChild(section, stub);
setCleanItem(section, false);
updateTitle();
cm.focus();
linter.enableForEditor(cm);
linter.refreshReport();
};
section.insertAdjacentElement('afterend', stub);
}
setCleanItem($('#sections'), false);
removeAreaAndSetDirty(section);
editors.splice(editors.indexOf(cm), 1);
linter.disableForEditor(cm);
linter.refreshReport();
}
function removeAreaAndSetDirty(area) {
const contributors = $$('.style-contributor', area);
if (!contributors.length) {
setCleanItem(area, false);
}
contributors.some(node => {
if (node.savedValue) {
// it's a saved section, so make it dirty and stop the enumeration
setCleanItem(area, false);
return true;
} else {
// it's an empty section, so undirty the applies-to items,
// otherwise orphaned ids would keep the style dirty
setCleanItem(node, true);
}
});
updateTitle();
area.remove();
}
function makeSectionVisible(cm) {
if (editors.length === 1) {
return;
}
const section = cm.getSection();
const bounds = section.getBoundingClientRect();
if (
(bounds.bottom > window.innerHeight && bounds.top > 0) ||
(bounds.top < 0 && bounds.bottom < window.innerHeight)
) {
if (bounds.top < 0) {
window.scrollBy(0, bounds.top - 1);
} else {
window.scrollBy(0, bounds.bottom - window.innerHeight + 1);
}
}
}
function maximizeCodeHeight(sectionDiv, isLast) {
const cm = sectionDiv.CodeMirror;
const stats = maximizeCodeHeight.stats = maximizeCodeHeight.stats || {totalHeight: 0, deltas: []};
if (!stats.cmActualHeight) {
stats.cmActualHeight = getComputedHeight(cm.display.wrapper);
}
if (!stats.sectionMarginTop) {
stats.sectionMarginTop = parseFloat(getComputedStyle(sectionDiv).marginTop);
}
const sectionTop = sectionDiv.getBoundingClientRect().top - stats.sectionMarginTop;
if (!stats.firstSectionTop) {
stats.firstSectionTop = sectionTop;
}
const extrasHeight = getComputedHeight(sectionDiv) - stats.cmActualHeight;
const cmMaxHeight = window.innerHeight - extrasHeight - sectionTop - stats.sectionMarginTop;
const cmDesiredHeight = cm.display.sizer.clientHeight + 2 * cm.defaultTextHeight();
const cmGrantableHeight = Math.max(stats.cmActualHeight, Math.min(cmMaxHeight, cmDesiredHeight));
stats.deltas.push(cmGrantableHeight - stats.cmActualHeight);
stats.totalHeight += cmGrantableHeight + extrasHeight;
if (!isLast) {
return;
}
stats.totalHeight += stats.firstSectionTop;
if (stats.totalHeight <= window.innerHeight) {
editors.forEach((cm, index) => {
cm.setSize(null, stats.deltas[index] + stats.cmActualHeight);
});
return;
}
// scale heights to fill the gap between last section and bottom edge of the window
const sections = $('#sections');
const available = window.innerHeight - sections.getBoundingClientRect().bottom -
parseFloat(getComputedStyle(sections).marginBottom);
if (available <= 0) {
return;
}
const totalDelta = stats.deltas.reduce((sum, d) => sum + d, 0);
const q = available / totalDelta;
const baseHeight = stats.cmActualHeight - stats.sectionMarginTop;
stats.deltas.forEach((delta, index) => {
editors[index].setSize(null, baseHeight + Math.floor(q * delta));
});
function getComputedHeight(el) {
const compStyle = getComputedStyle(el);
return el.getBoundingClientRect().height +
parseFloat(compStyle.marginTop) + parseFloat(compStyle.marginBottom);
}
}

View File

@ -1,4 +1,5 @@
/* global CodeMirror showHelp */
/* global CodeMirror showHelp onDOMready $ $$ $create template t
prefs stringAsRegExp */
'use strict';
onDOMready().then(() => {

View File

@ -1,11 +1,9 @@
/*
global editors styleId: true
global CodeMirror dirtyReporter
global createAppliesToLineWidget messageBox
global sectionsToMozFormat
global beforeUnload
global createMetaCompiler linter
*/
/* global dirtyReporter
createAppliesToLineWidget messageBox
sectionsToMozFormat
createMetaCompiler linter createLivePreview cmFactory $ $create API prefs t
chromeSync */
/* exported createSourceEditor */
'use strict';
function createSourceEditor(style) {
@ -20,7 +18,6 @@ function createSourceEditor(style) {
const dirty = dirtyReporter();
dirty.onChange(() => {
const isDirty = dirty.isDirty();
window.onbeforeunload = isDirty ? beforeUnload : null;
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
updateTitle();
@ -29,29 +26,26 @@ function createSourceEditor(style) {
// normalize style
if (!style.id) setupNewStyle(style);
const cm = CodeMirror($('.single-editor'), {
const cm = cmFactory.create($('.single-editor'), {
value: style.sourceCode,
});
let savedGeneration = cm.changeGeneration();
editors.push(cm);
const livePreview = createLivePreview(preprocess);
livePreview.show(Boolean(style.id));
$('#enabled').onchange = function () {
const value = this.checked;
dirty.modify('enabled', style.enabled, value);
style.enabled = value;
updateLivePreview();
};
cm.on('changes', () => {
dirty.modify('sourceGeneration', savedGeneration, cm.changeGeneration());
updateLivePreview();
});
CodeMirror.commands.prevEditor = cm => nextPrevMozDocument(cm, -1);
CodeMirror.commands.nextEditor = cm => nextPrevMozDocument(cm, 1);
CodeMirror.commands.toggleStyle = toggleStyle;
CodeMirror.commands.save = save;
CodeMirror.closestVisible = () => cm;
cm.operation(initAppliesToLineWidget);
const metaCompiler = createMetaCompiler(cm);
@ -86,6 +80,24 @@ function createSourceEditor(style) {
});
});
function preprocess(style) {
return API.buildUsercss({
sourceCode: style.sourceCode,
assignVars: true
})
.then(({style: newStyle}) => {
delete newStyle.enabled;
return Object.assign(style, newStyle);
});
}
function updateLivePreview() {
if (!style.id) {
return;
}
livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
}
function initAppliesToLineWidget() {
const PREF_NAME = 'editor.appliesToLineWidget';
const widget = createAppliesToLineWidget(cm);
@ -179,6 +191,7 @@ function createSourceEditor(style) {
if (codeIsUpdated === false || sameCode) {
updateEnvironment();
dirty.clear('enabled');
updateLivePreview();
return;
}
@ -191,6 +204,10 @@ function createSourceEditor(style) {
cm.setCursor(cursor);
savedGeneration = cm.changeGeneration();
}
if (sameCode) {
// the code is same but the environment is changed
updateLivePreview();
}
dirty.clear();
});
@ -199,10 +216,10 @@ function createSourceEditor(style) {
history.replaceState({}, '', `?id=${newStyle.id}`);
}
sessionStorage.justEditedStyleId = newStyle.id;
style = newStyle;
styleId = style.id;
Object.assign(style, newStyle);
$('#preview-label').classList.remove('hidden');
updateMeta();
livePreview.show(Boolean(style.id));
}
}
@ -218,19 +235,15 @@ function createSourceEditor(style) {
if (!dirty.isDirty()) return;
const code = cm.getValue();
return ensureUniqueStyle(code)
.then(() => API.saveUsercssUnsafe({
.then(() => API.editSaveUsercss({
id: style.id,
reason: 'editSave',
enabled: style.enabled,
sourceCode: code,
}))
.then(({style, errors}) => {
replaceStyle(style);
if (errors) return Promise.reject(errors);
})
.then(replaceStyle)
.catch(err => {
if (err.handled) return;
if (err.message === t('styleMissingMeta', 'name')) {
if (err.code === 'missingMandatory' && err.args.includes('name')) {
messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
chromeSync.setLZValue('usercssTemplate', code)
.then(() => chromeSync.getLZValue('usercssTemplate'))
@ -239,7 +252,7 @@ function createSourceEditor(style) {
}
const contents = Array.isArray(err) ?
$create('pre', err.join('\n')) :
[String(err)];
[err.message || String(err)];
if (Number.isInteger(err.index)) {
const pos = cm.posFromIndex(err.index);
contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`;
@ -373,5 +386,15 @@ function createSourceEditor(style) {
replaceStyle,
isDirty: dirty.isDirty,
getStyle: () => style,
getEditors: () => [cm],
scrollToEditor: () => {},
getStyleId: () => style.id,
getEditorTitle: () => '',
save,
toggleStyle,
prevEditor: cm => nextPrevMozDocument(cm, -1),
nextEditor: cm => nextPrevMozDocument(cm, 1),
closestVisible: () => cm,
getSearchableInputs: () => []
};
}

View File

@ -1,3 +1,4 @@
/* exported dirtyReporter memoize clipString sectionsToMozFormat */
'use strict';
function dirtyReporter() {

View File

@ -9,6 +9,8 @@
<link href="global.css" rel="stylesheet">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
<script src="js/promisify.js"></script>
<script src="js/msg.js"></script>
<script src="js/messaging.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>

View File

@ -1,5 +1,5 @@
/* global CodeMirror semverCompare closeCurrentTab */
/* global messageBox download chromeLocal */
/* global CodeMirror semverCompare closeCurrentTab messageBox download
$ $$ $create $createLink t prefs API getTab */
'use strict';
(() => {
@ -86,13 +86,11 @@
cm.setCursor(cursor);
cm.scrollTo(scrollInfo.left, scrollInfo.top);
API.saveUsercssUnsafe({
API.installUsercss({
id: (installed || installedDup).id,
reason: 'update',
sourceCode
}).then(({style, errors}) => {
}).then(style => {
updateMeta(style);
if (errors) return Promise.reject(errors);
}).catch(showError);
});
}
@ -242,7 +240,7 @@
const contents = Array.isArray(err) ?
[$create('pre', err.join('\n'))] :
[err && err.message && $create('pre', err.message) || err || 'Unknown error'];
if (Number.isInteger(err.index)) {
if (Number.isInteger(err.index) && typeof contents[0] === 'string') {
const pos = cm.posFromIndex(err.index);
contents[0] = `${pos.line + 1}:${pos.ch + 1} ` + contents[0];
contents.push($create('pre', drawLinePointer(pos)));
@ -301,7 +299,7 @@
data.version,
]))
).then(ok => ok &&
API.saveUsercss(Object.assign(style, dup && {reason: 'update'}))
API.installUsercss(style)
.then(install)
.catch(err => messageBox.alert(t('styleInstallFailed', err), 'pre'))
);

71
js/cache.js Normal file
View File

@ -0,0 +1,71 @@
/* exported createCache */
'use strict';
// create a FIFO limit-size map.
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get,
set,
delete: delete_,
clear,
has: id => map.has(id),
entries: function *() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
values: function *() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
}
};
function get(id) {
const item = map.get(id);
return item && item.data;
}
function set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
}
function delete_(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
}
function clear() {
map.clear();
index = lastIndex = 0;
}
}

View File

@ -1,9 +1,18 @@
/* global prefs */
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
setupLivePrefs moveFocus */
'use strict';
if (!/^Win\d+/.test(navigator.platform)) {
document.documentElement.classList.add('non-windows');
}
// make querySelectorAll enumeration code readable
// FIXME: avoid extending native?
['forEach', 'some', 'indexOf', 'map'].forEach(method => {
NodeList.prototype[method] = Array.prototype[method];
});
// polyfill for old browsers to enable [...results] and for-of
for (const type of [NodeList, NamedNodeMap, HTMLCollection, HTMLAllCollection]) {
if (!type.prototype[Symbol.iterator]) {
@ -392,3 +401,55 @@ function moveFocus(rootElement, step) {
}
}
}
// Accepts an array of pref names (values are fetched via prefs.get)
// and establishes a two-way connection between the document elements and the actual prefs
function setupLivePrefs(
IDs = Object.getOwnPropertyNames(prefs.defaults)
.filter(id => $('#' + id))
) {
for (const id of IDs) {
const element = $('#' + id);
updateElement({id, element, force: true});
element.addEventListener('change', onChange);
}
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
function onChange() {
const value = getInputValue(this);
if (prefs.get(this.id) !== value) {
prefs.set(this.id, value);
}
}
function updateElement({
id,
value = prefs.get(id),
element = $('#' + id),
force,
}) {
if (!element) {
prefs.unsubscribe(IDs, updateElement);
return;
}
setInputValue(element, value, force);
}
function getInputValue(input) {
if (input.type === 'checkbox') {
return input.checked;
}
if (input.type === 'number') {
return Number(input.value);
}
return input.value;
}
function setInputValue(input, value, force = false) {
if (force || getInputValue(input) !== value) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
}
}
}

View File

@ -1,3 +1,5 @@
/* global tryCatch */
/* exported tHTML formatDate */
'use strict';
const template = {};

View File

@ -1,17 +1,13 @@
/*
global BG: true
global FIREFOX: true
global onRuntimeMessage applyOnMessage
*/
/* exported getActiveTab onTabReady stringAsRegExp getTabRealURL openURL
getStyleWithNoCode tryRegExp sessionStorageHash download
closeCurrentTab */
'use strict';
// keep message channel open for sendResponse in chrome.runtime.onMessage listener
const KEEP_CHANNEL_OPEN = true;
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]);
const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
const VIVALDI = Boolean(chrome.app) && navigator.userAgent.includes('Vivaldi');
const ANDROID = !chrome.windows;
// FIXME: who use this?
// const ANDROID = !chrome.windows;
let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
if (!CHROME && !chrome.browserAction.openPopup) {
@ -72,14 +68,9 @@ const URLS = {
),
};
let BG = chrome.extension.getBackgroundPage();
if (BG && !BG.getStyles && BG !== window) {
// own page like editor/manage is being loaded on browser startup
// before the background page has been fully initialized;
// it'll be resolved in onBackgroundReady() instead
BG = null;
}
if (!BG || BG !== window) {
const IS_BG = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window;
if (!IS_BG) {
if (FIREFOX) {
document.documentElement.classList.add('firefox');
} else if (OPERA) {
@ -87,169 +78,15 @@ if (!BG || BG !== window) {
} else {
if (VIVALDI) document.documentElement.classList.add('vivaldi');
}
// TODO: remove once our manifest's minimum_chrome_version is 50+
// Chrome 49 doesn't report own extension pages in webNavigation apparently
if (CHROME && CHROME < 2661) {
getActiveTab().then(tab =>
window.API.updateIcon({tab}));
}
} else if (!BG.API_METHODS) {
BG.API_METHODS = {};
}
const FIREFOX_NO_DOM_STORAGE = FIREFOX && !tryCatch(() => localStorage);
if (FIREFOX_NO_DOM_STORAGE) {
// may be disabled via dom.storage.enabled
Object.defineProperty(window, 'localStorage', {value: {}});
Object.defineProperty(window, 'sessionStorage', {value: {}});
}
// eslint-disable-next-line no-var
var API = (() => {
return new Proxy(() => {}, {
get: (target, name) =>
name === 'remoteCall' ?
remoteCall :
arg => invokeBG(name, arg),
});
function remoteCall(name, arg, remoteWindow) {
let thing = window[name] || window.API_METHODS[name];
if (typeof thing === 'function') {
thing = thing(arg);
}
if (!thing || typeof thing !== 'object') {
return thing;
} else if (thing instanceof Promise) {
return thing.then(product => remoteWindow.deepCopy(product));
} else {
return remoteWindow.deepCopy(thing);
}
}
function invokeBG(name, arg = {}) {
if (BG && (name in BG || name in BG.API_METHODS)) {
const call = BG !== window ?
BG.API.remoteCall(name, BG.deepCopy(arg), window) :
remoteCall(name, arg, BG);
return Promise.resolve(call);
}
if (BG && BG.getStyles) {
throw new Error('Bad API method', name, arg);
}
if (FIREFOX) {
arg.method = name;
return sendMessage(arg);
}
return onBackgroundReady().then(() => invokeBG(name, arg));
}
function onBackgroundReady() {
return BG && BG.getStyles ? Promise.resolve() : new Promise(function ping(resolve) {
sendMessage({method: 'healthCheck'}, health => {
if (health !== undefined) {
BG = chrome.extension.getBackgroundPage();
resolve();
} else {
setTimeout(ping, 0, resolve);
}
});
});
}
})();
function notifyAllTabs(msg) {
const originalMessage = msg;
const styleUpdated = msg.method === 'styleUpdated';
if (styleUpdated || msg.method === 'styleAdded') {
// apply/popup/manage use only meta for these two methods,
// editor may need the full code but can fetch it directly,
// so we send just the meta to avoid spamming lots of tabs with huge styles
msg = Object.assign({}, msg, {
style: getStyleWithNoCode(msg.style)
});
}
const affectsAll = !msg.affects || msg.affects.all;
const affectsOwnOriginOnly = !affectsAll && (msg.affects.editor || msg.affects.manager);
const affectsTabs = affectsAll || affectsOwnOriginOnly;
const affectsIcon = affectsAll || msg.affects.icon;
const affectsPopup = affectsAll || msg.affects.popup;
const affectsSelf = affectsPopup || msg.prefs;
// notify all open extension pages and popups
if (affectsSelf) {
msg.tabId = undefined;
sendMessage(msg, ignoreChromeError);
}
// notify tabs
if (affectsTabs || affectsIcon) {
const notifyTab = tab => {
if (!styleUpdated
&& (affectsTabs || URLS.optionsUI.includes(tab.url))
// own pages are already notified via sendMessage
&& !(affectsSelf && tab.url.startsWith(URLS.ownOrigin))
// skip lazy-loaded aka unloaded tabs that seem to start loading on message in FF
&& (!FIREFOX || tab.width)) {
msg.tabId = tab.id;
sendMessage(msg, ignoreChromeError);
}
if (affectsIcon) {
// eslint-disable-next-line no-use-before-define
debounce(API.updateIcon, 0, {tab});
}
};
// list all tabs including chrome-extension:// which can be ours
Promise.all([
queryTabs(affectsOwnOriginOnly ? {url: URLS.ownOrigin + '*'} : {}),
getActiveTab(),
]).then(([tabs, activeTab]) => {
const activeTabId = activeTab && activeTab.id;
for (const tab of tabs) {
invokeOrPostpone(tab.id === activeTabId, notifyTab, tab);
}
});
}
// notify self: the message no longer is sent to the origin in new Chrome
if (typeof onRuntimeMessage !== 'undefined') {
onRuntimeMessage(originalMessage);
}
// notify apply.js on own pages
if (typeof applyOnMessage !== 'undefined') {
applyOnMessage(originalMessage);
}
// propagate saved style state/code efficiently
if (styleUpdated) {
msg.refreshOwnTabs = false;
API.refreshAllTabs(msg);
}
}
function sendMessage(msg, callback) {
/*
Promise mode [default]:
- rejects on receiving {__ERROR__: message} created by background.js::onRuntimeMessage
- automatically suppresses chrome.runtime.lastError because it's autogenerated
by browserAction.setText which lacks a callback param in chrome API
Standard callback mode:
- enabled by passing a second param
*/
const {tabId, frameId} = msg;
const fn = tabId >= 0 ? chrome.tabs.sendMessage : chrome.runtime.sendMessage;
const args = tabId >= 0 ? [tabId, msg, {frameId}] : [msg];
if (callback) {
fn(...args, callback);
} else {
return new Promise((resolve, reject) => {
fn(...args, r => {
const err = r && r.__ERROR__;
(err ? reject : resolve)(err || r);
ignoreChromeError();
});
});
}
if (IS_BG) {
window.API_METHODS = {};
}
// FIXME: `localStorage` and `sessionStorage` may be disabled via dom.storage.enabled
// Object.defineProperty(window, 'localStorage', {value: {}});
// Object.defineProperty(window, 'sessionStorage', {value: {}});
function queryTabs(options = {}) {
return new Promise(resolve =>
@ -276,13 +113,6 @@ function getActiveTab() {
.then(tabs => tabs[0]);
}
function getActiveTabRealURL() {
return getActiveTab()
.then(getTabRealURL);
}
function getTabRealURL(tab) {
return new Promise(resolve => {
if (tab.url !== 'chrome://newtab/' || URLS.chromeProtectsNTP) {
@ -385,7 +215,6 @@ function openURL({
index,
active,
currentWindow = true,
message,
}) {
url = url.includes('://') ? url : chrome.runtime.getURL(url);
// [some] chromium forks don't handle their fake branded protocols
@ -401,15 +230,7 @@ function openURL({
url.replace(/%2F.*/, '*').replace(/#.*/, '') :
url.replace(/#.*/, '');
const task = queryTabs({url: urlQuery, currentWindow}).then(maybeSwitch);
if (!message) {
return task;
} else {
return task.then(onTabReady).then(tab => {
message.tabId = tab.id;
return sendMessage(message).then(() => tab);
});
}
return queryTabs({url: urlQuery, currentWindow}).then(maybeSwitch);
function maybeSwitch(tabs = []) {
const urlWithSlash = url + '/';
@ -652,27 +473,6 @@ function download(url, {
}
}
function invokeOrPostpone(isInvoke, fn, ...args) {
return isInvoke
? fn(...args)
: setTimeout(invokeOrPostpone, 0, true, fn, ...args);
}
function openEditor({id}) {
let url = '/edit.html';
if (id) {
url += `?id=${id}`;
}
if (chrome.windows && prefs.get('openEditInWindow')) {
chrome.windows.create(Object.assign({url}, prefs.get('windowPosition')));
} else {
openURL({url});
}
}
function closeCurrentTab() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
getOwnTab().then(tab => {

78
js/meta-parser.js Normal file
View File

@ -0,0 +1,78 @@
/* global usercssMeta colorConverter */
/* exported metaParser */
'use strict';
const metaParser = (() => {
const {createParser, ParseError} = usercssMeta;
const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);
const options = {
validateKey: {
preprocessor: state => {
if (!PREPROCESSORS.has(state.value)) {
throw new ParseError({
code: 'unknownPreprocessor',
args: [state.value],
index: state.valueIndex
});
}
}
},
validateVar: {
select: state => {
if (state.varResult.options.every(o => o.name !== state.value)) {
throw new ParseError({
code: 'invalidSelectValueMismatch',
index: state.valueIndex
});
}
},
color: state => {
const color = colorConverter.parse(state.value);
if (!color) {
throw new ParseError({
code: 'invalidColor',
args: [state.value],
index: state.valueIndex
});
}
state.value = colorConverter.format(color, 'rgb');
}
}
};
const parser = createParser(options);
const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'}));
return {
parse,
lint,
nullifyInvalidVars
};
function parse(text, indexOffset) {
try {
return parser.parse(text);
} catch (err) {
if (typeof err.index === 'number') {
err.index += indexOffset;
}
throw err;
}
}
function lint(text) {
return looseParser.parse(text);
}
function nullifyInvalidVars(vars) {
for (const va of Object.values(vars)) {
if (va.value === null) {
continue;
}
try {
parser.validateVar(va);
} catch (err) {
va.value = null;
}
}
return vars;
}
})();

View File

@ -1,4 +1,5 @@
/* global parserlib */
/* exported parseMozFormat */
'use strict';
/**

329
js/msg.js Normal file
View File

@ -0,0 +1,329 @@
/* global promisify deepCopy */
/* exported msg API */
// deepCopy is only used if the script is executed in extension pages.
'use strict';
const msg = (() => {
let isBg = false;
if (chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window) {
isBg = true;
window._msg = {
id: 1,
storage: new Map(),
handler: null,
clone: deepCopy
};
}
const runtimeSend = promisify(chrome.runtime.sendMessage.bind(chrome.runtime));
const tabSend = chrome.tabs && promisify(chrome.tabs.sendMessage.bind(chrome.tabs));
const tabQuery = chrome.tabs && promisify(chrome.tabs.query.bind(chrome.tabs));
let bg;
const preparing = !isBg && chrome.runtime.getBackgroundPage &&
promisify(chrome.runtime.getBackgroundPage.bind(chrome.runtime))()
.catch(() => null)
.then(_bg => {
bg = _bg;
});
bg = isBg ? window : !preparing ? null : undefined;
const EXTENSION_URL = chrome.runtime.getURL('');
let handler;
const from_ = location.href.startsWith(EXTENSION_URL) ? 'extension' : 'content';
const RX_NO_RECEIVER = /Receiving end does not exist/;
const RX_PORT_CLOSED = /The message port closed before a response was received/;
return {
send,
sendTab,
sendBg,
broadcast,
broadcastTab,
broadcastExtension,
ignoreError,
on,
onTab,
onExtension,
off,
RX_NO_RECEIVER,
RX_PORT_CLOSED
};
function send(data, target = 'extension') {
if (bg === undefined) {
return preparing.then(() => send(data, target));
}
const message = {type: 'direct', data, target, from: from_};
if (bg) {
exchangeSet(message);
}
const request = runtimeSend(message).then(unwrapData);
if (message.id) {
return withCleanup(request, () => bg._msg.storage.delete(message.id));
}
return request;
}
function sendTab(tabId, data, options, target = 'tab') {
return tabSend(tabId, {type: 'direct', data, target, from: from_}, options)
.then(unwrapData);
}
function sendBg(data) {
if (bg === undefined) {
return preparing.then(doSend);
}
return withPromiseError(doSend);
function doSend() {
if (bg) {
if (!bg._msg.handler) {
throw new Error('there is no bg handler');
}
const handlers = bg._msg.handler.extension.concat(bg._msg.handler.both);
// in FF, the object would become a dead object when the window
// is closed, so we have to clone the object into background.
return Promise.resolve(executeCallbacks(handlers, bg._msg.clone(data), {url: location.href}))
.then(deepCopy);
}
return send(data);
}
}
function ignoreError(err) {
if (err.message && (
RX_NO_RECEIVER.test(err.message) ||
RX_PORT_CLOSED.test(err.message)
)) {
return;
}
console.warn(err);
}
function broadcast(data, filter) {
return Promise.all([
send(data, 'both').catch(ignoreError),
broadcastTab(data, filter, null, true, 'both')
]);
}
function broadcastTab(data, filter, options, ignoreExtension = false, target = 'tab') {
return tabQuery({})
// TODO: send to activated tabs first?
.then(tabs => {
const requests = [];
for (const tab of tabs) {
const isExtension = tab.url.startsWith(EXTENSION_URL);
if (
tab.discarded ||
// FIXME: use `URLS.supported`?
!/^(http|ftp|file)/.test(tab.url) &&
!tab.url.startsWith('chrome://newtab/') &&
!isExtension ||
isExtension && ignoreExtension ||
filter && !filter(tab)
) {
continue;
}
const dataObj = typeof data === 'function' ? data(tab) : data;
if (!dataObj) {
continue;
}
const message = {type: 'direct', data: dataObj, target, from: from_};
if (isExtension) {
exchangeSet(message);
}
let request = tabSend(tab.id, message, options).then(unwrapData);
if (message.id) {
request = withCleanup(request, () => bg._msg.storage.delete(message.id));
}
requests.push(request.catch(ignoreError));
}
return Promise.all(requests);
});
}
function broadcastExtension(...args) {
return send(...args).catch(ignoreError);
}
function on(fn) {
initHandler();
handler.both.push(fn);
}
function onTab(fn) {
initHandler();
handler.tab.push(fn);
}
function onExtension(fn) {
initHandler();
handler.extension.push(fn);
}
function off(fn) {
for (const type of ['both', 'tab', 'extension']) {
const index = handler[type].indexOf(fn);
if (index >= 0) {
handler[type].splice(index, 1);
}
}
}
function initHandler() {
if (handler) {
return;
}
handler = {
both: [],
tab: [],
extension: []
};
if (isBg) {
bg._msg.handler = handler;
}
chrome.runtime.onMessage.addListener(handleMessage);
}
function executeCallbacks(callbacks, ...args) {
let result;
for (const fn of callbacks) {
const data = withPromiseError(fn, ...args);
if (data !== undefined && result === undefined) {
result = data;
}
}
return result;
}
function handleMessage(message, sender, sendResponse) {
const handlers = message.target === 'tab' ?
handler.tab.concat(handler.both) : message.target === 'extension' ?
handler.extension.concat(handler.both) :
handler.both.concat(handler.extension, handler.tab);
if (!handlers.length) {
return;
}
if (message.type === 'exchange') {
const pending = exchangeGet(message, true);
if (pending) {
pending.then(response);
return true;
}
}
return response();
function response() {
const result = executeCallbacks(handlers, message.data, sender);
if (result === undefined) {
return;
}
Promise.resolve(result)
.then(
data => ({
error: false,
data
}),
err => ({
error: true,
data: Object.assign({
message: err.message || String(err),
// FIXME: do we want to pass the entire stack?
stack: err.stack
}, err) // this allows us to pass custom properties e.g. `err.index`
})
)
.then(function doResponse(responseMessage) {
if (message.from === 'extension' && bg === undefined) {
return preparing.then(() => doResponse(responseMessage));
}
if (message.from === 'extension' && bg) {
exchangeSet(responseMessage);
} else {
responseMessage.type = 'direct';
}
return responseMessage;
})
.then(sendResponse);
return true;
}
}
function exchangeGet(message, keepStorage = false) {
if (bg === undefined) {
return preparing.then(() => exchangeGet(message, keepStorage));
}
message.data = bg._msg.storage.get(message.id);
if (keepStorage) {
message.data = deepCopy(message.data);
} else {
bg._msg.storage.delete(message.id);
}
}
function exchangeSet(message) {
const id = bg._msg.id;
bg._msg.storage.set(id, message.data);
bg._msg.id++;
message.type = 'exchange';
message.id = id;
delete message.data;
}
function withPromiseError(fn, ...args) {
try {
return fn(...args);
} catch (err) {
return Promise.reject(err);
}
}
function withCleanup(p, fn) {
return p.then(
result => {
cleanup();
return result;
},
err => {
cleanup();
throw err;
}
);
function cleanup() {
try {
fn();
} catch (err) {
// pass
}
}
}
// {type, error, data, id}
function unwrapData(result) {
if (result === undefined) {
throw new Error('Receiving end does not exist');
}
if (result.type === 'exchange') {
const pending = exchangeGet(result);
if (pending) {
return pending.then(unwrap);
}
}
return unwrap();
function unwrap() {
if (result.error) {
throw Object.assign(new Error(result.data.message), result.data);
}
return result.data;
}
}
})();
const API = new Proxy({}, {
get: (target, name) =>
(...args) => Promise.resolve(msg.sendBg({
method: 'invokeAPI',
name,
args
}))
});

View File

@ -1,8 +1,8 @@
/* global prefs: true, contextMenus, FIREFOX_NO_DOM_STORAGE */
/* global promisify */
/* exported prefs */
'use strict';
// eslint-disable-next-line no-var
var prefs = new function Prefs() {
const prefs = (() => {
const defaults = {
'openEditInWindow': false, // new editor opens in a own browser window
'windowPosition': {}, // detached window position
@ -98,29 +98,33 @@ var prefs = new function Prefs() {
};
const values = deepCopy(defaults);
const affectsIcon = [
'show-badge',
'disableAll',
'badgeDisabled',
'badgeNormal',
'iconset',
];
const onChange = {
any: new Set(),
specific: new Map(),
};
// coalesce multiple pref changes in broadcast
let broadcastPrefs = {};
Object.defineProperties(this, {
defaults: {value: deepCopy(defaults)},
readOnlyValues: {value: {}},
const initializing = promisify(chrome.storage.sync.get.bind(chrome.storage.sync))('settings')
.then(result => {
if (result.settings) {
setAll(result.settings, true);
}
});
Object.assign(Prefs.prototype, {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync' || !changes.settings || !changes.settings.newValue) {
return;
}
initializing.then(() => setAll(changes.settings.newValue, true));
});
let timer;
// coalesce multiple pref changes in broadcast
// let changes = {};
return {
initializing,
defaults,
get(key, defaultValue) {
if (key in values) {
return values[key];
@ -133,62 +137,11 @@ var prefs = new function Prefs() {
}
console.warn("No default preference for '%s'", key);
},
getAll() {
return deepCopy(values);
},
set(key, value, {broadcast = true, sync = true, fromBroadcast} = {}) {
const oldValue = values[key];
switch (typeof defaults[key]) {
case typeof value:
break;
case 'string':
value = String(value);
break;
case 'number':
value |= 0;
break;
case 'boolean':
value = value === true || value === 'true';
break;
}
values[key] = value;
defineReadonlyProperty(this.readOnlyValues, key, value);
const hasChanged = !equal(value, oldValue);
if (!fromBroadcast || FIREFOX_NO_DOM_STORAGE) {
localStorage[key] = typeof defaults[key] === 'object'
? JSON.stringify(value)
: value;
}
if (!fromBroadcast && broadcast && hasChanged) {
this.broadcast(key, value, {sync});
}
if (hasChanged) {
const specific = onChange.specific.get(key);
if (typeof specific === 'function') {
specific(key, value);
} else if (specific instanceof Set) {
for (const listener of specific.values()) {
listener(key, value);
}
}
for (const listener of onChange.any.values()) {
listener(key, value);
}
}
},
reset: key => this.set(key, deepCopy(defaults[key])),
broadcast(key, value, {sync = true} = {}) {
broadcastPrefs[key] = value;
debounce(doBroadcast);
if (sync) {
debounce(doSyncSet);
}
},
set,
reset: key => set(key, deepCopy(defaults[key])),
subscribe(keys, listener) {
// keys: string[] ids
// or a falsy value to subscribe to everything
@ -208,7 +161,6 @@ var prefs = new function Prefs() {
onChange.any.add(listener);
}
},
unsubscribe(keys, listener) {
if (keys) {
for (const key of keys) {
@ -226,147 +178,58 @@ var prefs = new function Prefs() {
onChange.all.remove(listener);
}
},
});
};
{
const importFromBG = () =>
API.getPrefs().then(prefs => {
const props = {};
for (const id in prefs) {
const value = prefs[id];
values[id] = value;
props[id] = {value: deepCopy(value)};
function setAll(settings, synced) {
for (const [key, value] of Object.entries(settings)) {
set(key, value, synced);
}
Object.defineProperties(this.readOnlyValues, props);
});
// Unlike chrome.storage or messaging, HTML5 localStorage is synchronous and always ready,
// so we'll mirror the prefs to avoid using the wrong defaults during the startup phase
const importFromLocalStorage = () => {
forgetOutdatedDefaults(localStorage);
for (const key in defaults) {
const defaultValue = defaults[key];
let value = localStorage[key];
if (typeof value === 'string') {
switch (typeof defaultValue) {
case 'boolean':
value = value.toLowerCase() === 'true';
}
function set(key, value, synced = false) {
const oldValue = values[key];
switch (typeof defaults[key]) {
case typeof value:
break;
case 'string':
value = String(value);
break;
case 'number':
value |= 0;
break;
case 'object':
value = tryJSONparse(value) || defaultValue;
case 'boolean':
value = value === true || value === 'true';
break;
}
} else if (FIREFOX_NO_DOM_STORAGE && BG) {
value = BG.localStorage[key];
value = value === undefined ? defaultValue : value;
localStorage[key] = value;
} else {
value = defaultValue;
if (equal(value, oldValue)) {
return;
}
if (BG === window) {
// when in bg page, .set() will write to localStorage
this.set(key, value, {broadcast: false, sync: false});
} else {
values[key] = value;
defineReadonlyProperty(this.readOnlyValues, key, value);
}
}
return Promise.resolve();
};
(FIREFOX_NO_DOM_STORAGE && !BG ? importFromBG() : importFromLocalStorage()).then(() => {
if (BG && BG !== window) return;
if (BG === window) {
affectsIcon.forEach(key => this.broadcast(key, values[key], {sync: false}));
chromeSync.getValue('settings').then(settings => importFromSync.call(this, settings));
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && 'settings' in changes) {
importFromSync.call(this, changes.settings.newValue);
}
});
});
}
// any access to chrome API takes time due to initialization of bindings
window.addEventListener('load', function _() {
window.removeEventListener('load', _);
chrome.runtime.onMessage.addListener(msg => {
if (msg.prefs) {
for (const id in msg.prefs) {
prefs.set(id, msg.prefs[id], {fromBroadcast: true});
}
}
});
});
// register hotkeys
if (FIREFOX && (browser.commands || {}).update) {
const hotkeyPrefs = Object.keys(values).filter(k => k.startsWith('hotkey.'));
this.subscribe(hotkeyPrefs, (name, value) => {
try {
name = name.split('.')[1];
if (value.trim()) {
browser.commands.update({name, shortcut: value}).catch(ignoreChromeError);
} else {
browser.commands.reset(name).catch(ignoreChromeError);
}
} catch (e) {}
});
}
return;
function doBroadcast() {
if (BG && BG === window && !BG.dbExec.initialized) {
window.addEventListener('storageReady', function _() {
window.removeEventListener('storageReady', _);
doBroadcast();
});
emitChange(key, value);
if (synced || timer) {
return;
}
const affects = {
all: 'disableAll' in broadcastPrefs
|| 'exposeIframes' in broadcastPrefs,
};
if (!affects.all) {
for (const key in broadcastPrefs) {
affects.icon = affects.icon || affectsIcon.includes(key);
affects.popup = affects.popup || key.startsWith('popup');
affects.editor = affects.editor || key.startsWith('editor');
affects.manager = affects.manager || key.startsWith('manage');
}
}
notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs, affects});
broadcastPrefs = {};
timer = setTimeout(syncPrefs);
}
function doSyncSet() {
chromeSync.setValue('settings', values);
function emitChange(key, value) {
const specific = onChange.specific.get(key);
if (typeof specific === 'function') {
specific(key, value);
} else if (specific instanceof Set) {
for (const listener of specific.values()) {
listener(key, value);
}
function importFromSync(synced = {}) {
forgetOutdatedDefaults(synced);
for (const key in defaults) {
if (key in synced) {
this.set(key, synced[key], {sync: false});
}
for (const listener of onChange.any.values()) {
listener(key, value);
}
}
function forgetOutdatedDefaults(storage) {
// our linter runs as a worker so we can reduce the delay and forget the old default values
if (Number(storage['editor.lintDelay']) === 500) delete storage['editor.lintDelay'];
if (Number(storage['editor.lintReportDelay']) === 4500) delete storage['editor.lintReportDelay'];
}
function defineReadonlyProperty(obj, key, value) {
const copy = deepCopy(value);
if (typeof copy === 'object') {
Object.freeze(copy);
}
Object.defineProperty(obj, key, {value: copy, configurable: true});
function syncPrefs() {
// FIXME: we always set the entire object? Ideally, this should only use `changes`.
chrome.storage.sync.set({settings: values});
timer = null;
}
function equal(a, b) {
@ -389,7 +252,7 @@ var prefs = new function Prefs() {
}
function contextDeleteMissing() {
return CHROME && (
return /Chrome\/\d+/.test(navigator.userAgent) && (
// detect browsers without Delete by looking at the end of UA string
/Vivaldi\/[\d.]+$/.test(navigator.userAgent) ||
// Chrome and co.
@ -398,44 +261,17 @@ var prefs = new function Prefs() {
!Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')
);
}
}();
// Accepts an array of pref names (values are fetched via prefs.get)
// and establishes a two-way connection between the document elements and the actual prefs
function setupLivePrefs(
IDs = Object.getOwnPropertyNames(prefs.readOnlyValues)
.filter(id => $('#' + id))
) {
const checkedProps = {};
for (const id of IDs) {
const element = $('#' + id);
checkedProps[id] = element.type === 'checkbox' ? 'checked' : 'value';
updateElement({id, element, force: true});
element.addEventListener('change', onChange);
}
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
function onChange() {
const value = this[checkedProps[this.id]];
if (prefs.get(this.id) !== value) {
prefs.set(this.id, value);
}
}
function updateElement({
id,
value = prefs.get(id),
element = $('#' + id),
force,
}) {
if (!element) {
prefs.unsubscribe(IDs, updateElement);
return;
}
const prop = checkedProps[id];
if (force || element[prop] !== value) {
element[prop] = value;
element.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
function deepCopy(obj) {
if (!obj || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(deepCopy);
}
return Object.keys(obj).reduce((output, key) => {
output[key] = deepCopy(obj[key]);
return output;
}, {});
}
})();

24
js/promisify.js Normal file
View File

@ -0,0 +1,24 @@
/* exported promisify */
'use strict';
/*
Convert chrome APIs into promises. Example:
const storageSyncGet = promisify(chrome.storage.sync.get.bind(chrome.storage.sync));
storageSyncGet(['key']).then(result => {...});
*/
function promisify(fn) {
return (...args) =>
new Promise((resolve, reject) => {
fn(...args, (...result) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
return;
}
resolve(
result.length === 0 ? undefined :
result.length === 1 ? result[0] : result
);
});
});
}

View File

@ -1,8 +1,8 @@
/* exported loadScript */
'use strict';
// loadScript(script: Array<Promise|string>|string): Promise
// eslint-disable-next-line no-var
var loadScript = (() => {
const loadScript = (() => {
const cache = new Map();
function inject(file) {
@ -26,7 +26,7 @@ var loadScript = (() => {
el.onload = () => {
el.onload = null;
el.onerror = null;
resolve();
resolve(el);
};
el.onerror = () => {
el.onload = null;
@ -37,11 +37,15 @@ var loadScript = (() => {
});
}
return files => {
return (files, noCache = false) => {
if (!Array.isArray(files)) {
files = [files];
}
return Promise.all(files.map(f => (typeof f === 'string' ? inject(f) : f)));
return Promise.all(files.map(f =>
typeof f !== 'string' ? f :
noCache ? doInject(f) :
inject(f)
));
};
})();
@ -65,7 +69,10 @@ var loadScript = (() => {
subscribers.set(srcSuffix, [resolve]);
}
// a resolved Promise won't reject anymore
setTimeout(() => emptyAfterCleanup(srcSuffix) + reject(), timeout);
setTimeout(() => {
emptyAfterCleanup(srcSuffix);
reject(new Error('Timeout'));
}, timeout);
});
};

View File

@ -1,5 +1,19 @@
/* exported styleSectionsEqual styleCodeEmpty calcStyleDigest */
'use strict';
function styleCodeEmpty(code) {
if (!code) {
return true;
}
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
while (rx.exec(code)) {
if (rx.lastIndex === code.length) {
return true;
}
}
return false;
}
/**
* @param {Style} a - first style object
* @param {Style} b - second style object
@ -54,3 +68,31 @@ function styleSectionsEqual(a, b, {ignoreCode, checkSource} = {}) {
);
}
}
function normalizeStyleSections({sections}) {
// retain known properties in an arbitrarily predefined order
return (sections || []).map(section => ({
code: section.code || '',
urls: section.urls || [],
urlPrefixes: section.urlPrefixes || [],
domains: section.domains || [],
regexps: section.regexps || [],
}));
}
function calcStyleDigest(style) {
const jsonString = style.usercssData ?
style.sourceCode : JSON.stringify(normalizeStyleSections(style));
const text = new TextEncoder('utf-8').encode(jsonString);
return crypto.subtle.digest('SHA-1', text).then(hex);
function hex(buffer) {
const parts = [];
const PAD8 = '00000000';
const view = new DataView(buffer);
for (let i = 0; i < view.byteLength; i += 4) {
parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8));
}
return parts.join('');
}
}

View File

@ -1,20 +1,14 @@
/* global loadScript */
/* global loadScript tryJSONparse */
/* exported chromeLocal chromeSync */
'use strict';
// eslint-disable-next-line no-var
var [chromeLocal, chromeSync] = (() => {
const native = 'sync' in chrome.storage &&
!chrome.runtime.id.includes('@temporary');
if (!native && BG !== window) {
setupOnChangeRelay();
}
const [chromeLocal, chromeSync] = (() => {
return [
createWrapper('local'),
createWrapper('sync'),
];
function createWrapper(name) {
if (!native) createDummyStorage(name);
const storage = chrome.storage[name];
const wrapper = {
get: data => new Promise(resolve => storage.get(data, resolve)),
@ -58,39 +52,10 @@ var [chromeLocal, chromeSync] = (() => {
return wrapper;
}
function createDummyStorage(name) {
chrome.storage[name] = {
get: (data, cb) => API.dummyStorageGet({data, name}).then(cb),
set: (data, cb) => API.dummyStorageSet({data, name}).then(cb),
remove: (data, cb) => API.dummyStorageRemove({data, name}).then(cb),
};
}
function loadLZStringScript() {
return window.LZString ?
Promise.resolve(window.LZString) :
loadScript('/vendor/lz-string-unsafe/lz-string-unsafe.min.js').then(() =>
(window.LZString = window.LZString || window.LZStringUnsafe));
}
function setupOnChangeRelay() {
const listeners = new Set();
const onMessage = msg => {
if (!msg.dummyStorageChanges) return;
for (const fn of listeners.values()) {
fn(msg.dummyStorageChanges, msg.dummyStorageName);
}
};
Object.assign(chrome.storage.onChanged, {
addListener(fn) {
if (!listeners.size) chrome.runtime.onMessage.addListener(onMessage);
listeners.add(fn);
},
hasListener: fn => listeners.has(fn),
removeListener(fn) {
listeners.delete(fn);
if (!listeners.size) chrome.runtime.onMessage.removeListener(onMessage);
}
});
}
})();

View File

@ -1,514 +1,55 @@
/* global loadScript semverCompare colorConverter styleCodeEmpty */
/* global backgroundWorker */
/* exported usercss */
'use strict';
// eslint-disable-next-line no-var
var usercss = (() => {
// true = global
// false or 0 = private
// <string> = global key name
// <function> = (style, newValue)
const KNOWN_META = new Map([
['author', true],
['advanced', 0],
['description', true],
['homepageURL', 'url'],
['icon', 0],
['license', 0],
['name', true],
['namespace', 0],
//['noframes', 0],
['preprocessor', 0],
['supportURL', 0],
['updateURL', (style, newValue) => {
// always preserve locally installed style's updateUrl
if (!/^file:/.test(style.updateUrl)) {
style.updateUrl = newValue;
}
}],
['var', 0],
['version', 0],
]);
const MANDATORY_META = ['name', 'namespace', 'version'];
const META_VARS = ['text', 'color', 'checkbox', 'select', 'dropdown', 'image', 'number', 'range'];
const META_URLS = [...KNOWN_META.keys()].filter(k => k.endsWith('URL'));
const BUILDER = {
default: {
postprocess(sections, vars) {
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) {
return loadScript('/vendor/stylus-lang-bundle/stylus.min.js').then(() => (
new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
if (!Error.captureStackTrace) Error.captureStackTrace = () => {};
window.stylus(varDef + source).render((err, output) => {
if (err) {
reject(err);
} else {
resolve(output);
}
});
})
));
}
},
less: {
preprocess(source, vars) {
window.less = window.less || {
logLevel: 0,
useFileCache: false,
const usercss = (() => {
const GLOBAL_METAS = {
author: undefined,
description: undefined,
homepageURL: 'url',
// updateURL: 'updateUrl',
name: undefined,
};
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
return loadScript('/vendor/less/less.min.js')
.then(() => window.less.render(varDefs + source))
.then(({css}) => css);
}
},
uso: {
preprocess(source, vars) {
const pool = new Map();
return Promise.resolve(doReplace(source));
function getValue(name, rgb) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), true);
}
return null;
}
if (rgb) {
if (vars[name].type === 'color') {
const color = colorConverter.parse(vars[name].value);
if (!color) return null;
const {r, g, b} = color;
return `${r}, ${g}, ${b}`;
}
return null;
}
if (vars[name].type === 'dropdown' || vars[name].type === 'select') {
// prevent infinite recursion
pool.set(name, '');
return doReplace(vars[name].value);
}
return vars[name].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);
});
}
}
}
};
const RX_NUMBER = /-?\d+(\.\d+)?\s*/y;
const RX_WHITESPACE = /\s*/y;
const RX_WORD = /([\w-]+)\s*/y;
const RX_STRING_BACKTICK = /(`(?:\\`|[\s\S])*?`)\s*/y;
const RX_STRING_QUOTED = /((['"])(?:\\\2|[^\n])*?\2|\w+)\s*/y;
const worker = {};
function getMetaSource(source) {
const commentRe = /\/\*[\s\S]*?\*\//g;
const metaRe = /==userstyle==[\s\S]*?==\/userstyle==/i;
let m;
// iterate through each comment
while ((m = commentRe.exec(source))) {
const commentSource = source.slice(m.index, m.index + m[0].length);
const n = commentSource.match(metaRe);
if (n) {
return {
index: m.index + n.index,
text: n[0]
};
}
}
return {text: '', index: 0};
}
function parseWord(state, error = 'invalid word') {
RX_WORD.lastIndex = state.re.lastIndex;
const match = RX_WORD.exec(state.text);
if (!match) {
throw new Error((state.errorPrefix || '') + error);
}
state.value = match[1];
state.re.lastIndex += match[0].length;
}
function parseVar(state) {
const result = {
type: null,
label: null,
name: null,
value: null,
default: null,
options: null
};
parseWord(state, 'missing type');
result.type = state.type = state.value;
if (!META_VARS.includes(state.type)) {
throw new Error(`unknown type: ${state.type}`);
}
parseWord(state, 'missing name');
result.name = state.value;
parseString(state);
result.label = state.value;
const {re, type, text} = state;
switch (type === 'image' && state.key === 'var' ? '@image@var' : type) {
case 'checkbox': {
const match = text.slice(re.lastIndex).match(/([01])\s+/);
if (!match) {
throw new Error('value must be 0 or 1');
}
re.lastIndex += match[0].length;
result.default = match[1];
break;
}
case 'select':
case '@image@var': {
state.errorPrefix = 'Invalid JSON: ';
parseJSONValue(state);
state.errorPrefix = '';
const extractDefaultOption = (key, value) => {
if (key.endsWith('*')) {
const option = createOption(key.slice(0, -1), value);
result.default = option.name;
return option;
}
return createOption(key, value);
};
if (Array.isArray(state.value)) {
result.options = state.value.map(k => extractDefaultOption(k));
} else {
result.options = Object.keys(state.value).map(k => extractDefaultOption(k, state.value[k]));
}
if (result.default === null) {
result.default = (result.options[0] || {}).name || '';
}
break;
}
case 'number':
case 'range': {
state.errorPrefix = 'Invalid JSON: ';
parseJSONValue(state);
state.errorPrefix = '';
// [default, start, end, step, units] (start, end, step & units are optional)
if (Array.isArray(state.value) && state.value.length) {
// label may be placed anywhere
result.units = (state.value.find(i => typeof i === 'string') || '').replace(/[\d.+-]/g, '');
const range = state.value.filter(i => typeof i === 'number' || i === null);
result.default = range[0];
result.min = range[1];
result.max = range[2];
result.step = range[3] === 0 ? 1 : range[3];
}
break;
}
case 'dropdown':
case 'image': {
if (text[re.lastIndex] !== '{') {
throw new Error('no open {');
}
result.options = [];
re.lastIndex++;
while (text[re.lastIndex] !== '}') {
const option = {};
parseStringUnquoted(state);
option.name = state.value;
parseString(state);
option.label = state.value;
if (type === 'dropdown') {
parseEOT(state);
} else {
parseString(state);
}
option.value = state.value;
result.options.push(option);
}
re.lastIndex++;
eatWhitespace(state);
result.default = result.options[0].name;
break;
}
default: {
// text, color
parseStringToEnd(state);
result.default = state.value;
}
}
state.usercssData.vars[result.name] = result;
validateVar(result);
}
function createOption(label, value) {
let name;
const match = label.match(/^(\w+):(.*)/);
if (match) {
([, name, label] = match);
}
if (!name) {
name = label;
}
if (!value) {
value = name;
}
return {name, label, value};
}
function parseEOT(state) {
const re = /<<<EOT([\s\S]+?)EOT;/y;
re.lastIndex = state.re.lastIndex;
const match = state.text.match(re);
if (!match) {
throw new Error('missing EOT');
}
state.re.lastIndex += match[0].length;
state.value = match[1].trim().replace(/\*\\\//g, '*/');
eatWhitespace(state);
}
function parseStringUnquoted(state) {
const pos = state.re.lastIndex;
const nextQuoteOrEOL = posOrEnd(state.text, '"', pos);
state.re.lastIndex = nextQuoteOrEOL;
state.value = state.text.slice(pos, nextQuoteOrEOL).trim().replace(/\s+/g, '-');
}
function parseString(state) {
const pos = state.re.lastIndex;
const rx = state.text[pos] === '`' ? RX_STRING_BACKTICK : RX_STRING_QUOTED;
rx.lastIndex = pos;
const match = rx.exec(state.text);
if (!match) {
throw new Error((state.errorPrefix || '') + 'Quoted string expected');
}
state.re.lastIndex += match[0].length;
state.value = unquote(match[1]);
}
function parseJSONValue(state) {
const JSON_PRIME = {
__proto__: null,
'null': null,
'true': true,
'false': false
};
const {text, re, errorPrefix} = state;
if (text[re.lastIndex] === '{') {
// object
const obj = {};
re.lastIndex++;
eatWhitespace(state);
while (text[re.lastIndex] !== '}') {
parseString(state);
const key = state.value;
if (text[re.lastIndex] !== ':') {
throw new Error(`${errorPrefix}missing ':'`);
}
re.lastIndex++;
eatWhitespace(state);
parseJSONValue(state);
obj[key] = state.value;
if (text[re.lastIndex] === ',') {
re.lastIndex++;
eatWhitespace(state);
} else if (text[re.lastIndex] !== '}') {
throw new Error(`${errorPrefix}missing ',' or '}'`);
}
}
re.lastIndex++;
eatWhitespace(state);
state.value = obj;
} else if (text[re.lastIndex] === '[') {
// array
const arr = [];
re.lastIndex++;
eatWhitespace(state);
while (text[re.lastIndex] !== ']') {
parseJSONValue(state);
arr.push(state.value);
if (text[re.lastIndex] === ',') {
re.lastIndex++;
eatWhitespace(state);
} else if (text[re.lastIndex] !== ']') {
throw new Error(`${errorPrefix}missing ',' or ']'`);
}
}
re.lastIndex++;
eatWhitespace(state);
state.value = arr;
} else if (text[re.lastIndex] === '"' || text[re.lastIndex] === '`') {
// string
parseString(state);
} else if (/\d/.test(text[re.lastIndex])) {
// number
parseNumber(state);
} else {
parseWord(state);
if (!(state.value in JSON_PRIME)) {
throw new Error(`${errorPrefix}unknown literal '${state.value}'`);
}
state.value = JSON_PRIME[state.value];
}
}
function parseNumber(state) {
RX_NUMBER.lastIndex = state.re.lastIndex;
const match = RX_NUMBER.exec(state.text);
if (!match) {
throw new Error((state.errorPrefix || '') + 'invalid number');
}
state.value = Number(match[0].trim());
state.re.lastIndex += match[0].length;
}
function eatWhitespace(state) {
RX_WHITESPACE.lastIndex = state.re.lastIndex;
state.re.lastIndex += RX_WHITESPACE.exec(state.text)[0].length;
}
function parseStringToEnd(state) {
const EOL = posOrEnd(state.text, '\n', state.re.lastIndex);
const match = state.text.slice(state.re.lastIndex, EOL);
state.value = unquote(match.trim());
state.re.lastIndex += match.length;
}
function unquote(s) {
const q = s[0];
if (q === s[s.length - 1] && (q === '"' || q === "'" || q === '`')) {
// http://www.json.org/
return s.slice(1, -1).replace(
new RegExp(`\\\\([${q}\\\\/bfnrt]|u[0-9a-fA-F]{4})`, 'g'),
s => {
if (s[1] === q) {
return q;
}
return JSON.parse(`"${s}"`);
}
);
}
return s;
}
function posOrEnd(haystack, needle, start) {
const pos = haystack.indexOf(needle, start);
return pos < 0 ? haystack.length : pos;
}
const RX_META = /\/\*\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i;
const ERR_ARGS_IS_LIST = new Set(['missingMandatory', 'missingChar']);
return {buildMeta, buildCode, assignVars};
function buildMeta(sourceCode) {
sourceCode = sourceCode.replace(/\r\n?/g, '\n');
const usercssData = {
vars: {}
};
const style = {
reason: 'install',
enabled: true,
sourceCode,
sections: [],
usercssData
sections: []
};
const {text, index: metaIndex} = getMetaSource(sourceCode);
const re = /@(\w+)[ \t\xA0]*/mg;
const state = {style, re, text, usercssData};
function doParse() {
let match;
while ((match = re.exec(text))) {
const key = state.key = match[1];
const route = KNOWN_META.get(key);
if (route === undefined) {
continue;
}
if (key === 'var' || key === 'advanced') {
if (key === 'advanced') {
state.maybeUSO = true;
}
parseVar(state);
} else {
parseStringToEnd(state);
usercssData[key] = state.value;
}
let value = state.value;
if (key === 'version') {
value = usercssData[key] = normalizeVersion(value);
validateVersion(value);
}
if (META_URLS.includes(key)) {
validateUrl(key, value);
}
switch (typeof route) {
case 'function':
route(style, value);
break;
case 'string':
style[route] = value;
break;
default:
if (route) {
style[key] = value;
}
}
}
const match = sourceCode.match(RX_META);
if (!match) {
throw new Error('can not find metadata');
}
try {
doParse();
} catch (e) {
// the source code string offset
e.index = metaIndex + state.re.lastIndex;
throw e;
return backgroundWorker.parseUsercssMeta(match[0], match.index)
.catch(err => {
if (err.code) {
const args = ERR_ARGS_IS_LIST.has(err.code) ? drawList(err.args) : err.args;
const message = chrome.i18n.getMessage(`meta_${err.code}`, args);
if (message) {
err.message = message;
}
if (state.maybeUSO && !usercssData.preprocessor) {
usercssData.preprocessor = 'uso';
}
validateStyle(style);
throw err;
})
.then(({metadata}) => {
style.usercssData = metadata;
for (const [key, value = key] of Object.entries(GLOBAL_METAS)) {
style[value] = metadata[key];
}
return style;
});
}
function normalizeVersion(version) {
// https://docs.npmjs.com/misc/semver#versions
if (version[0] === 'v' || version[0] === '=') {
return version.slice(1);
}
return version;
function drawList(items) {
return items.map(i => i.length === 1 ? JSON.stringify(i) : i).join(', ');
}
/**
@ -518,136 +59,37 @@ var usercss = (() => {
* when allowErrors is falsy or {style, errors} object when allowErrors is truthy
*/
function buildCode(style, allowErrors) {
const {usercssData: {preprocessor, vars}, sourceCode} = style;
let builder;
if (preprocessor) {
if (!BUILDER[preprocessor]) {
return Promise.reject(chrome.i18n.getMessage('styleMetaErrorPreprocessor', preprocessor));
}
builder = BUILDER[preprocessor];
} else {
builder = BUILDER.default;
}
const sVars = simpleVars(vars);
return (
Promise.resolve(
builder.preprocess && builder.preprocess(sourceCode, sVars) ||
sourceCode)
.then(mozStyle => invokeWorker({
action: 'parse',
styleId: style.id,
code: mozStyle,
}))
const match = style.sourceCode.match(RX_META);
return backgroundWorker.compileUsercss(
style.usercssData.preprocessor,
style.sourceCode.slice(0, match.index) + style.sourceCode.slice(match.index + match[0].length),
style.usercssData.vars
)
.then(({sections, errors}) => {
if (!errors.length) errors = false;
if (!sections.length || errors && !allowErrors) {
return Promise.reject(errors);
throw errors;
}
style.sections = sections;
if (builder.postprocess) builder.postprocess(style.sections, sVars);
return allowErrors ? {style, errors} : style;
}));
}
function simpleVars(vars) {
// 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 validateStyle({usercssData: data}) {
for (const prop of MANDATORY_META) {
if (!data[prop]) {
throw new Error(chrome.i18n.getMessage('styleMissingMeta', prop));
}
}
validateVersion(data.version);
META_URLS.forEach(k => validateUrl(k, data[k]));
Object.keys(data.vars).forEach(k => validateVar(data.vars[k]));
}
function validateVersion(version) {
semverCompare(version, '0.0.0');
}
function validateUrl(key, url) {
if (!url) {
return;
}
url = new URL(url);
if (!/^https?:/.test(url.protocol)) {
throw new Error(`${url.protocol} is not a valid protocol in ${key}`);
}
}
function validateVar(va, value = 'default') {
if (va.type === 'select' || va.type === 'dropdown') {
if (va.options.every(o => o.name !== va[value])) {
throw new Error(chrome.i18n.getMessage('styleMetaErrorSelectValueMismatch'));
}
} else if (va.type === 'checkbox' && !/^[01]$/.test(va[value])) {
throw new Error(chrome.i18n.getMessage('styleMetaErrorCheckbox'));
} else if (va.type === 'color') {
va[value] = colorConverter.format(colorConverter.parse(va[value]), 'rgb');
} else if ((va.type === 'number' || va.type === 'range') && typeof va[value] !== 'number') {
throw new Error(chrome.i18n.getMessage('styleMetaErrorRangeOrNumber', va.type));
}
}
function assignVars(style, oldStyle) {
const {usercssData: {vars}} = style;
const {usercssData: {vars: oldVars}} = oldStyle;
if (!vars || !oldVars) {
return Promise.resolve();
}
// The type of var might be changed during the update. Set value to null if the value is invalid.
for (const key of Object.keys(vars)) {
if (oldVars[key] && oldVars[key].value) {
vars[key].value = oldVars[key].value;
try {
validateVar(vars[key], 'value');
} catch (e) {
vars[key].value = null;
}
}
}
}
function invokeWorker(message) {
if (!worker.queue) {
worker.instance = new Worker('/background/parserlib-loader.js');
worker.queue = [];
worker.instance.onmessage = ({data}) => {
worker.queue.shift().resolve(data.__ERROR__ ? Promise.reject(data.__ERROR__) : data);
if (worker.queue.length) {
worker.instance.postMessage(worker.queue[0].message);
}
};
}
return new Promise(resolve => {
worker.queue.push({message, resolve});
if (worker.queue.length === 1) {
worker.instance.postMessage(message);
}
return backgroundWorker.nullifyInvalidVars(vars)
.then(vars => {
style.usercssData.vars = vars;
});
}
return {buildMeta, buildCode, assignVars, invokeWorker};
})();

98
js/worker-util.js Normal file
View File

@ -0,0 +1,98 @@
/* global importScripts */
/* exported workerUtil */
'use strict';
const workerUtil = (() => {
const loadedScripts = new Set();
return {createWorker, createAPI, loadScript, cloneError};
function createWorker({url, lifeTime = 30}) {
let worker;
let id;
let timer;
const pendingResponse = new Map();
return new Proxy({}, {
get: (target, prop) =>
(...args) => {
if (!worker) {
init();
}
return invoke(prop, args);
}
});
function init() {
id = 0;
worker = new Worker(url);
worker.onmessage = onMessage;
}
function uninit() {
worker.onmessage = null;
worker.terminate();
worker = null;
}
function onMessage(e) {
const message = e.data;
pendingResponse.get(message.id)[message.error ? 'reject' : 'resolve'](message.data);
pendingResponse.delete(message.id);
if (!pendingResponse.size && lifeTime >= 0) {
timer = setTimeout(uninit, lifeTime * 1000);
}
}
function invoke(action, args) {
return new Promise((resolve, reject) => {
pendingResponse.set(id, {resolve, reject});
clearTimeout(timer);
worker.postMessage({
id,
action,
args
});
id++;
});
}
}
function createAPI(methods) {
self.onmessage = e => {
const message = e.data;
Promise.resolve()
.then(() => methods[message.action](...message.args))
.then(result => ({
id: message.id,
error: false,
data: result
}))
.catch(err => ({
id: message.id,
error: true,
data: cloneError(err)
}))
.then(data => self.postMessage(data));
};
}
function cloneError(err) {
return Object.assign({
name: err.name,
stack: err.stack,
message: err.message,
lineNumber: err.lineNumber,
columnNumber: err.columnNumber,
fileName: err.fileName
}, err);
}
function loadScript(...scripts) {
const urls = scripts.filter(u => !loadedScripts.has(u));
if (!urls.length) {
return;
}
importScripts(...urls);
urls.forEach(u => loadedScripts.add(u));
}
})();

View File

@ -146,26 +146,27 @@
</details>
</template>
<script src="js/promisify.js"></script>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/prefs.js"></script>
<script src="js/msg.js"></script>
<script src="content/apply.js"></script>
<script src="js/localization.js"></script>
<script src="manage/filters.js"></script>
<script src="manage/sort.js"></script>
<script src="manage/manage.js"></script>
<script src="vendor-overwrites/colorpicker/colorconverter.js" async></script>
<script src="vendor-overwrites/colorpicker/colorpicker.js" async></script>
<script src="manage/config-dialog.js" async></script>
<script src="manage/updater-ui.js" async></script>
<script src="manage/object-diff.js" async></script>
<script src="manage/import-export.js" async></script>
<script src="manage/incremental-search.js" async></script>
<script src="msgbox/msgbox.js" async></script>
<script src="js/sections-equal.js" async></script>
<script src="js/storage-util.js" async></script>
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
<script src="manage/config-dialog.js"></script>
<script src="manage/updater-ui.js"></script>
<script src="manage/object-diff.js"></script>
<script src="manage/import-export.js"></script>
<script src="manage/incremental-search.js"></script>
<script src="msgbox/msgbox.js"></script>
<script src="js/sections-util.js"></script>
<script src="js/storage-util.js"></script>
<script src="sync/vendor/dropbox/dropbox-sdk.js" async></script>
<script src="sync/vendor/zipjs/zip.js" defer></script>

View File

@ -1,4 +1,6 @@
/* global messageBox */
/* global messageBox deepCopy $create $createLink $ t tWordBreak
prefs setupLivePrefs debounce API */
/* exported configDialog */
'use strict';
function configDialog(style) {
@ -117,13 +119,13 @@ function configDialog(style) {
return;
}
if (!bgStyle) {
API.getStyles({id: style.id, omitCode: !BG})
.then(([bgStyle]) => save({anyChangeIsDirty}, bgStyle || {}));
API.getStyle(style.id, true)
.catch(() => ({}))
.then(bgStyle => save({anyChangeIsDirty}, bgStyle));
return;
}
style = style.sections ? Object.assign({}, style) : style;
style.enabled = true;
style.reason = 'config';
style.sourceCode = null;
style.sections = null;
const styleVars = style.usercssData.vars;
@ -171,9 +173,9 @@ function configDialog(style) {
return;
}
saving = true;
return API.saveUsercss(style)
.then(saved => {
varsInitial = getInitialValues(saved.usercssData.vars);
return API.configUsercssVars(style.id, style.usercssData.vars)
.then(newVars => {
varsInitial = getInitialValues(newVars);
vars.forEach(va => onchange({target: va.input, justSaved: true}));
renderValues();
updateButtons();
@ -182,7 +184,7 @@ function configDialog(style) {
.catch(errors => {
const el = $('.config-error', messageBox.element) ||
$('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error'));
el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors;
el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors.message || String(errors);
})
.then(() => {
saving = false;

View File

@ -1,5 +1,5 @@
/* global installed messageBox */
/* global sorter */
/* global installed messageBox sorter $ $$ $create t debounce prefs API onDOMready */
/* exported filterAndAppend */
'use strict';
const filtersSelector = {
@ -114,7 +114,7 @@ onDOMready().then(() => {
}
if (value !== undefined) {
el.lastValue = value;
if (el.id in prefs.readOnlyValues) {
if (el.id in prefs.defaults) {
prefs.set(el.id, false);
}
}

View File

@ -1,9 +1,54 @@
/* global messageBox handleUpdate handleDelete applyOnMessage styleSectionsEqual */
/* global messageBox styleSectionsEqual getOwnTab API onDOMready
tryJSONparse scrollElementIntoView $ $$ API $create t animateElement */
'use strict';
const STYLISH_DUMP_FILE_EXT = '.txt';
const STYLUS_BACKUP_FILE_EXT = '.json';
onDOMready().then(() => {
$('#file-all-styles').onclick = exportToFile;
$('#unfile-all-styles').onclick = () => {
importFromFile({fileTypeFilter: STYLUS_BACKUP_FILE_EXT});
};
Object.assign(document.body, {
ondragover(event) {
const hasFiles = event.dataTransfer.types.includes('Files');
event.dataTransfer.dropEffect = hasFiles || event.target.type === 'search' ? 'copy' : 'none';
this.classList.toggle('dropzone', hasFiles);
if (hasFiles) {
event.preventDefault();
clearTimeout(this.fadeoutTimer);
this.classList.remove('fadeout');
}
},
ondragend() {
animateElement(this, {className: 'fadeout', removeExtraClasses: ['dropzone']}).then(() => {
this.style.animationDuration = '';
});
},
ondragleave(event) {
try {
// in Firefox event.target could be XUL browser and hence there is no permission to access it
if (event.target === this) {
this.ondragend();
}
} catch (e) {
this.ondragend();
}
},
ondrop(event) {
this.ondragend();
if (event.dataTransfer.files.length) {
event.preventDefault();
if ($('#only-updates input').checked) {
$('#only-updates input').click();
}
importFromFile({file: event.dataTransfer.files[0]});
}
},
});
});
function importFromFile({fileTypeFilter, file} = {}) {
return new Promise(resolve => {
@ -41,7 +86,7 @@ function importFromFile({fileTypeFilter, file} = {}) {
importFromString(text) :
getOwnTab().then(tab => {
tab.url = URL.createObjectURL(new Blob([text], {type: 'text/css'}));
return API.installUsercss({direct: true, tab})
return API.openUsercssInstallPage({direct: true, tab})
.then(() => URL.revokeObjectURL(tab.url));
})
).then(numStyles => {
@ -56,19 +101,14 @@ function importFromFile({fileTypeFilter, file} = {}) {
}
function importFromString(jsonString, oldStyles) {
if (!oldStyles) {
return API.getStyles().then(styles => importFromString(jsonString, styles));
function importFromString(jsonString) {
const json = tryJSONparse(jsonString);
if (!Array.isArray(json)) {
return Promise.reject(new Error('the backup is not a valid JSON file'));
}
const json = tryJSONparse(jsonString) || [];
if (typeof json.slice !== 'function') {
json.length = 0;
}
const oldStylesById = new Map(
oldStyles.map(style => [style.id, style]));
const oldStylesByName = json.length && new Map(
oldStyles.map(style => [style.name.trim(), style]));
let oldStyles;
let oldStylesById;
let oldStylesByName;
const stats = {
added: {names: [], ids: [], legend: 'importReportLegendAdded'},
unchanged: {names: [], ids: [], legend: 'importReportLegendIdentical'},
@ -78,31 +118,25 @@ function importFromString(jsonString, oldStyles) {
invalid: {names: [], legend: 'importReportLegendInvalid'},
};
let index = 0;
let lastRenderTime = performance.now();
const renderQueue = [];
const RENDER_NAP_TIME_MAX = 1000; // ms
const RENDER_QUEUE_MAX = 50; // number of styles
const SAVE_OPTIONS = {reason: 'import', notify: false};
return new Promise(proceed);
function proceed(resolve) {
while (index < json.length) {
const item = json[index++];
const info = analyze(item);
return API.getAllStyles().then(styles => {
// make a copy of the current database, that may be used when we want to
// undo
oldStyles = styles;
oldStylesById = new Map(
oldStyles.map(style => [style.id, style]));
oldStylesByName = json.length && new Map(
oldStyles.map(style => [style.name.trim(), style]));
return Promise.all(json.map((item, i) => {
const info = analyze(item, i);
if (info) {
// using saveStyle directly since json was parsed in background page context
return API.saveStyle(Object.assign(item, SAVE_OPTIONS))
.then(style => account({style, info, resolve}));
}
}
renderQueue.forEach(style => handleUpdate(style, {reason: 'import'}));
renderQueue.length = 0;
done(resolve);
return API.importStyle(item)
.then(style => updateStats(style, info));
}
}));
})
.then(done);
function analyze(item) {
function analyze(item, index) {
if (typeof item !== 'object' ||
!item ||
!item.name ||
@ -146,17 +180,7 @@ function importFromString(jsonString, oldStyles) {
.some(field => oldStyle[field] && oldStyle[field] === newStyle[field]);
}
function account({style, info, resolve}) {
renderQueue.push(style);
if (performance.now() - lastRenderTime > RENDER_NAP_TIME_MAX
|| renderQueue.length > RENDER_QUEUE_MAX) {
renderQueue.forEach(style => handleUpdate(style, {reason: 'import'}));
setTimeout(scrollElementIntoView, 0, $('#style-' + renderQueue.pop().id));
renderQueue.length = 0;
lastRenderTime = performance.now();
}
setTimeout(proceed, 0, resolve);
const {oldStyle, metaEqual, codeEqual} = info;
function updateStats(style, {oldStyle, metaEqual, codeEqual}) {
if (!oldStyle) {
stats.added.names.push(style.name);
stats.added.ids.push(style.id);
@ -176,12 +200,11 @@ function importFromString(jsonString, oldStyles) {
stats.metaOnly.ids.push(style.id);
}
function done(resolve) {
function done() {
const numChanged = stats.metaAndCode.names.length +
stats.metaOnly.names.length +
stats.codeOnly.names.length +
stats.added.names.length;
Promise.resolve(numChanged && API.refreshAllTabs()).then(() => {
const report = Object.keys(stats)
.filter(kind => stats[kind].names.length)
.map(kind => {
@ -205,13 +228,13 @@ function importFromString(jsonString, oldStyles) {
contents: report.length ? report : t('importReportUnchanged'),
buttons: [t('confirmClose'), numChanged && t('undo')],
onshow: bindClick,
}).then(({button}) => {
})
.then(({button}) => {
if (button === 1) {
undo();
}
});
resolve(numChanged);
});
return Promise.resolve(numChanged);
}
function undo() {
@ -222,23 +245,16 @@ function importFromString(jsonString, oldStyles) {
...stats.added.ids,
];
let tasks = Promise.resolve();
let tasksUI = Promise.resolve();
for (const id of newIds) {
tasks = tasks.then(() => API.deleteStyle({id, notify: false}));
tasksUI = tasksUI.then(() => handleDelete(id));
tasks = tasks.then(() => API.deleteStyle(id));
const oldStyle = oldStylesById.get(id);
if (oldStyle) {
Object.assign(oldStyle, SAVE_OPTIONS);
tasks = tasks.then(() => API.saveStyle(oldStyle));
tasksUI = tasksUI.then(() => handleUpdate(oldStyle, {reason: 'import'}));
tasks = tasks.then(() => API.importStyle(oldStyle));
}
}
// taskUI is superfast and updates style list only in this page,
// which should account for 99.99999999% of cases, supposedly
return tasks
.then(tasksUI)
.then(API.refreshAllTabs)
.then(() => messageBox({
return tasks.then(() => messageBox({
title: t('importReportUndoneTitle'),
contents: newIds.length + ' ' + t('importReportUndone'),
buttons: [t('confirmClose')],
@ -273,8 +289,8 @@ function importFromString(jsonString, oldStyles) {
}
$('#file-all-styles').onclick = () => {
API.getStyles().then(styles => {
function exportToFile() {
API.getAllStyles().then(styles => {
// https://crbug.com/714373
document.documentElement.appendChild(
$create('iframe', {
@ -313,47 +329,4 @@ $('#file-all-styles').onclick = () => {
const yyyy = today.getFullYear();
return `stylus-${yyyy}-${mm}-${dd}${STYLUS_BACKUP_FILE_EXT}`;
}
};
$('#unfile-all-styles').onclick = () => {
importFromFile({fileTypeFilter: STYLUS_BACKUP_FILE_EXT});
};
Object.assign(document.body, {
ondragover(event) {
const hasFiles = event.dataTransfer.types.includes('Files');
event.dataTransfer.dropEffect = hasFiles || event.target.type === 'search' ? 'copy' : 'none';
this.classList.toggle('dropzone', hasFiles);
if (hasFiles) {
event.preventDefault();
clearTimeout(this.fadeoutTimer);
this.classList.remove('fadeout');
}
},
ondragend() {
animateElement(this, {className: 'fadeout', removeExtraClasses: ['dropzone']}).then(() => {
this.style.animationDuration = '';
});
},
ondragleave(event) {
try {
// in Firefox event.target could be XUL browser and hence there is no permission to access it
if (event.target === this) {
this.ondragend();
}
} catch (e) {
this.ondragend();
}
},
ondrop(event) {
this.ondragend();
if (event.dataTransfer.files.length) {
event.preventDefault();
if ($('#only-updates input').checked) {
$('#only-updates input').click();
}
importFromFile({file: event.dataTransfer.files[0]});
}
},
});

View File

@ -1,4 +1,5 @@
/* global installed */
/* global installed onDOMready $create debounce $ scrollElementIntoView
animateElement */
'use strict';
onDOMready().then(() => {

View File

@ -1,10 +1,13 @@
/*
global messageBox getStyleWithNoCode retranslateCSS
global filtersSelector filterAndAppend urlFilterParam showFiltersStats
global checkUpdate handleUpdateInstalled
global objectDiff
global configDialog
global sorter
global messageBox getStyleWithNoCode
filterAndAppend urlFilterParam showFiltersStats
checkUpdate handleUpdateInstalled
objectDiff
configDialog
sorter msg prefs API onDOMready $ $$ $create template setupLivePrefs
URLS enforceInputRange t tWordBreak formatDate
getOwnTab getActiveTab openURL animateElement sessionStorageHash debounce
scrollElementIntoView CHROME VIVALDI FIREFOX
*/
'use strict';
@ -23,7 +26,6 @@ const newUI = {
},
};
newUI.renderClass();
requestAnimationFrame(usePrefsDuringPageLoad);
const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps'];
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
@ -32,23 +34,38 @@ const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const handleEvent = {};
Promise.all([
API.getStyles({omitCode: !BG}),
API.getAllStyles(true),
urlFilterParam && API.searchDB({query: 'url:' + urlFilterParam}),
onDOMready().then(initGlobalEvents),
Promise.all([
onDOMready(),
prefs.initializing,
])
.then(() => {
initGlobalEvents();
if (!VIVALDI) {
$$('#header select').forEach(el => el.adjustWidth());
}
if (FIREFOX && 'update' in (chrome.commands || {})) {
const btn = $('#manage-shortcuts-button');
btn.classList.remove('chromium-only');
btn.onclick = API.optionsCustomizeHotkeys;
}
}),
]).then(args => {
showStyles(...args);
});
chrome.runtime.onMessage.addListener(onRuntimeMessage);
msg.onExtension(onRuntimeMessage);
function onRuntimeMessage(msg) {
switch (msg.method) {
case 'styleUpdated':
case 'styleAdded':
handleUpdate(msg.style, msg);
API.getStyle(msg.style.id, true)
.then(style => handleUpdate(style, msg));
break;
case 'styleDeleted':
handleDelete(msg.id);
handleDelete(msg.style.id);
break;
case 'styleApply':
case 'styleReplaceAll':
@ -96,8 +113,7 @@ function initGlobalEvents() {
setupLivePrefs();
sorter.init();
$$('[id^="manage.newUI"]')
.forEach(el => (el.oninput = (el.onchange = switchUI)));
prefs.subscribe(['manage.newUI'], () => switchUI());
switchUI({styleOnly: true});
@ -119,7 +135,7 @@ function showStyles(styles = [], matchUrlIds) {
const sorted = sorter.sort({
styles: styles.map(style => ({
style,
name: style.name.toLocaleLowerCase() + '\n' + style.name,
name: (style.name || '').toLocaleLowerCase() + '\n' + style.name,
})),
});
let index = 0;
@ -195,7 +211,7 @@ function createStyleElement({style, name}) {
};
}
const parts = createStyleElement.parts;
const configurable = style.usercssData && Object.keys(style.usercssData.vars).length > 0;
const configurable = style.usercssData && style.usercssData.vars && Object.keys(style.usercssData.vars).length > 0;
parts.checker.checked = style.enabled;
parts.nameLink.textContent = tWordBreak(style.name);
parts.nameLink.href = parts.editLink.href = parts.editHrefBase + style.id;
@ -395,10 +411,7 @@ Object.assign(handleEvent, {
},
toggle(event, entry) {
API.saveStyle({
id: entry.styleId,
enabled: this.matches('.enable') || this.checked,
});
API.toggleStyle(entry.styleId, this.matches('.enable') || this.checked);
},
check(event, entry) {
@ -410,8 +423,7 @@ Object.assign(handleEvent, {
event.preventDefault();
const json = entry.updatedCode;
json.id = entry.styleId;
json.reason = 'update';
API[json.usercssData ? 'saveUsercss' : 'saveStyle'](json);
API[json.usercssData ? 'installUsercss' : 'installStyle'](json);
},
delete(event, entry) {
@ -426,7 +438,7 @@ Object.assign(handleEvent, {
})
.then(({button}) => {
if (button === 0) {
API.deleteStyle({id});
API.deleteStyle(id);
}
});
},
@ -510,10 +522,7 @@ Object.assign(handleEvent, {
function handleUpdate(style, {reason, method} = {}) {
if (reason === 'editPreview') return;
// the style was toggled and refreshAllTabs() sent a mini-notification,
// but we've already processed 'styleUpdated' sent directly from notifyAllTabs()
if (!style.sections) return;
if (reason === 'editPreview' || reason === 'editPreviewEnd') return;
let entry;
let oldEntry = $(ENTRY_ID_PREFIX + style.id);
if (oldEntry && method === 'styleUpdated') {
@ -638,7 +647,7 @@ function switchUI({styleOnly} = {}) {
const missingFavicons = newUI.enabled && newUI.favicons && !$('.applies-to img');
if (changed.enabled || (missingFavicons && !createStyleElement.parts)) {
installed.textContent = '';
API.getStyles().then(showStyles);
API.getAllStyles(true).then(showStyles);
return;
}
if (changed.targets) {
@ -662,7 +671,8 @@ function onVisibilityChange() {
// assuming other changes aren't important enough to justify making a complicated DOM sync
case 'visible':
if (sessionStorage.justEditedStyleId) {
API.getStyles({id: sessionStorage.justEditedStyleId}).then(([style]) => {
API.getStyle(Number(sessionStorage.justEditedStyleId), true)
.then(style => {
handleUpdate(style, {method: 'styleUpdated'});
});
delete sessionStorage.justEditedStyleId;
@ -685,30 +695,3 @@ function highlightEditedStyle() {
requestAnimationFrame(() => scrollElementIntoView(entry));
}
}
function usePrefsDuringPageLoad() {
for (const id of Object.getOwnPropertyNames(prefs.readOnlyValues)) {
const value = prefs.readOnlyValues[id];
if (value !== true) continue;
const el = document.getElementById(id) ||
id.includes('expanded') && $(`details[data-pref="${id}"]`);
if (!el) continue;
if (el.type === 'checkbox') {
el.checked = value;
} else if (el.localName === 'details') {
el.open = value;
} else {
el.value = value;
}
}
if (!VIVALDI) {
$$('#header select').forEach(el => el.adjustWidth());
}
if (FIREFOX && 'update' in (chrome.commands || {})) {
const btn = $('#manage-shortcuts-button');
btn.classList.remove('chromium-only');
btn.onclick = API.optionsCustomizeHotkeys;
}
}

View File

@ -1,3 +1,4 @@
/* exported objectDiff */
'use strict';
function objectDiff(first, second, path = '') {

View File

@ -1,5 +1,5 @@
/* global installed */
/* global messageBox */
/* global installed messageBox t $ $create prefs */
/* exported sorter */
'use strict';
const sorter = (() => {

View File

@ -1,6 +1,6 @@
/* global messageBox */
/* global ENTRY_ID_PREFIX, newUI */
/* global filtersSelector, filterAndAppend, sorter */
/* global messageBox ENTRY_ID_PREFIX newUI filtersSelector filterAndAppend
sorter $ $$ $create API onDOMready scrollElementIntoView t chromeLocal */
/* exported handleUpdateInstalled */
'use strict';
onDOMready().then(() => {

View File

@ -24,23 +24,27 @@
],
"background": {
"scripts": [
"js/promisify.js",
"js/messaging.js",
"js/msg.js",
"js/storage-util.js",
"js/sections-equal.js",
"background/storage-dummy.js",
"background/storage.js",
"js/sections-util.js",
"js/worker-util.js",
"js/prefs.js",
"js/script-loader.js",
"js/usercss.js",
"js/cache.js",
"background/db.js",
"background/style-manager.js",
"background/navigator-util.js",
"background/icon-util.js",
"background/background.js",
"background/usercss-helper.js",
"background/style-via-api.js",
"background/search-db.js",
"background/update.js",
"background/refresh-all-tabs.js",
"background/openusercss-api.js",
"vendor/semver-bundle/semver.js",
"vendor-overwrites/colorpicker/colorconverter.js"
"vendor/semver-bundle/semver.js"
]
},
"commands": {
@ -58,7 +62,7 @@
"run_at": "document_start",
"all_frames": true,
"match_about_blank": true,
"js": ["content/apply.js"]
"js": ["js/promisify.js", "js/msg.js", "js/prefs.js", "content/apply.js"]
},
{
"matches": ["http://userstyles.org/*", "https://userstyles.org/*"],
@ -111,5 +115,11 @@
"options_ui": {
"page": "options.html",
"chrome_style": false
},
"applications": {
"gecko": {
"id": "{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}",
"strict_min_version": "53"
}
}
}

View File

@ -1,5 +1,4 @@
/* global focusAccessibility */
/* global moveFocus */
/* global focusAccessibility moveFocus $ $create t tHTML animateElement */
'use strict';
/**

View File

@ -21,6 +21,8 @@
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/promisify.js"></script>
<script src="js/msg.js"></script>
<script src="js/localization.js"></script>
<script src="js/prefs.js"></script>
<script src="js/storage-util.js" async></script>

View File

@ -1,4 +1,6 @@
/* global messageBox */
/* global messageBox msg setupLivePrefs enforceInputRange
$ $$ $create $createLink
FIREFOX OPERA CHROME URLS openURL prefs t API ignoreChromeError */
'use strict';
setupLivePrefs();
@ -21,7 +23,7 @@ if (!FIREFOX && !OPERA && CHROME < 3343) {
if (FIREFOX && 'update' in (chrome.commands || {})) {
$('[data-cmd="open-keyboard"]').classList.remove('chromium-only');
chrome.runtime.onMessage.addListener(msg => {
msg.onExtension(msg => {
if (msg.method === 'optionsCustomizeHotkeys') {
customizeHotkeys();
}
@ -57,7 +59,7 @@ document.onclick = e => {
case 'reset':
$$('input')
.filter(input => input.id in prefs.readOnlyValues)
.filter(input => input.id in prefs.defaults)
.forEach(input => prefs.reset(input.id));
break;

View File

@ -18,6 +18,8 @@
"stylelint-bundle": "^8.0.0",
"stylus-lang-bundle": "^0.54.5",
"updates": "^5.1.2",
"web-ext": "^2.9.1",
"usercss-meta": "^0.8.1",
"webext-tx-fix": "^0.3.1"
},
"scripts": {
@ -30,6 +32,7 @@
"update-transifex": "tx push -s",
"update-vendor": "node tools/update-libraries.js && node tools/update-codemirror-themes.js",
"update-versions": "node tools/update-versions",
"zip": "npm run update-versions && node tools/zip.js"
"zip": "npm run update-versions && node tools/zip.js",
"start": "web-ext run"
}
}

View File

@ -151,10 +151,12 @@
<link rel="stylesheet" href="manage/config-dialog.css">
<script src="manage/config-dialog.js"></script>
<script src="js/promisify.js"></script>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/localization.js"></script>
<script src="js/prefs.js"></script>
<script src="js/msg.js"></script>
<script src="content/apply.js"></script>
<link rel="stylesheet" href="popup/popup.css">

View File

@ -1,8 +1,8 @@
/* global applyOnMessage installed */
/* global $ $$ API debounce $create t */
'use strict';
// eslint-disable-next-line no-var
var hotkeys = (() => {
/* exported hotkeys */
const hotkeys = (() => {
const entries = document.getElementsByClassName('entry');
let togglablesShown;
let togglables;
@ -101,11 +101,9 @@ var hotkeys = (() => {
entry = typeof entry === 'string' ? $('#' + entry) : entry;
if (!match && $('.checker', entry).checked !== enable || entry.classList.contains(match)) {
results.push(entry.id);
task = task.then(() => API.saveStyle({
id: entry.styleId,
enabled: enable,
notify: false,
})).then(() => {
task = task
.then(() => API.toggleStyle(entry.styleId, enable))
.then(() => {
entry.classList.toggle('enabled', enable);
entry.classList.toggle('disabled', !enable);
$('.checker', entry).checked = enable;

View File

@ -301,6 +301,10 @@ html[style*="border"] .entry:nth-child(11):before {
box-sizing: border-box;
cursor: pointer;
font-size: 90%;
display: none;
}
.regexp-partial .regexp-problem-indicator {
display: block;
}
.regexp-partial .actions,
@ -311,6 +315,8 @@ html[style*="border"] .entry:nth-child(11):before {
#regexp-explanation {
position: fixed;
background-color: white;
top: 50%;
transform: translateY(-50%);
left: 0;
right: 0;
padding: .5rem;

View File

@ -1,4 +1,7 @@
/* global configDialog hotkeys */
/* global configDialog hotkeys onTabReady msg
getActiveTab FIREFOX getTabRealURL URLS API onDOMready $ $$ prefs CHROME
setupLivePrefs template t $create tWordBreak animateElement
tryJSONparse debounce */
'use strict';
@ -11,44 +14,45 @@ const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW;
toggleSideBorders();
getActiveTab().then(tab =>
getActiveTab()
.then(tab =>
FIREFOX && tab.url === 'about:blank' && tab.status === 'loading'
? getTabRealURLFirefox(tab)
: getTabRealURL(tab)
).then(url => Promise.all([
)
.then(url => Promise.all([
(tabURL = URLS.supported(url) ? url : '') &&
API.getStyles({
matchUrl: tabURL,
omitCode: !BG,
}),
API.getStylesByUrl(tabURL),
onDOMready().then(initPopup),
])).then(([styles]) => {
showStyles(styles);
});
]))
.then(([results]) => {
if (!results) {
// unsupported URL;
return;
}
showStyles(results.map(r => Object.assign(r.data, r)));
})
.catch(console.error);
chrome.runtime.onMessage.addListener(onRuntimeMessage);
msg.onExtension(onRuntimeMessage);
prefs.subscribe(['popup.stylesFirst'], (key, stylesFirst) => {
const actions = $('body > .actions');
const before = stylesFirst ? actions : actions.nextSibling;
document.body.insertBefore(installed, before);
});
prefs.subscribe(['popupWidth'], (key, value) => setPopupWidth(value));
prefs.subscribe(['popup.borders'], (key, value) => toggleSideBorders(value));
function onRuntimeMessage(msg) {
switch (msg.method) {
case 'styleAdded':
case 'styleUpdated':
if (msg.reason === 'editPreview') return;
handleUpdate(msg.style);
if (msg.reason === 'editPreview' || msg.reason === 'editPreviewEnd') return;
handleUpdate(msg);
break;
case 'styleDeleted':
handleDelete(msg.id);
break;
case 'prefChanged':
if ('popup.stylesFirst' in msg.prefs) {
const stylesFirst = msg.prefs['popup.stylesFirst'];
const actions = $('body > .actions');
const before = stylesFirst ? actions : actions.nextSibling;
document.body.insertBefore(installed, before);
} else if ('popupWidth' in msg.prefs) {
setPopupWidth(msg.prefs.popupWidth);
} else if ('popup.borders' in msg.prefs) {
toggleSideBorders(msg.prefs['popup.borders']);
}
handleDelete(msg.style.id);
break;
}
dispatchEvent(new CustomEvent(msg.method, {detail: msg}));
@ -111,11 +115,12 @@ function initPopup() {
}
getActiveTab().then(function ping(tab, retryCountdown = 10) {
sendMessage({tabId: tab.id, method: 'ping', frameId: 0}, pong => {
msg.sendTab(tab.id, {method: 'ping'}, {frameId: 0})
.catch(() => false)
.then(pong => {
if (pong) {
return;
}
ignoreChromeError();
// FF and some Chrome forks (e.g. CentBrowser) implement tab-on-demand
// so we'll wait a bit to handle popup being invoked right after switching
if (retryCountdown > 0 && (
@ -227,93 +232,92 @@ function showStyles(styles) {
const container = document.createDocumentFragment();
styles.forEach(style => createStyleElement({style, container}));
installed.appendChild(container);
setTimeout(detectSloppyRegexps, 100, styles);
API.getStyles({
matchUrl: tabURL,
strictRegexp: false,
omitCode: true,
}).then(unscreenedStyles => {
for (const style of unscreenedStyles) {
if (!styles.find(({id}) => id === style.id)) {
createStyleElement({style, check: true});
}
}
window.dispatchEvent(new Event('showStyles:done'));
});
}
function createStyleElement({
style,
check = false,
container = installed,
}) {
const entry = template.style.cloneNode(true);
let entry = $(ENTRY_ID_PREFIX + style.id);
if (!entry) {
entry = template.style.cloneNode(true);
entry.setAttribute('style-id', style.id);
Object.assign(entry, {
id: ENTRY_ID_PREFIX_RAW + style.id,
styleId: style.id,
styleIsUsercss: Boolean(style.usercssData),
className: entry.className + ' ' + (style.enabled ? 'enabled' : 'disabled'),
onmousedown: handleEvent.maybeEdit,
styleMeta: style
});
const checkbox = $('.checker', entry);
Object.assign(checkbox, {
id: ENTRY_ID_PREFIX_RAW + style.id,
checked: style.enabled,
// title: t('exclusionsPopupTip'),
onclick: handleEvent.toggle,
// oncontextmenu: handleEvent.openExcludeMenu
});
const editLink = $('.style-edit-link', entry);
Object.assign(editLink, {
href: editLink.getAttribute('href') + style.id,
onclick: handleEvent.openLink,
});
const styleName = $('.style-name', entry);
Object.assign(styleName, {
htmlFor: ENTRY_ID_PREFIX_RAW + style.id,
onclick: handleEvent.name,
});
styleName.checkbox = checkbox;
styleName.appendChild(document.createTextNode(style.name));
setTimeout((el = styleName) => {
if (el.scrollWidth > el.clientWidth + 1) {
el.title = el.textContent;
}
});
styleName.appendChild(document.createTextNode(' '));
const config = $('.configure', entry);
if (!style.usercssData && style.updateUrl && style.updateUrl.includes('?') && style.url) {
config.href = style.url;
config.target = '_blank';
config.title = t('configureStyleOnHomepage');
config.dataset.sendMessage = JSON.stringify({method: 'openSettings'});
$('use', config).attributes['xlink:href'].nodeValue = '#svg-icon-config-uso';
} else if (!style.usercssData || !Object.keys(style.usercssData.vars || {}).length) {
config.style.display = 'none';
}
$('.enable', entry).onclick = handleEvent.toggle;
$('.disable', entry).onclick = handleEvent.toggle;
$('.delete', entry).onclick = handleEvent.delete;
$('.configure', entry).onclick = handleEvent.configure;
if (check) detectSloppyRegexps([style]);
const indicator = template.regexpProblemIndicator.cloneNode(true);
indicator.appendChild(document.createTextNode('!'));
indicator.onclick = handleEvent.indicator;
$('.main-controls', entry).appendChild(indicator);
}
const oldElement = $(ENTRY_ID_PREFIX + style.id);
if (oldElement && oldElement.contains(document.activeElement)) {
// preserve the focused element inside
const {className} = document.activeElement;
oldElement.parentNode.replaceChild(entry, oldElement);
// we're not using $() since className may contain multiple tokens
const el = entry.getElementsByClassName(className)[0];
if (el) el.focus();
} else if (oldElement) {
oldElement.parentNode.replaceChild(entry, oldElement);
style = Object.assign(entry.styleMeta, style);
entry.classList.toggle('disabled', !style.enabled);
entry.classList.toggle('enabled', style.enabled);
$('.checker', entry).checked = style.enabled;
const styleName = $('.style-name', entry);
styleName.lastChild.textContent = style.name;
setTimeout(() => {
styleName.title = entry.styleMeta.sloppy ?
t('styleNotAppliedRegexpProblemTooltip') :
styleName.scrollWidth > styleName.clientWidth + 1 ?
styleName.textContent : '';
});
const config = $('.configure', entry);
if (!style.usercssData && style.updateUrl && style.updateUrl.includes('?') && style.url) {
config.href = style.url;
} else {
config.removeAttribute('href');
}
config.style.display =
!style.usercssData && config.href ||
style.usercssData && Object.keys(style.usercssData.vars || {}).length ?
'' : 'none';
entry.classList.toggle('not-applied', style.excluded || style.sloppy);
entry.classList.toggle('regexp-partial', style.sloppy);
if (entry.parentNode !== container) {
container.appendChild(entry);
}
}
@ -337,10 +341,10 @@ Object.assign(handleEvent, {
toggle(event) {
// when fired on checkbox, prevent the parent label from seeing the event, see #501
event.stopPropagation();
API.saveStyle({
id: handleEvent.getClickedStyleId(event),
enabled: this.matches('.enable') || this.checked,
});
API.toggleStyle(
handleEvent.getClickedStyleId(event),
this.matches('.enable') || this.checked
);
},
delete(event) {
@ -367,14 +371,14 @@ Object.assign(handleEvent, {
className: 'lights-on',
onComplete: () => (box.dataset.display = false),
});
if (ok) API.deleteStyle({id});
if (ok) API.deleteStyle(id);
}
},
configure(event) {
const {styleId, styleIsUsercss} = handleEvent.getClickedStyleElement(event);
if (styleIsUsercss) {
API.getStyles({id: styleId}).then(([style]) => {
API.getStyle(styleId, true).then(style => {
hotkeys.setState(false);
configDialog(style).then(() => {
hotkeys.setState(true);
@ -436,12 +440,18 @@ Object.assign(handleEvent, {
openURLandHide(event) {
event.preventDefault();
const message = tryJSONparse(this.dataset.sendMessage);
getActiveTab()
.then(activeTab => API.openURL({
url: this.href || this.dataset.href,
index: activeTab.index + 1,
message: tryJSONparse(this.dataset.sendMessage),
index: activeTab.index + 1
}))
.then(tab => {
if (message) {
return onTabReady(tab)
.then(() => msg.sendTab(tab.id, message));
}
})
.then(window.close);
},
@ -457,24 +467,31 @@ Object.assign(handleEvent, {
});
function handleUpdate(style) {
if ($(ENTRY_ID_PREFIX + style.id)) {
createStyleElement({style, check: true});
function handleUpdate({style, reason}) {
if (!tabURL) return;
fetchStyle()
.then(style => {
if (!style) {
return;
}
if ($(ENTRY_ID_PREFIX + style.id)) {
createStyleElement({style});
return;
}
if (!tabURL) return;
// Add an entry when a new style for the current url is installed
API.getStyles({
matchUrl: tabURL,
stopOnFirst: true,
omitCode: true,
}).then(([style]) => {
if (style) {
document.body.classList.remove('blocked');
$$.remove('.blocked-info, #no-styles');
createStyleElement({style, check: true});
createStyleElement({style});
})
.catch(console.error);
function fetchStyle() {
if (reason === 'toggle' && $(ENTRY_ID_PREFIX + style.id)) {
return Promise.resolve(style);
}
return API.getStylesByUrl(tabURL, style.id)
.then(([result]) => result && Object.assign(result.data, result));
}
});
}
@ -485,32 +502,6 @@ function handleDelete(id) {
}
}
function detectSloppyRegexps(styles) {
API.detectSloppyRegexps({
matchUrl: tabURL,
ids: styles.map(({id}) => id),
}).then(results => {
for (const {id, applied, skipped, hasInvalidRegexps} of results) {
const entry = $(ENTRY_ID_PREFIX + id);
if (!entry) continue;
if (!applied) {
entry.classList.add('not-applied');
$('.style-name', entry).title = t('styleNotAppliedRegexpProblemTooltip');
}
if (skipped || hasInvalidRegexps) {
entry.classList.toggle('regexp-partial', Boolean(skipped));
entry.classList.toggle('regexp-invalid', Boolean(hasInvalidRegexps));
const indicator = template.regexpProblemIndicator.cloneNode(true);
indicator.appendChild(document.createTextNode(entry.skipped || '!'));
indicator.onclick = handleEvent.indicator;
$('.main-controls', entry).appendChild(indicator);
}
}
});
}
function getTabRealURLFirefox(tab) {
// wait for FF tab-on-demand to get a real URL (initially about:blank), 5 sec max
return new Promise(resolve => {

View File

@ -1,4 +1,6 @@
/* global tabURL handleEvent */
/* global tabURL handleEvent $ $$ prefs template FIREFOX chromeLocal debounce
$create t API tWordBreak formatDate tryCatch tryJSONparse LZString
ignoreChromeError */
'use strict';
window.addEventListener('showStyles:done', function _() {
@ -135,7 +137,7 @@ window.addEventListener('showStyles:done', function _() {
if (result) {
result.installed = false;
result.installedStyleId = -1;
(BG || window).clearTimeout(result.pingbackTimer);
window.clearTimeout(result.pingbackTimer);
renderActionButtons($('#' + RESULT_ID_PREFIX + result.id));
}
});
@ -287,14 +289,14 @@ window.addEventListener('showStyles:done', function _() {
return;
}
const md5Url = UPDATE_URL.replace('%', result.id);
API.getStyles({md5Url}).then(([installedStyle]) => {
if (installedStyle) {
API.styleExists({md5Url}).then(exist => {
if (exist) {
totalResults = Math.max(0, totalResults - 1);
} else {
processedResults.push(result);
render();
}
setTimeout(processNextResult, !installedStyle && DELAY_AFTER_FETCHING_STYLES);
setTimeout(processNextResult, !exist && DELAY_AFTER_FETCHING_STYLES);
});
}
@ -529,7 +531,7 @@ window.addEventListener('showStyles:done', function _() {
event.stopPropagation();
const entry = this.closest('.search-result');
saveScrollPosition(entry);
API.deleteStyle({id: entry._result.installedStyleId})
API.deleteStyle(entry._result.installedStyleId)
.then(restoreScrollPosition);
}
@ -555,9 +557,7 @@ window.addEventListener('showStyles:done', function _() {
pingback(result);
// show a 'config-on-homepage' icon in the popup
style.updateUrl += settings.length ? '?' : '';
// show a 'style installed' tooltip in the manager
style.reason = 'install';
return API.saveStyle(style);
return API.installStyle(style);
})
.catch(reason => {
const usoId = result.id;
@ -581,7 +581,8 @@ window.addEventListener('showStyles:done', function _() {
}
function pingback(result) {
const wnd = BG || window;
const wnd = window;
// FIXME: move this to background page and create an API like installUSOStyle
result.pingbackTimer = wnd.setTimeout(wnd.download, PINGBACK_DELAY,
BASE_URL + '/styles/install/' + result.id + '?source=stylish-ch');
}

View File

@ -1,5 +1,5 @@
/* global messageBox */
/* global zip */
/* global zip onDOMready */
/* exported createZipFileFromText readZipFileFromBlob */
'use strict';
onDOMready().then(() => {

View File

@ -1,3 +1,4 @@
/* exported getRedirectUrlAuthFlow launchWebAuthFlow */
'use strict';
/**

View File

@ -1,4 +1,6 @@
/* global messageBox Dropbox createZipFileFromText readZipFileFromBlob launchWebAuthFlow getRedirectUrlAuthFlow importFromString resolve */
/* global messageBox Dropbox createZipFileFromText readZipFileFromBlob
launchWebAuthFlow getRedirectUrlAuthFlow importFromString resolve
$ $create t chromeLocal API getOwnTab */
'use strict';
const DROPBOX_API_KEY = 'zg52vphuapvpng9';
@ -66,7 +68,7 @@ $('#sync-dropbox-export').onclick = () => {
// file deleted with success, get styles and create a file
.then(() => {
messageProgressBar({title: title, text: t('gettingStyles')});
return API.getStyles().then(styles => JSON.stringify(styles, null, '\t'));
return API.getAllStyles().then(styles => JSON.stringify(styles, null, '\t'));
})
// create zip file
.then(stylesText => {
@ -85,7 +87,7 @@ $('#sync-dropbox-export').onclick = () => {
console.log(error);
// saving file first time
if (error.status === API_ERROR_STATUS_FILE_NOT_FOUND) {
API.getStyles()
API.getAllStyles()
.then(styles => {
messageProgressBar({title: title, text: t('gettingStyles')});
return JSON.stringify(styles, null, '\t');
@ -141,7 +143,7 @@ $('#sync-dropbox-import').onclick = () => {
importFromString(text) :
getOwnTab().then(tab => {
tab.url = URL.createObjectURL(new Blob([text], {type: 'text/css'}));
return API.installUsercss({direct: true, tab})
return API.openUsercssInstallPage({direct: true, tab})
.then(() => URL.revokeObjectURL(tab.url));
})
).then(numStyles => {

View File

@ -28,6 +28,9 @@ const files = {
],
'stylus-lang-bundle': [
'stylus.min.js'
],
'usercss-meta': [
'dist/usercss-meta.min.js → usercss-meta.min.js'
]
};
@ -35,7 +38,7 @@ async function updateReadme(lib) {
const pkg = await fs.readJson(`${root}/node_modules/${lib}/package.json`);
const file = `${root}/vendor/${lib}/README.md`;
const txt = await fs.readFile(file, 'utf8');
return fs.writeFile(file, txt.replace(/\bv[\d.]+[-\w]*\b/g, `v${pkg.version}`));
return fs.writeFile(file, txt.replace(/\b([v@])[\d.]+[-\w]*\b/g, `$1${pkg.version}`));
}
function isFolder(fileOrFolder) {

View File

@ -1,4 +1,5 @@
/* global CodeMirror colorConverter */
/* global colorConverter $create debounce */
/* exported colorMimicry */
'use strict';
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {

View File

@ -5505,3 +5505,5 @@ self.parserlib = (() => {
//endregion
})();
self.parserlib.css.Tokens[self.parserlib.css.Tokens.COMMENT].hide = false;

4
vendor/README.md vendored
View File

@ -9,7 +9,8 @@ Using this repo, run `npm install`... the latest versions of:
* `less` (https://github.com/less/less.js) is installed.
* `lz-string-unsafe` (https://github.com/openstyles/lz-string-unsafe) is installed.
* `semver-bundle` (https://github.com/openstyles/semver-bundle) is installed.
* `stylus-lang` (https://github.com/openstyles/stylus-lang-bundle) is installed.<br><br>
* `stylus-lang` (https://github.com/openstyles/stylus-lang-bundle) is installed.
* `usercss-meta` (https://github.com/StylishThemes/parse-usercss) is installed.
* The necessary build tools are installed; see `devDependencies` in the `package.json`.
## Running the build script
@ -24,6 +25,7 @@ The following changes are made:
* `lz-string-unsafe`: The compressed `lz-string-unsafe.min.js` file is copied directly into `vendor/lz-string-unsafe`.
* `semver-bundle`: The `dist/semver.js` file is copied directly into `vendor/semver`.
* `stylus-lang-bundle`: The `stylus.min.js` file is copied directly into `vendor/stylus-lang-bundle`.
* `usercss-meta`: The `dist/usercss-meta.min.js` file is copied directly into `vendor/usercss-meta`.
## Creating the ZIP

22
vendor/usercss-meta/LICENSE vendored Normal file
View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Original code: Copyright (c) Stylus team (github.com/openstyles/stylus)
Modified code: Copyright (c) StylishThemes (github.com/StylishThemes/parse-usercss)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

5
vendor/usercss-meta/README.md vendored Normal file
View File

@ -0,0 +1,5 @@
## usercss-meta v0.8.1
usercss-meta installed via npm - source repo:
https://unpkg.com/usercss-meta@0.8.1/dist/usercss-meta.min.js

File diff suppressed because one or more lines are too long