From 8ec9a52e4a7d99d01b1f89b9bd1537f0a31fb9e0 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 24 Oct 2026 16:13:18 +0200 Subject: [PATCH 1/3] Drop lifting the style protection, enforce protected metadata server-side and leave copies unprotected --- com.woltlab.wcf/package.xml | 1 + ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts | 28 ------------- .../install/files/acp/templates/styleAdd.tpl | 14 ------- ...te_com.woltlab.wcf_6.3_styleProtection.php | 32 ++++++++++++++ .../WoltLabSuite/Core/Acp/Ui/Style/Editor.js | 25 +---------- .../lib/acp/form/StyleEditForm.class.php | 17 ++++++++ .../lib/command/style/CopyStyle.class.php | 17 ++++++++ .../lib/data/style/StyleAction.class.php | 42 ++----------------- .../lib/data/style/StyleEditor.class.php | 3 ++ wcfsetup/install/lang/de.xml | 8 ++-- wcfsetup/install/lang/en.xml | 8 ++-- 11 files changed, 83 insertions(+), 112 deletions(-) create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleProtection.php diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index bc136faa0db..9e5b05659b7 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -64,6 +64,7 @@ acp/update_com.woltlab.wcf_6.3_styleTemplateGroup.php acp/update_com.woltlab.wcf_6.3_styleVariables.php + acp/update_com.woltlab.wcf_6.3_styleProtection.php acp/database/update_com.woltlab.wcf_6.3_step2.php acp/update_com.woltlab.wcf_6.3_embeddedObjects.php diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts b/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts index b54c78cc168..914f0b0c7a3 100644 --- a/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts +++ b/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts @@ -6,7 +6,6 @@ * @license GNU Lesser General Public License */ -import * as Ajax from "../../../Ajax"; import * as Core from "../../../Core"; import DomUtil from "../../../Dom/Util"; import * as EventHandler from "../../../Event/Handler"; @@ -22,7 +21,6 @@ let _updateRegionMarker: () => void; interface StyleEditorOptions { isTainted: boolean; - styleId: number; } /** @@ -81,28 +79,6 @@ function handleScss(isTainted: boolean): void { } } -function handleProtection(styleId: number): void { - const button = document.getElementById("styleDisableProtectionSubmit") as HTMLButtonElement; - const checkbox = document.getElementById("styleDisableProtectionConfirm") as HTMLInputElement; - - checkbox.addEventListener("change", () => { - button.disabled = !checkbox.checked; - }); - - button.addEventListener("click", () => { - Ajax.apiOnce({ - data: { - actionName: "markAsTainted", - className: "wcf\\data\\style\\StyleAction", - objectIDs: [styleId], - }, - success: () => { - window.location.reload(); - }, - }); - }); -} - /** * Mirrors the selected style options in the preview, e.g. `data-page-header-layout`. */ @@ -281,10 +257,6 @@ export function setup(options: StyleEditorOptions): void { handleLayoutWidth(); handleScss(options.isTainted); - if (!options.isTainted) { - handleProtection(options.styleId); - } - initVisualEditor(); handleStyleOptions(); diff --git a/wcfsetup/install/files/acp/templates/styleAdd.tpl b/wcfsetup/install/files/acp/templates/styleAdd.tpl index dc768ba5b42..d29a51db3c3 100644 --- a/wcfsetup/install/files/acp/templates/styleAdd.tpl +++ b/wcfsetup/install/files/acp/templates/styleAdd.tpl @@ -8,7 +8,6 @@ require(["WoltLabSuite/Core/Acp/Ui/Style/Editor", "WoltLabSuite/Core/Ui/Color/Picker"], (AcpUiStyleEditor, ColorPicker) => { AcpUiStyleEditor.setup({ isTainted: {if $isTainted}true{else}false{/if}, - styleId: {if $action === 'edit'}{$style->styleID}{else}0{/if}, }); ColorPicker.fromSelector(".jsColorPicker"); @@ -1180,17 +1179,4 @@ -
-

{lang}wcf.acp.style.protected.description{/lang}

- -
-
-
-
- -
- -
-
- {include file='footer'} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleProtection.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleProtection.php new file mode 100644 index 00000000000..022acec7c16 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleProtection.php @@ -0,0 +1,32 @@ +prepare($sql); +$statement->execute([ + "/* WCF_STYLE_CUSTOM_USER_MODIFICATIONS */\n", + 0, + '', + 'individualScss', + 'individualScssDarkMode', + 'overrideScss', +]); + +$sql = "UPDATE wcf1_style + SET isTainted = ? + WHERE isTainted = ? + AND packageName = ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([1, 0, '']); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js index ef89d12ef64..1ca5ab46757 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js @@ -5,13 +5,12 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -define(["require", "exports", "tslib", "../../../Ajax", "../../../Core", "../../../Dom/Util", "../../../Event/Handler", "../../../Ui/Screen"], function (require, exports, tslib_1, Ajax, Core, Util_1, EventHandler, UiScreen) { +define(["require", "exports", "tslib", "../../../Core", "../../../Dom/Util", "../../../Event/Handler", "../../../Ui/Screen"], function (require, exports, tslib_1, Core, Util_1, EventHandler, UiScreen) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; exports.hideVisualEditor = hideVisualEditor; exports.showVisualEditor = showVisualEditor; - Ajax = tslib_1.__importStar(Ajax); Core = tslib_1.__importStar(Core); Util_1 = tslib_1.__importDefault(Util_1); EventHandler = tslib_1.__importStar(EventHandler); @@ -74,25 +73,6 @@ define(["require", "exports", "tslib", "../../../Ajax", "../../../Core", "../../ }); } } - function handleProtection(styleId) { - const button = document.getElementById("styleDisableProtectionSubmit"); - const checkbox = document.getElementById("styleDisableProtectionConfirm"); - checkbox.addEventListener("change", () => { - button.disabled = !checkbox.checked; - }); - button.addEventListener("click", () => { - Ajax.apiOnce({ - data: { - actionName: "markAsTainted", - className: "wcf\\data\\style\\StyleAction", - objectIDs: [styleId], - }, - success: () => { - window.location.reload(); - }, - }); - }); - } /** * Mirrors the selected style options in the preview, e.g. `data-page-header-layout`. */ @@ -242,9 +222,6 @@ define(["require", "exports", "tslib", "../../../Ajax", "../../../Core", "../../ function setup(options) { handleLayoutWidth(); handleScss(options.isTainted); - if (!options.isTainted) { - handleProtection(options.styleId); - } initVisualEditor(); handleStyleOptions(); UiScreen.on("screen-sm-down", { diff --git a/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php b/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php index 180c96c5a3a..8005bc41543 100644 --- a/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php @@ -65,6 +65,23 @@ public function readParameters() parent::readParameters(); } + #[\Override] + public function readFormParameters() + { + parent::readFormParameters(); + + // The fields are only marked as `readonly` for protected styles. + if ($this->style->isTainted === 0) { + $this->authorName = $this->style->authorName; + $this->authorURL = $this->style->authorURL; + $this->copyright = $this->style->copyright; + $this->license = $this->style->license; + $this->packageName = $this->style->packageName; + $this->styleDate = $this->style->styleDate; + $this->styleVersion = $this->style->styleVersion; + } + } + #[\Override] public function validate() { diff --git a/wcfsetup/install/files/lib/command/style/CopyStyle.class.php b/wcfsetup/install/files/lib/command/style/CopyStyle.class.php index e1eb20a5100..5c84b31ddc8 100644 --- a/wcfsetup/install/files/lib/command/style/CopyStyle.class.php +++ b/wcfsetup/install/files/lib/command/style/CopyStyle.class.php @@ -55,6 +55,8 @@ private function copyStyle(Style $style): Style 'coverPhotoExtension' => $style->coverPhotoExtension, 'hasFavicon' => $style->hasFavicon, 'hasDarkMode' => $style->hasDarkMode, + // A copy is never updated, protecting it would only lock the preset declarations. + 'isTainted' => 1, ]); } @@ -97,6 +99,21 @@ private function copyVariables(Style $source, Style $destination): void variableValueDarkMode = VALUES(variableValueDarkMode)"; $statement = WCF::getDB()->prepare($sql); $statement->execute([$destination->styleID, $source->styleID]); + + $sql = "UPDATE wcf1_style_variable_value style_variable_value + INNER JOIN wcf1_style_variable style_variable + ON style_variable.variableID = style_variable_value.variableID + SET style_variable_value.variableValue = REPLACE(style_variable_value.variableValue, ?, '') + WHERE style_variable_value.styleID = ? + AND style_variable.variableName IN (?, ?, ?)"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + "/* WCF_STYLE_CUSTOM_USER_MODIFICATIONS */\n", + $destination->styleID, + 'individualScss', + 'individualScssDarkMode', + 'overrideScss', + ]); } private function copyAssets(Style $source, Style $destination): void diff --git a/wcfsetup/install/files/lib/data/style/StyleAction.class.php b/wcfsetup/install/files/lib/data/style/StyleAction.class.php index dbcb5f977fc..e5dd06a7556 100644 --- a/wcfsetup/install/files/lib/data/style/StyleAction.class.php +++ b/wcfsetup/install/files/lib/data/style/StyleAction.class.php @@ -586,54 +586,20 @@ public function getStyleChooser() } /** - * Validates the mark as tainted action. - * * @return void + * @deprecated 6.3 The protection of a style can no longer be lifted. */ public function validateMarkAsTainted() { - if (!WCF::getSession()->hasPermission('admin.style.canManageStyle')) { - throw new PermissionDeniedException(); - } - - $this->styleEditor = $this->getSingleObject(); + throw new PermissionDeniedException(); } /** - * Marks a style as tainted. + * @deprecated 6.3 The protection of a style can no longer be lifted. */ public function markAsTainted(): void { - // merge definitions - $variables = $this->styleEditor->getVariables(); - $variables['individualScss'] = \str_replace( - "/* WCF_STYLE_CUSTOM_USER_MODIFICATIONS */\n", - '', - $variables['individualScss'] - ); - $variables['overrideScss'] = \str_replace( - "/* WCF_STYLE_CUSTOM_USER_MODIFICATIONS */\n", - '', - $variables['overrideScss'] - ); - - $variablesDarkMode = []; - $variables = \array_filter($variables, static function ($value, $key) use (&$variablesDarkMode) { - if (!\str_starts_with($key, Style::DARK_MODE_PREFIX)) { - return true; - } - - $variablesDarkMode[\str_replace(Style::DARK_MODE_PREFIX, '', $key)] = $value; - - return false; - }, \ARRAY_FILTER_USE_BOTH); - - $this->styleEditor->setVariables($variables, $variablesDarkMode); - - $this->styleEditor->update([ - 'isTainted' => 1, - 'packageName' => '', - ]); + throw new PermissionDeniedException(); } /** diff --git a/wcfsetup/install/files/lib/data/style/StyleEditor.class.php b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php index 31b8afb30f4..7a078fc2665 100644 --- a/wcfsetup/install/files/lib/data/style/StyleEditor.class.php +++ b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php @@ -653,6 +653,9 @@ public static function import(string $filename, int $packageID = 1, ?self $style } $styleData['packageID'] = $packageID; + // A style without a package name is never updated, protecting it + // would only lock the preset declarations. + $styleData['isTainted'] = $styleData['packageName'] === '' ? 1 : 0; $style = new self(self::create($styleData)); // handle descriptions diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 8eef1973ee0..e563712aa32 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -2957,11 +2957,8 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z - Schutz aufheben.]]> - -
Dieser Schritt wird nicht empfohlen und ist grundsätzlich nicht notwendig, Stile können weiterhin nach belieben angepasst werden, nur die Vorlage selbst kann nicht verändert werden.

Bitte {if LANGUAGE_USE_INFORMAL_VARIANT}speicher{else}speichern Sie{/if} eventuelle vorgenommene Änderungen bevor {if LANGUAGE_USE_INFORMAL_VARIANT}du{else}Sie{/if} diesen Schritt {if LANGUAGE_USE_INFORMAL_VARIANT}ausführst{else}ausführen{/if}.]]>
+ - @@ -5867,5 +5864,8 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]> + + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 2397762b774..878b5f7b1d0 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2887,11 +2887,8 @@ If you have already bought the licenses for the listed apps, th - disable this protection.]]> - -
It is neither recommended nor necessary to remove the protection in most cases, styles can still be fully customized while preserving the preset declarations.

