From 94db6c69fa4aa25231e213c6d0e185197bfe3418 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Thu, 11 Feb 2021 18:55:09 -0500 Subject: [PATCH] Permissions button in browser action popup (#1368) * Add key icon * Update context icon styles * Add permissions links * Show warning badge if permissions are insufficient for certain settings * Create PermissionsUtil * Use PermissionsUtil in Backend * Update SettingsController to use PermissionsUtil * Update AnkiController to use getRequiredPermissionsForAnkiFieldValue * Show the permissions buttons/links on the context page when necessary * Update MV3 compatibility --- .eslintrc.json | 1 + ext/bg/background.html | 1 + ext/bg/context.html | 30 ++++- ext/bg/css/context.css | 44 ++---- ext/bg/info.html | 1 + ext/bg/js/backend.js | 38 ++++-- ext/bg/js/context-main.js | 37 +++-- ext/bg/js/permissions-util.js | 126 ++++++++++++++++++ ext/bg/js/settings/anki-controller.js | 28 +--- ext/bg/js/settings/backup-controller.js | 2 +- .../settings/permissions-toggle-controller.js | 8 +- ext/bg/js/settings/settings-controller.js | 52 +------- ext/bg/permissions.html | 2 +- ext/bg/settings.html | 1 + ext/bg/settings2.html | 1 + ext/bg/welcome.html | 1 + ext/mixed/css/material.css | 1 + ext/mixed/img/key.svg | 1 + ext/sw.js | 1 + resources/icons.svg | 21 ++- 20 files changed, 258 insertions(+), 139 deletions(-) create mode 100644 ext/bg/js/permissions-util.js create mode 100644 ext/mixed/img/key.svg diff --git a/.eslintrc.json b/.eslintrc.json index 0afb1a43..4500e436 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -176,6 +176,7 @@ "ext/bg/js/mecab.js", "ext/bg/js/media-utility.js", "ext/bg/js/options.js", + "ext/bg/js/permissions-util.js", "ext/bg/js/profile-conditions.js", "ext/bg/js/request-builder.js", "ext/bg/js/simple-dom-parser.js", diff --git a/ext/bg/background.html b/ext/bg/background.html index 29cd1d85..5e4e2703 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -37,6 +37,7 @@ + diff --git a/ext/bg/context.html b/ext/bg/context.html index 76e4db2a..785570d8 100644 --- a/ext/bg/context.html +++ b/ext/bg/context.html @@ -25,19 +25,32 @@ @@ -53,6 +66,13 @@ + Search @@ -69,6 +89,8 @@ + + diff --git a/ext/bg/css/context.css b/ext/bg/css/context.css index 447c5066..27cbdb92 100644 --- a/ext/bg/css/context.css +++ b/ext/bg/css/context.css @@ -75,6 +75,7 @@ label { } .icon[data-icon=profile] { --icon-image: url(/mixed/img/profile.svg); } .icon[data-icon=cog] { --icon-image: url(/mixed/img/cog.svg); } +.icon[data-icon=key] { --icon-image: url(/mixed/img/key.svg); } .icon[data-icon=magnifying-glass] { --icon-image: url(/mixed/img/magnifying-glass.svg); } .icon[data-icon=exclamation-point-short] { --icon-image: url(/mixed/img/exclamation-point-short.svg); } .icon[data-icon=question-mark-circle] { --icon-image: url(/mixed/img/question-mark-circle.svg); } @@ -82,7 +83,6 @@ label { /* Page-specific styles */ .link-group { - display: flex; flex-flow: row nowrap; align-items: center; line-height: 1.5em; @@ -95,6 +95,9 @@ label { transition: background-color 0.125s linear 0s; max-width: none; } +.link-group:not([hidden]) { + display: flex; +} .link-group:hover, .link-group:active { color: #333; @@ -276,23 +279,6 @@ body[data-loaded=true] .toggle-group { .nav-button+.nav-button { margin-left: -1px; } -.nav-button::after { - content: ''; - display: block; - width: 16px; - height: 16px; - box-sizing: content-box; - background-color: #333333; - mask-repeat: no-repeat; - mask-position: center center; - mask-mode: alpha; - mask-size: 16px 16px; - -webkit-mask-repeat: no-repeat; - -webkit-mask-position: center center; - -webkit-mask-mode: alpha; - -webkit-mask-size: 16px 16px; - pointer-events: none; -} .nav-button:hover { z-index: 1; border-color: #aaaaaa; @@ -308,21 +294,13 @@ body[data-loaded=true] .toggle-group { .nav-button:focus { outline: none; } -.nav-button[data-icon=magnifying-glass]::after { - mask-image: url(/mixed/img/magnifying-glass.svg); - -webkit-mask-image: url(/mixed/img/magnifying-glass.svg); -} -.nav-button[data-icon=cog]::after { - mask-image: url(/mixed/img/cog.svg); - -webkit-mask-image: url(/mixed/img/cog.svg); -} -.nav-button[data-icon=question-mark]::after { - mask-image: url(/mixed/img/question-mark-circle.svg); - -webkit-mask-image: url(/mixed/img/question-mark-circle.svg); -} -.nav-button[data-icon=profile]::after { - mask-image: url(/mixed/img/profile.svg); - -webkit-mask-image: url(/mixed/img/profile.svg); +.nav-button>.icon { + --icon-size: 16px 16px; + display: block; + width: 16px; + height: 16px; + box-sizing: content-box; + background-color: #333333; } .nav-button:first-child, .nav-button:first-child[hidden]+.nav-button { diff --git a/ext/bg/info.html b/ext/bg/info.html index 07edcbcf..fc5db6e5 100644 --- a/ext/bg/info.html +++ b/ext/bg/info.html @@ -65,6 +65,7 @@ + diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index e5f8466e..3dd1955f 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -28,6 +28,7 @@ * MediaUtility * ObjectPropertyAccessor * OptionsUtil + * PermissionsUtil * ProfileConditions * RequestBuilder * Translator @@ -83,6 +84,8 @@ class Backend { this._defaultBrowserActionTitle = null; this._badgePrepareDelayTimer = null; this._logErrorLevel = null; + this._permissions = null; + this._permissionsUtil = new PermissionsUtil(); this._messageHandlers = new Map([ ['requestBackendReadySignal', {async: false, contentScript: true, handler: this._onApiRequestBackendReadySignal.bind(this)}], @@ -174,12 +177,17 @@ class Backend { const onMessage = this._onMessageWrapper.bind(this); chrome.runtime.onMessage.addListener(onMessage); + + const onPermissionsChanged = this._onWebExtensionEventWrapper(this._onPermissionsChanged.bind(this)); + chrome.permissions.onAdded.addListener(onPermissionsChanged); + chrome.permissions.onRemoved.addListener(onPermissionsChanged); } async _prepareInternal() { try { this._prepareInternalSync(); + this._permissions = await this._permissionsUtil.getAllPermissions(); this._defaultBrowserActionTitle = await this._getBrowserIconTitle(); this._badgePrepareDelayTimer = setTimeout(() => { this._badgePrepareDelayTimer = null; @@ -357,6 +365,10 @@ class Backend { this._sendMessageTabIgnoreResponse(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}); } + _onPermissionsChanged() { + this._checkPermissions(); + } + // Message handlers _onApiRequestBackendReadySignal(_params, sender) { @@ -682,7 +694,7 @@ class Backend { let permissionsOkay = false; try { - permissionsOkay = await this._hasPermissions({permissions: ['nativeMessaging']}); + permissionsOkay = await this._permissionsUtil.hasPermissions({permissions: ['nativeMessaging']}); } catch (e) { // NOP } @@ -1263,6 +1275,10 @@ class Backend { text = 'off'; color = '#555555'; status = 'Disabled'; + } else if (!this._hasRequiredPermissionsForSettings(options)) { + text = '!'; + color = '#f0ad4e'; + status = 'Some settings require additional permissions'; } else if (!this._isAnyDictionaryEnabled(options)) { text = '!'; color = '#f0ad4e'; @@ -1941,17 +1957,6 @@ class Backend { }); } - _hasPermissions(permissions) { - return new Promise((resolve, reject) => chrome.permissions.contains(permissions, (result) => { - const e = chrome.runtime.lastError; - if (e) { - reject(new Error(e.message)); - } else { - resolve(result); - } - })); - } - _getTabById(tabId) { return new Promise((resolve, reject) => { chrome.tabs.get( @@ -1967,4 +1972,13 @@ class Backend { ); }); } + + async _checkPermissions() { + this._permissions = await this._permissionsUtil.getAllPermissions(); + this._updateBadge(); + } + + _hasRequiredPermissionsForSettings(options) { + return this._permissions === null || this._permissionsUtil.hasRequiredPermissionsForOptions(this._permissions, options); + } } diff --git a/ext/bg/js/context-main.js b/ext/bg/js/context-main.js index 3d9c90ab..a7ea1471 100644 --- a/ext/bg/js/context-main.js +++ b/ext/bg/js/context-main.js @@ -17,12 +17,14 @@ /* global * HotkeyHelpController + * PermissionsUtil * api */ class DisplayController { constructor() { this._optionsFull = null; + this._permissionsUtil = new PermissionsUtil(); } async prepare() { @@ -40,6 +42,7 @@ class DisplayController { const optionsPageUrl = optionsFull.global.useSettingsV2 ? '/bg/settings2.html' : manifest.options_ui.page; this._setupButtonEvents('.action-open-settings', 'openSettingsPage', chrome.runtime.getURL(optionsPageUrl)); + this._setupButtonEvents('.action-open-permissions', null, chrome.runtime.getURL('/bg/permissions.html')); const {profiles, profileCurrent} = optionsFull; const primaryProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null; @@ -68,16 +71,18 @@ class DisplayController { _setupButtonEvents(selector, command, url) { const nodes = document.querySelectorAll(selector); for (const node of nodes) { - node.addEventListener('click', (e) => { - if (e.button !== 0) { return; } - api.commandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); - e.preventDefault(); - }, false); - node.addEventListener('auxclick', (e) => { - if (e.button !== 1) { return; } - api.commandExec(command, {mode: 'newTab'}); - e.preventDefault(); - }, false); + if (typeof command === 'string') { + node.addEventListener('click', (e) => { + if (e.button !== 0) { return; } + api.commandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); + e.preventDefault(); + }, false); + node.addEventListener('auxclick', (e) => { + if (e.button !== 1) { return; } + api.commandExec(command, {mode: 'newTab'}); + e.preventDefault(); + }, false); + } if (typeof url === 'string') { node.href = url; @@ -131,6 +136,7 @@ class DisplayController { toggle.addEventListener('change', onToggleChanged, false); } this._updateDictionariesEnabledWarnings(options); + this._updatePermissionsWarnings(options); } async _setupHotkeys() { @@ -201,6 +207,17 @@ class DisplayController { node.hidden = hasEnabledDictionary; } } + + async _updatePermissionsWarnings(options) { + const permissions = await this._permissionsUtil.getAllPermissions(); + if (this._permissionsUtil.hasRequiredPermissionsForOptions(permissions, options)) { return; } + + const warnings = document.querySelectorAll('.action-open-permissions,.permissions-required-warning'); + for (const node of warnings) { + console.log(node); + node.hidden = false; + } + } } (async () => { diff --git a/ext/bg/js/permissions-util.js b/ext/bg/js/permissions-util.js new file mode 100644 index 00000000..bd3a18ce --- /dev/null +++ b/ext/bg/js/permissions-util.js @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +class PermissionsUtil { + constructor() { + this._ankiFieldMarkersRequiringClipboardPermission = new Set([ + 'clipboard-image', + 'clipboard-text' + ]); + this._ankiMarkerPattern = /\{([\w-]+)\}/g; + } + + hasPermissions(permissions) { + return new Promise((resolve, reject) => chrome.permissions.contains(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })); + } + + setPermissionsGranted(permissions, shouldHave) { + return ( + shouldHave ? + new Promise((resolve, reject) => chrome.permissions.request(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })) : + new Promise((resolve, reject) => chrome.permissions.remove(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(!result); + } + })) + ); + } + + getAllPermissions() { + return new Promise((resolve, reject) => chrome.permissions.getAll((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })); + } + + getRequiredPermissionsForAnkiFieldValue(fieldValue) { + const markers = this._getAnkiFieldMarkers(fieldValue); + const markerPermissions = this._ankiFieldMarkersRequiringClipboardPermission; + for (const marker of markers) { + if (markerPermissions.has(marker)) { + return ['clipboardRead']; + } + } + return []; + } + + hasRequiredPermissionsForOptions(permissions, options) { + const permissionsSet = new Set(permissions.permissions); + + if (!permissionsSet.has('nativeMessaging')) { + if (options.parsing.enableMecabParser) { + return false; + } + } + + if (!permissionsSet.has('clipboardRead')) { + if (options.clipboard.enableBackgroundMonitor || options.clipboard.enableSearchPageMonitor) { + return false; + } + const fieldMarkersRequiringClipboardPermission = this._ankiFieldMarkersRequiringClipboardPermission; + const fieldsList = [ + options.anki.terms.fields, + options.anki.kanji.fields + ]; + for (const fields of fieldsList) { + for (const fieldValue of Object.values(fields)) { + const markers = this._getAnkiFieldMarkers(fieldValue); + for (const marker of markers) { + if (fieldMarkersRequiringClipboardPermission.has(marker)) { + return false; + } + } + } + } + } + + return true; + } + + // Private + + _getAnkiFieldMarkers(fieldValue) { + const pattern = this._ankiMarkerPattern; + const markers = []; + let match; + while ((match = pattern.exec(fieldValue)) !== null) { + markers.push(match[1]); + } + return markers; + } +} diff --git a/ext/bg/js/settings/anki-controller.js b/ext/bg/js/settings/anki-controller.js index cb6922b8..db3e3c14 100644 --- a/ext/bg/js/settings/anki-controller.js +++ b/ext/bg/js/settings/anki-controller.js @@ -34,10 +34,6 @@ class AnkiController { onRemoved: this._removeCardController.bind(this), isStale: this._isCardControllerStale.bind(this) }); - this._fieldMarkersRequiringClipboardPermission = new Set([ - 'clipboard-image', - 'clipboard-text' - ]); this._stringComparer = new Intl.Collator(); // Locale does not matter this._getAnkiDataPromise = null; this._ankiErrorContainer = null; @@ -157,13 +153,7 @@ class AnkiController { } getRequiredPermissions(fieldValue) { - const markers = this._getFieldMarkers(fieldValue); - for (const marker of markers) { - if (this._fieldMarkersRequiringClipboardPermission.has(marker)) { - return ['clipboardRead']; - } - } - return []; + return this._settingsController.permissionsUtil.getRequiredPermissionsForAnkiFieldValue(fieldValue); } containsAnyMarker(field) { @@ -338,16 +328,6 @@ class AnkiController { this._ankiErrorMessageDetailsToggle.hidden = false; } - _getFieldMarkers(fieldValue) { - const pattern = /\{([\w-]+)\}/g; - const markers = []; - let match; - while ((match = pattern.exec(fieldValue)) !== null) { - markers.push(match[1]); - } - return markers; - } - _sortStringArray(array) { const stringComparer = this._stringComparer; array.sort((a, b) => stringComparer.compare(a, b)); @@ -656,7 +636,7 @@ class AnkiCardController { async _requestPermissions(permissions) { try { - await this._settingsController.setPermissionsGranted(permissions, true); + await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true); } catch (e) { yomichan.logError(e); } @@ -669,8 +649,8 @@ class AnkiCardController { node.dataset.requiredPermission = permissions.join(' '); const hasPermissions = await ( request ? - this._settingsController.setPermissionsGranted(permissions, true) : - this._settingsController.hasPermissions(permissions) + this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true) : + this._settingsController.permissionsUtil.hasPermissions({permissions}) ); node.dataset.hasPermissions = `${hasPermissions}`; } else { diff --git a/ext/bg/js/settings/backup-controller.js b/ext/bg/js/settings/backup-controller.js index 34817ee9..8837b927 100644 --- a/ext/bg/js/settings/backup-controller.js +++ b/ext/bg/js/settings/backup-controller.js @@ -87,7 +87,7 @@ class BackupController { const optionsFull = await this._settingsController.getOptionsFull(); const environment = await api.getEnvironmentInfo(); const fieldTemplatesDefault = await api.getDefaultAnkiFieldTemplates(); - const permissions = await this._settingsController.getAllPermissions(); + const permissions = await this._settingsController.permissionsUtil.getAllPermissions(); // Format options for (const {options} of optionsFull.profiles) { diff --git a/ext/bg/js/settings/permissions-toggle-controller.js b/ext/bg/js/settings/permissions-toggle-controller.js index 04c8f3f2..f80e7585 100644 --- a/ext/bg/js/settings/permissions-toggle-controller.js +++ b/ext/bg/js/settings/permissions-toggle-controller.js @@ -71,13 +71,13 @@ class PermissionsToggleController { if (value || !hasPermissionsSetting) { toggle.checked = valuePre; - const requiredPermissions = this._getRequiredPermissions(toggle); + const permissions = this._getRequiredPermissions(toggle); try { - value = await this._settingsController.setPermissionsGranted(requiredPermissions, value); + value = await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, value); } catch (error) { value = valuePre; try { - value = await this._settingsController.hasPermissions(requiredPermissions); + value = await this._settingsController.permissionsUtil.hasPermissions({permissions}); } catch (error2) { // NOP } @@ -113,7 +113,7 @@ class PermissionsToggleController { } async _updateValidity() { - const permissions = await this._settingsController.getAllPermissions(); + const permissions = await this._settingsController.permissionsUtil.getAllPermissions(); this._onPermissionsChanged({permissions}); } diff --git a/ext/bg/js/settings/settings-controller.js b/ext/bg/js/settings/settings-controller.js index a3885ef6..11a9435c 100644 --- a/ext/bg/js/settings/settings-controller.js +++ b/ext/bg/js/settings/settings-controller.js @@ -18,6 +18,7 @@ /* global * HtmlTemplateCollection * OptionsUtil + * PermissionsUtil * api */ @@ -29,6 +30,7 @@ class SettingsController extends EventDispatcher { this._pageExitPreventions = new Set(); this._pageExitPreventionEventListeners = new EventListenerCollection(); this._templates = new HtmlTemplateCollection(document); + this._permissionsUtil = new PermissionsUtil(); } get source() { @@ -44,6 +46,10 @@ class SettingsController extends EventDispatcher { this._setProfileIndex(value); } + get permissionsUtil() { + return this._permissionsUtil; + } + prepare() { yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); chrome.permissions.onAdded.addListener(this._onPermissionsChanged.bind(this)); @@ -134,50 +140,6 @@ class SettingsController extends EventDispatcher { return optionsFull; } - hasPermissions(permissions) { - return new Promise((resolve, reject) => chrome.permissions.contains({permissions}, (result) => { - const e = chrome.runtime.lastError; - if (e) { - reject(new Error(e.message)); - } else { - resolve(result); - } - })); - } - - setPermissionsGranted(permissions, shouldHave) { - return ( - shouldHave ? - new Promise((resolve, reject) => chrome.permissions.request({permissions}, (result) => { - const e = chrome.runtime.lastError; - if (e) { - reject(new Error(e.message)); - } else { - resolve(result); - } - })) : - new Promise((resolve, reject) => chrome.permissions.remove({permissions}, (result) => { - const e = chrome.runtime.lastError; - if (e) { - reject(new Error(e.message)); - } else { - resolve(!result); - } - })) - ); - } - - getAllPermissions() { - return new Promise((resolve, reject) => chrome.permissions.getAll((result) => { - const e = chrome.runtime.lastError; - if (e) { - reject(new Error(e.message)); - } else { - resolve(result); - } - })); - } - // Private _setProfileIndex(value) { @@ -242,7 +204,7 @@ class SettingsController extends EventDispatcher { const event = 'permissionsChanged'; if (!this.hasListeners(event)) { return; } - const permissions = await this.getAllPermissions(); + const permissions = await this._permissionsUtil.getAllPermissions(); this.trigger(event, {permissions}); } } diff --git a/ext/bg/permissions.html b/ext/bg/permissions.html index 45c65a65..de235eeb 100644 --- a/ext/bg/permissions.html +++ b/ext/bg/permissions.html @@ -168,7 +168,7 @@ - + diff --git a/ext/bg/settings.html b/ext/bg/settings.html index a087ba95..8bbcdb5a 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -1305,6 +1305,7 @@ + diff --git a/ext/bg/settings2.html b/ext/bg/settings2.html index b772ece1..88a55206 100644 --- a/ext/bg/settings2.html +++ b/ext/bg/settings2.html @@ -3219,6 +3219,7 @@ + diff --git a/ext/bg/welcome.html b/ext/bg/welcome.html index 09544627..aeebd9dd 100644 --- a/ext/bg/welcome.html +++ b/ext/bg/welcome.html @@ -336,6 +336,7 @@ + diff --git a/ext/mixed/css/material.css b/ext/mixed/css/material.css index 6dba7206..365b15ad 100644 --- a/ext/mixed/css/material.css +++ b/ext/mixed/css/material.css @@ -216,6 +216,7 @@ .icon[data-icon=right-chevron] { --icon-image: url(/mixed/img/right-chevron.svg); } .icon[data-icon=plus-thick] { --icon-image: url(/mixed/img/plus-thick.svg); } .icon[data-icon=clipboard] { --icon-image: url(/mixed/img/clipboard.svg); } +.icon[data-icon=key] { --icon-image: url(/mixed/img/key.svg); } .icon[data-icon=material-down-arrow] { --icon-image: url(/mixed/img/material-down-arrow.svg); --icon-size: var(--material-arrow-dimension2) var(--material-arrow-dimension1); diff --git a/ext/mixed/img/key.svg b/ext/mixed/img/key.svg new file mode 100644 index 00000000..738e07a3 --- /dev/null +++ b/ext/mixed/img/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ext/sw.js b/ext/sw.js index dd9a4060..d18d367a 100644 --- a/ext/sw.js +++ b/ext/sw.js @@ -36,6 +36,7 @@ self.importScripts( '/bg/js/mecab.js', '/bg/js/media-utility.js', '/bg/js/options.js', + '/bg/js/permissions-util.js', '/bg/js/profile-conditions.js', '/bg/js/request-builder.js', '/bg/js/simple-dom-parser.js', diff --git a/resources/icons.svg b/resources/icons.svg index e009bc47..18be3afe 100644 --- a/resources/icons.svg +++ b/resources/icons.svg @@ -25,11 +25,11 @@ borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" - inkscape:zoom="32" - inkscape:cx="7.2634756" - inkscape:cy="9.0903162" + inkscape:zoom="22.627417" + inkscape:cx="8.8042238" + inkscape:cy="5.555245" inkscape:document-units="px" - inkscape:current-layer="layer50" + inkscape:current-layer="layer51" showgrid="true" units="px" inkscape:snap-center="true" @@ -1594,11 +1594,22 @@ + inkscape:label="Clipboard" + style="display:none"> + + +