Please save any unsaved changes before proceeding.]]>
+ - @@ -5869,5 +5866,8 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]>
+ + + From b627bd2f89c28b6dd324ff25a61419cb1f6b5e82 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 24 Oct 2026 19:15:56 +0200 Subject: [PATCH 2/3] Show protected style metadata read-only and name the package or import that updates the style --- .../install/files/acp/templates/styleAdd.tpl | 237 ++++++++++-------- .../lib/acp/form/StyleEditForm.class.php | 23 ++ wcfsetup/install/lang/de.xml | 2 +- wcfsetup/install/lang/en.xml | 2 +- 4 files changed, 163 insertions(+), 101 deletions(-) diff --git a/wcfsetup/install/files/acp/templates/styleAdd.tpl b/wcfsetup/install/files/acp/templates/styleAdd.tpl index d29a51db3c3..eef5d0e2b41 100644 --- a/wcfsetup/install/files/acp/templates/styleAdd.tpl +++ b/wcfsetup/install/files/acp/templates/styleAdd.tpl @@ -115,105 +115,144 @@ {/if} - -
-
- - {if $errorField == 'authorName'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.style.authorName.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
- - {if $errorField == 'copyright'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.style.copyright.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
- - {if $errorField == 'styleVersion'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.style.styleVersion.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
- - {if $errorField == 'styleDate'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.style.styleDate.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
- - {if $errorField == 'license'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.style.license.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
- - {if $errorField == 'authorURL'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.style.authorURL.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
- - {if $errorField == 'packageName'} - {lang}wcf.acp.style.packageName.error.{$errorType}{/lang} - {/if} -
- + {if $isTainted} + +
+
+ + {if $errorField == 'authorName'} + + {if $errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} + {else} + {lang}wcf.acp.style.authorName.error.{$errorType}{/lang} + {/if} + + {/if} +
+ + +
+
+ + {if $errorField == 'copyright'} + + {if $errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} + {else} + {lang}wcf.acp.style.copyright.error.{$errorType}{/lang} + {/if} + + {/if} +
+ + +
+
+ + {if $errorField == 'styleVersion'} + + {if $errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} + {else} + {lang}wcf.acp.style.styleVersion.error.{$errorType}{/lang} + {/if} + + {/if} +
+ + +
+
+ + {if $errorField == 'styleDate'} + + {if $errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} + {else} + {lang}wcf.acp.style.styleDate.error.{$errorType}{/lang} + {/if} + + {/if} +
+ + +
+
+ + {if $errorField == 'license'} + + {if $errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} + {else} + {lang}wcf.acp.style.license.error.{$errorType}{/lang} + {/if} + + {/if} +
+ + +
+
+ + {if $errorField == 'authorURL'} + + {if $errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} + {else} + {lang}wcf.acp.style.authorURL.error.{$errorType}{/lang} + {/if} + + {/if} +
+ + +
+
+ + {if $errorField == 'packageName'} + {lang}wcf.acp.style.packageName.error.{$errorType}{/lang} + {/if} +
+ + {else} + {if $authorName !== ''} +
+
{lang}wcf.acp.style.authorName{/lang}
+
{$authorName}
+
+ {/if} + {if $copyright !== ''} +
+
{lang}wcf.acp.style.copyright{/lang}
+
{$copyright}
+
+ {/if} + {if $styleVersion !== ''} +
+
{lang}wcf.acp.style.styleVersion{/lang}
+
{$styleVersion}
+
+ {/if} + {if $styleDate !== ''} +
+
{lang}wcf.acp.style.styleDate{/lang}
+
{$styleDate}
+
+ {/if} + {if $license !== ''} +
+
{lang}wcf.acp.style.license{/lang}
+
{$license}
+
+ {/if} + {if $authorURL !== ''} +
+
{lang}wcf.acp.style.authorURL{/lang}
+
{$authorURL}
+
+ {/if} + {/if}
diff --git a/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php b/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php index 8005bc41543..17656e2a69a 100644 --- a/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php @@ -3,6 +3,8 @@ namespace wcf\acp\form; use wcf\acp\page\StyleListPage; +use wcf\data\package\Package; +use wcf\data\package\PackageCache; use wcf\data\style\Style; use wcf\data\style\StyleAction; use wcf\data\style\StyleEditor; @@ -452,6 +454,9 @@ public function assignVariables() 'style' => $this->style, 'styleID' => $this->styleID, 'isDarkMode' => $this->isDarkMode, + 'stylePackage' => $this->getStylePackage(), + 'canViewStylePackage' => WCF::getSession()->hasPermission('admin.configuration.package.canInstallPackage') + && WCF::getSession()->hasPermission('admin.configuration.package.canUpdatePackage'), 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( new StyleInteractions(), $this->style, @@ -459,4 +464,22 @@ public function assignVariables() ), ]); } + + /** + * Returns the package that updates this style. Imported styles carry the + * Core's package id, but are only updated by importing them again. + */ + private function getStylePackage(): ?Package + { + if ($this->style->isTainted !== 0) { + return null; + } + + $package = PackageCache::getInstance()->getPackage($this->style->packageID); + if ($package === null || ($package->packageID === 1 && $this->style->packageName !== 'com.woltlab.wcf')) { + return null; + } + + return $package; + } } diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index e563712aa32..c7ddf513d5b 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -2957,7 +2957,7 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z - + getLink()}">{$stylePackage->getTitle()}{else}„{$stylePackage->getTitle()}“{/if} bereitgestellt und mit diesem aktualisiert.{elseif $style->packageName !== ''}Dieser Stil wurde importiert und wird durch den Import einer neueren Version aktualisiert.{else}Dieser Stil ist geschützt und kann nur eingeschränkt verändert werden.{/if}]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 878b5f7b1d0..d57976bc344 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2887,7 +2887,7 @@ If you have already bought the licenses for the listed apps, th - + getLink()}">{$stylePackage->getTitle()}{else}“{$stylePackage->getTitle()}”{/if} and receives updates with it.{elseif $style->packageName !== ''}This style was imported and receives updates by importing a newer version of it.{else}This style is protected and editing is limited.{/if}]]> From 35e31d2a7f3bb04b4f0299852b4423208e85d164 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 24 Oct 2026 21:15:32 +0200 Subject: [PATCH 3/3] Record the colors shipped by a style and offer to restore them in the style editor --- com.woltlab.wcf/package.xml | 1 + ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts | 39 +++++++++++++ ts/WoltLabSuite/Core/Ui/Color/Picker.ts | 7 ++- .../update_com.woltlab.wcf_6.3_step1.php | 5 ++ .../files/acp/style/acpStyleEditor.css | 11 ++++ .../install/files/acp/templates/styleAdd.tpl | 3 + ...om.woltlab.wcf_6.3_styleAuthoredValues.php | 17 ++++++ .../WoltLabSuite/Core/Acp/Ui/Style/Editor.js | 32 ++++++++++- .../js/WoltLabSuite/Core/Ui/Color/Picker.js | 5 +- .../lib/acp/form/StyleEditForm.class.php | 30 ++++++++++ .../lib/command/style/CopyStyle.class.php | 8 ++- .../files/lib/data/style/Style.class.php | 31 ++++++++++ .../lib/data/style/StyleEditor.class.php | 57 +++++++++++++++++-- wcfsetup/install/lang/de.xml | 1 + wcfsetup/install/lang/en.xml | 1 + wcfsetup/setup/db/install_com.woltlab.wcf.php | 2 + 16 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleAuthoredValues.php diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index 9e5b05659b7..dc24d8a9d08 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -65,6 +65,7 @@ acp/update_com.woltlab.wcf_6.3_styleTemplateGroup.php acp/update_com.woltlab.wcf_6.3_styleVariables.php acp/update_com.woltlab.wcf_6.3_styleProtection.php + acp/update_com.woltlab.wcf_6.3_styleAuthoredValues.php acp/database/update_com.woltlab.wcf_6.3_step2.php acp/update_com.woltlab.wcf_6.3_embeddedObjects.php diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts b/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts index 914f0b0c7a3..828466b368b 100644 --- a/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts +++ b/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts @@ -7,6 +7,7 @@ */ import * as Core from "../../../Core"; +import * as ColorUtil from "../../../ColorUtil"; import DomUtil from "../../../Dom/Util"; import * as EventHandler from "../../../Event/Handler"; import * as UiScreen from "../../../Ui/Screen"; @@ -23,6 +24,24 @@ interface StyleEditorOptions { isTainted: boolean; } +/** + * Compares two colors regardless of their notation, e.g. `.8` and `0.8` for the alpha channel. + */ +function isSameColor(a: string, b: string): boolean { + if (a === b) { + return true; + } + + if (!ColorUtil.isValidColor(a) || !ColorUtil.isValidColor(b)) { + return false; + } + + const colorA = ColorUtil.stringToRgba(a); + const colorB = ColorUtil.stringToRgba(b); + + return colorA.r === colorB.r && colorA.g === colorB.g && colorA.b === colorB.b && colorA.a === colorB.a; +} + /** * Handles the switch between static and fluid layout. */ @@ -182,11 +201,30 @@ function initVisualEditor(): void { const wrapper = document.getElementById("spVariablesWrapper")!; wrapper.querySelectorAll(".styleVariableColor").forEach((colorField: HTMLElement) => { const variableName = colorField.dataset.store!.replace(/_value$/, ""); + const input = document.getElementById(colorField.dataset.store!) as HTMLInputElement; + const restoreButton = wrapper.querySelector( + `.spColorRestore[data-store="${colorField.dataset.store!}"]`, + ); + + const updateRestoreButton = () => { + if (restoreButton !== null) { + restoreButton.hidden = isSameColor(input.value, restoreButton.dataset.authoredColor!); + } + }; + + restoreButton?.addEventListener("click", () => { + const authoredColor = restoreButton.dataset.authoredColor!; + + input.value = authoredColor; + colorField.dataset.color = authoredColor; + colorField.style.setProperty("background-color", authoredColor); + }); const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === "style") { spWindow.style.setProperty(`--${variableName}`, colorField.style.getPropertyValue("background-color")); + updateRestoreButton(); } }); }); @@ -196,6 +234,7 @@ function initVisualEditor(): void { }); spWindow.style.setProperty(`--${variableName}`, colorField.style.getPropertyValue("background-color")); + updateRestoreButton(); }); // category selection by clicking on the area diff --git a/ts/WoltLabSuite/Core/Ui/Color/Picker.ts b/ts/WoltLabSuite/Core/Ui/Color/Picker.ts index 96991049a6b..824e3150076 100644 --- a/ts/WoltLabSuite/Core/Ui/Color/Picker.ts +++ b/ts/WoltLabSuite/Core/Ui/Color/Picker.ts @@ -191,7 +191,9 @@ class UiColorPicker implements DialogCallbackObject { this.opacityInput!.addEventListener("input", (event) => { this.setOpacity(event); }); - + }, + onShow: () => { + // The stored value can change while the dialog is closed. if (ColorUtil.isValidColor(this.input.value)) { this.setInitialColor(this.input.value); } else if (this.element.dataset.color && ColorUtil.isValidColor(this.element.dataset.color)) { @@ -199,8 +201,7 @@ class UiColorPicker implements DialogCallbackObject { } else { this.setInitialColor("#FFF0"); } - }, - onShow: () => { + this.colorTextInput!.focus(); this.colorTextInput!.select(); }, diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index ea89f87ca72..2f9ebab65b3 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -253,6 +253,11 @@ ->columns([ IntDatabaseTableColumn::create('templateGroupID'), ]), + PartialDatabaseTable::create('wcf1_style_variable_value') + ->columns([ + MediumtextDatabaseTableColumn::create('authoredValue'), + MediumtextDatabaseTableColumn::create('authoredValueDarkMode'), + ]), PartialDatabaseTable::create('wcf1_user_activity_event') ->columns([ IntDatabaseTableColumn::create('userID'), diff --git a/wcfsetup/install/files/acp/style/acpStyleEditor.css b/wcfsetup/install/files/acp/style/acpStyleEditor.css index 5f571b070ef..370a6d2a05b 100644 --- a/wcfsetup/install/files/acp/style/acpStyleEditor.css +++ b/wcfsetup/install/files/acp/style/acpStyleEditor.css @@ -1055,6 +1055,17 @@ html:not(.touch) .spColorBox { display: flex; flex: 0 0 auto; } +.spColorRestore { + color: var(--wcfContentDimmedText); + flex: 0 0 auto; + margin-left: auto; +} +.spColorRestore:not([hidden]) { + display: flex; +} +.spColorRestore:hover { + color: var(--wcfContentText); +} #spWindow[data-page-header-layout="classic"] + #spSidebar .spColorScope[data-style-option="pageHeaderLayout"], #spWindow[data-page-footer-layout="classic"] + #spSidebar .spColorScope[data-style-option="pageFooterLayout"] { display: none; diff --git a/wcfsetup/install/files/acp/templates/styleAdd.tpl b/wcfsetup/install/files/acp/templates/styleAdd.tpl index eef5d0e2b41..448274ab81a 100644 --- a/wcfsetup/install/files/acp/templates/styleAdd.tpl +++ b/wcfsetup/install/files/acp/templates/styleAdd.tpl @@ -1050,6 +1050,9 @@ {if $colorScopes[$spColor]|isset} {icon name='circle-info'} {/if} + {if $authoredVariables[$spColor]|isset} + + {/if} {$spType} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleAuthoredValues.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleAuthoredValues.php new file mode 100644 index 00000000000..a53ed774552 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleAuthoredValues.php @@ -0,0 +1,17 @@ +prepare($sql); +$statement->execute(['com.woltlab.wcf']); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js index 1ca5ab46757..99ff57fa542 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Style/Editor.js @@ -5,13 +5,14 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -define(["require", "exports", "tslib", "../../../Core", "../../../Dom/Util", "../../../Event/Handler", "../../../Ui/Screen"], function (require, exports, tslib_1, Core, Util_1, EventHandler, UiScreen) { +define(["require", "exports", "tslib", "../../../Core", "../../../ColorUtil", "../../../Dom/Util", "../../../Event/Handler", "../../../Ui/Screen"], function (require, exports, tslib_1, Core, ColorUtil, Util_1, EventHandler, UiScreen) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; exports.hideVisualEditor = hideVisualEditor; exports.showVisualEditor = showVisualEditor; Core = tslib_1.__importStar(Core); + ColorUtil = tslib_1.__importStar(ColorUtil); Util_1 = tslib_1.__importDefault(Util_1); EventHandler = tslib_1.__importStar(EventHandler); UiScreen = tslib_1.__importStar(UiScreen); @@ -21,6 +22,20 @@ define(["require", "exports", "tslib", "../../../Core", "../../../Dom/Util", ".. let _isVisible = true; let _isSmartphone = false; let _updateRegionMarker; + /** + * Compares two colors regardless of their notation, e.g. `.8` and `0.8` for the alpha channel. + */ + function isSameColor(a, b) { + if (a === b) { + return true; + } + if (!ColorUtil.isValidColor(a) || !ColorUtil.isValidColor(b)) { + return false; + } + const colorA = ColorUtil.stringToRgba(a); + const colorB = ColorUtil.stringToRgba(b); + return colorA.r === colorB.r && colorA.g === colorB.g && colorA.b === colorB.b && colorA.a === colorB.a; + } /** * Handles the switch between static and fluid layout. */ @@ -163,10 +178,24 @@ define(["require", "exports", "tslib", "../../../Core", "../../../Dom/Util", ".. const wrapper = document.getElementById("spVariablesWrapper"); wrapper.querySelectorAll(".styleVariableColor").forEach((colorField) => { const variableName = colorField.dataset.store.replace(/_value$/, ""); + const input = document.getElementById(colorField.dataset.store); + const restoreButton = wrapper.querySelector(`.spColorRestore[data-store="${colorField.dataset.store}"]`); + const updateRestoreButton = () => { + if (restoreButton !== null) { + restoreButton.hidden = isSameColor(input.value, restoreButton.dataset.authoredColor); + } + }; + restoreButton?.addEventListener("click", () => { + const authoredColor = restoreButton.dataset.authoredColor; + input.value = authoredColor; + colorField.dataset.color = authoredColor; + colorField.style.setProperty("background-color", authoredColor); + }); const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === "style") { spWindow.style.setProperty(`--${variableName}`, colorField.style.getPropertyValue("background-color")); + updateRestoreButton(); } }); }); @@ -174,6 +203,7 @@ define(["require", "exports", "tslib", "../../../Core", "../../../Dom/Util", ".. attributes: true, }); spWindow.style.setProperty(`--${variableName}`, colorField.style.getPropertyValue("background-color")); + updateRestoreButton(); }); // category selection by clicking on the area const buttonToggleColorPalette = document.querySelector(".jsButtonToggleColorPalette"); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Color/Picker.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Color/Picker.js index 4aac61b166a..14d1ec3ad06 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Color/Picker.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Color/Picker.js @@ -143,6 +143,9 @@ define(["require", "exports", "tslib", "../../Core", "../Dialog", "../../Dom/Uti this.opacityInput.addEventListener("input", (event) => { this.setOpacity(event); }); + }, + onShow: () => { + // The stored value can change while the dialog is closed. if (ColorUtil.isValidColor(this.input.value)) { this.setInitialColor(this.input.value); } @@ -152,8 +155,6 @@ define(["require", "exports", "tslib", "../../Core", "../Dialog", "../../Dom/Uti else { this.setInitialColor("#FFF0"); } - }, - onShow: () => { this.colorTextInput.focus(); this.colorTextInput.select(); }, diff --git a/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php b/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php index 17656e2a69a..79db9085d80 100644 --- a/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/StyleEditForm.class.php @@ -454,6 +454,7 @@ public function assignVariables() 'style' => $this->style, 'styleID' => $this->styleID, 'isDarkMode' => $this->isDarkMode, + 'authoredVariables' => $this->getAuthoredVariables(), 'stylePackage' => $this->getStylePackage(), 'canViewStylePackage' => WCF::getSession()->hasPermission('admin.configuration.package.canInstallPackage') && WCF::getSession()->hasPermission('admin.configuration.package.canUpdatePackage'), @@ -482,4 +483,33 @@ private function getStylePackage(): ?Package return $package; } + + /** + * Returns the authored values of the variables edited in the current mode. + * The dark mode edits the light value of variables without dark mode support. + * + * @return array + */ + private function getAuthoredVariables(): array + { + $authoredVariables = $this->style->getAuthoredVariables(); + $supportsDarkMode = $this->isDarkMode ? Style::getVariablesWithDarkModeSupport() : []; + + $variables = []; + foreach ($authoredVariables as $variableName => $value) { + if (\str_starts_with($variableName, Style::DARK_MODE_PREFIX)) { + continue; + } + + if (\in_array($variableName, $supportsDarkMode, true)) { + $value = $authoredVariables[Style::DARK_MODE_PREFIX . $variableName] ?? null; + } + + if ($value !== null) { + $variables[$variableName] = $value; + } + } + + return $variables; + } } diff --git a/wcfsetup/install/files/lib/command/style/CopyStyle.class.php b/wcfsetup/install/files/lib/command/style/CopyStyle.class.php index 5c84b31ddc8..03f25223b34 100644 --- a/wcfsetup/install/files/lib/command/style/CopyStyle.class.php +++ b/wcfsetup/install/files/lib/command/style/CopyStyle.class.php @@ -91,12 +91,14 @@ private function handleI18n(Style $style): void private function copyVariables(Style $source, Style $destination): void { $sql = "INSERT INTO wcf1_style_variable_value - (styleID, variableID, variableValue, variableValueDarkMode) - SELECT ?, variableID, variableValue, variableValueDarkMode + (styleID, variableID, variableValue, variableValueDarkMode, authoredValue, authoredValueDarkMode) + SELECT ?, variableID, variableValue, variableValueDarkMode, authoredValue, authoredValueDarkMode FROM wcf1_style_variable_value WHERE styleID = ? ON DUPLICATE KEY UPDATE variableValue = VALUES(variableValue), - variableValueDarkMode = VALUES(variableValueDarkMode)"; + variableValueDarkMode = VALUES(variableValueDarkMode), + authoredValue = VALUES(authoredValue), + authoredValueDarkMode = VALUES(authoredValueDarkMode)"; $statement = WCF::getDB()->prepare($sql); $statement->execute([$destination->styleID, $source->styleID]); diff --git a/wcfsetup/install/files/lib/data/style/Style.class.php b/wcfsetup/install/files/lib/data/style/Style.class.php index bca3526e7f1..9659303495f 100644 --- a/wcfsetup/install/files/lib/data/style/Style.class.php +++ b/wcfsetup/install/files/lib/data/style/Style.class.php @@ -130,6 +130,37 @@ public function getVariables() return $this->variables; } + /** + * Returns the values shipped by the author of this style, the values for + * the dark mode are prefixed with `DARK_MODE_PREFIX`. Variables without a + * known value are omitted. + * + * @return array + * @since 6.3 + */ + public function getAuthoredVariables(): array + { + $sql = "SELECT variable.variableName, value.authoredValue, value.authoredValueDarkMode + FROM wcf1_style_variable_value value + INNER JOIN wcf1_style_variable variable + ON variable.variableID = value.variableID + WHERE value.styleID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$this->styleID]); + + $variables = []; + while ($row = $statement->fetchArray()) { + if ($row['authoredValue'] !== null) { + $variables[$row['variableName']] = $row['authoredValue']; + } + if ($row['authoredValueDarkMode'] !== null) { + $variables[self::DARK_MODE_PREFIX . $row['variableName']] = $row['authoredValueDarkMode']; + } + } + + return $variables; + } + /** * Returns a specific style variable or null if not found. * If $toHex is set to true the color defined by the variable diff --git a/wcfsetup/install/files/lib/data/style/StyleEditor.class.php b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php index 7a078fc2665..18a36480015 100644 --- a/wcfsetup/install/files/lib/data/style/StyleEditor.class.php +++ b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php @@ -645,13 +645,20 @@ public static function import(string $filename, int $packageID = 1, ?self $style $importedOptions[$variableName] = $option::tryFromString($styleData['variables'][$variableName] ?? ''); } + $authoredVariables = $styleData['variables']; + foreach (Style::getAvailableOptions() as $option) { + $variableName = $option::getVariableName(); + $authoredVariables[$variableName] = ($importedOptions[$variableName] ?? $option::getFallback())->toString(); + } + + $authoredVariablesDarkMode = null; + if ($styleData['hasDarkMode'] === 1) { + $authoredVariablesDarkMode = \is_array($styleData['variablesDarkMode']) ? $styleData['variablesDarkMode'] : []; + } + // save style if ($style === null) { - foreach (Style::getAvailableOptions() as $option) { - $variableName = $option::getVariableName(); - $styleData['variables'][$variableName] = ($importedOptions[$variableName] ?? $option::getFallback())->toString(); - } - + $styleData['variables'] = $authoredVariables; $styleData['packageID'] = $packageID; // A style without a package name is never updated, protecting it // would only lock the preset declarations. @@ -726,6 +733,9 @@ public static function import(string $filename, int $packageID = 1, ?self $style $style->update($styleData); } + // An update keeps the installed values, but records the ones it ships. + $style->setAuthoredVariables($authoredVariables, $authoredVariablesDarkMode); + // import images if (!empty($data['images'])) { $index = $tar->getIndexByFilename($data['images']); @@ -1302,6 +1312,43 @@ public function setVariables(array $variables, array $variablesDarkMode): void StyleHandler::getInstance()->resetStylesheet($this->getDecoratedObject()); } + /** + * Records the values the style author shipped. Variables missing from the + * archive store the current default, because a later change of the default + * must not alter what the author shipped. + * + * @param array $variables + * @param ?array $variablesDarkMode `null` if the archive has no dark mode + */ + private function setAuthoredVariables(array $variables, ?array $variablesDarkMode): void + { + $sql = "SELECT variableID, variableName, defaultValue, defaultValueDarkMode + FROM wcf1_style_variable"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(); + $styleVariables = $statement->fetchAll(\PDO::FETCH_ASSOC); + + $sql = "INSERT INTO wcf1_style_variable_value + (styleID, variableID, authoredValue, authoredValueDarkMode) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE authoredValue = VALUES(authoredValue), + authoredValueDarkMode = VALUES(authoredValueDarkMode)"; + $statement = WCF::getDB()->prepare($sql); + + WCF::getDB()->beginTransaction(); + foreach ($styleVariables as $row) { + $variableName = $row['variableName']; + + $statement->execute([ + $this->styleID, + $row['variableID'], + $variables[$variableName] ?? $row['defaultValue'], + $variablesDarkMode === null ? null : ($variablesDarkMode[$variableName] ?? $row['defaultValueDarkMode']), + ]); + } + WCF::getDB()->commitTransaction(); + } + /** * Writes the style-*.css file. */ diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index c7ddf513d5b..46cd0dbefd7 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -2898,6 +2898,7 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index d57976bc344..616f00a23da 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2827,6 +2827,7 @@ If you have already bought the licenses for the listed apps, th + diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 3f99455bd7d..99b787a0f41 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -3625,6 +3625,8 @@ NotNullInt10DatabaseTableColumn::create('variableID'), MediumtextDatabaseTableColumn::create('variableValue'), MediumtextDatabaseTableColumn::create('variableValueDarkMode'), + MediumtextDatabaseTableColumn::create('authoredValue'), + MediumtextDatabaseTableColumn::create('authoredValueDarkMode'), ]) ->indices([ DatabaseTableIndex::create('styleID')