From 887004b00ab45df6a416bb86c89d964a50f1b97b Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 15 Nov 2024 11:01:46 +0100 Subject: [PATCH] fixup! fixup! feat(systemtags): add color support Signed-off-by: skjnldsv --- apps/dav/lib/SystemTag/SystemTagNode.php | 5 +- apps/dav/lib/SystemTag/SystemTagPlugin.php | 9 +- .../src/components/SystemTagPicker.vue | 52 ++--- apps/systemtags/src/services/api.ts | 3 +- apps/systemtags/src/utils/colorUtils.ts | 192 ++++++++++++++++-- lib/private/SystemTag/SystemTagManager.php | 40 +--- package-lock.json | 60 +++++- package.json | 1 + 8 files changed, 279 insertions(+), 83 deletions(-) diff --git a/apps/dav/lib/SystemTag/SystemTagNode.php b/apps/dav/lib/SystemTag/SystemTagNode.php index ed4eb44cbbd43..350547865f9ab 100644 --- a/apps/dav/lib/SystemTag/SystemTagNode.php +++ b/apps/dav/lib/SystemTag/SystemTagNode.php @@ -86,12 +86,13 @@ public function setName($name) { * @param string $name new tag name * @param bool $userVisible user visible * @param bool $userAssignable user assignable + * @param string $color color * * @throws NotFound whenever the given tag id does not exist * @throws Forbidden whenever there is no permission to update said tag * @throws Conflict whenever a tag already exists with the given attributes */ - public function update($name, $userVisible, $userAssignable): void { + public function update($name, $userVisible, $userAssignable, $color): void { try { if (!$this->tagManager->canUserSeeTag($this->tag, $this->user)) { throw new NotFound('Tag with id ' . $this->tag->getId() . ' does not exist'); @@ -110,7 +111,7 @@ public function update($name, $userVisible, $userAssignable): void { } } - $this->tagManager->updateTag($this->tag->getId(), $name, $userVisible, $userAssignable); + $this->tagManager->updateTag($this->tag->getId(), $name, $userVisible, $userAssignable, $color); } catch (TagNotFoundException $e) { throw new NotFound('Tag with id ' . $this->tag->getId() . ' does not exist'); } catch (TagAlreadyExistsException $e) { diff --git a/apps/dav/lib/SystemTag/SystemTagPlugin.php b/apps/dav/lib/SystemTag/SystemTagPlugin.php index ae04efe6356cb..afa4b8149d045 100644 --- a/apps/dav/lib/SystemTag/SystemTagPlugin.php +++ b/apps/dav/lib/SystemTag/SystemTagPlugin.php @@ -411,6 +411,7 @@ public function handleUpdateProperties($path, PropPatch $propPatch) { self::GROUPS_PROPERTYNAME, self::NUM_FILES_PROPERTYNAME, self::REFERENCE_FILEID_PROPERTYNAME, + self::COLOR_PROPERTYNAME, ], function ($props) use ($node) { if (!($node instanceof SystemTagNode)) { return false; @@ -420,6 +421,7 @@ public function handleUpdateProperties($path, PropPatch $propPatch) { $name = $tag->getName(); $userVisible = $tag->isUserVisible(); $userAssignable = $tag->isUserAssignable(); + $color = $tag->getColor(); $updateTag = false; @@ -440,6 +442,11 @@ public function handleUpdateProperties($path, PropPatch $propPatch) { $updateTag = true; } + if (isset($props[self::COLOR_PROPERTYNAME])) { + $color = $props[self::COLOR_PROPERTYNAME]; + $updateTag = true; + } + if (isset($props[self::GROUPS_PROPERTYNAME])) { if (!$this->groupManager->isAdmin($this->userSession->getUser()->getUID())) { // property only available for admins @@ -457,7 +464,7 @@ public function handleUpdateProperties($path, PropPatch $propPatch) { } if ($updateTag) { - $node->update($name, $userVisible, $userAssignable); + $node->update($name, $userVisible, $userAssignable, $color); } return true; diff --git a/apps/systemtags/src/components/SystemTagPicker.vue b/apps/systemtags/src/components/SystemTagPicker.vue index c2596f145d3a7..bd8c35782d77a 100644 --- a/apps/systemtags/src/components/SystemTagPicker.vue +++ b/apps/systemtags/src/components/SystemTagPicker.vue @@ -52,8 +52,9 @@ :value="`#${tag.color || primaryColor}`" class="systemtags-picker__tag-color" @update:value="onColorChange(tag, $event)"> - + @@ -136,18 +137,22 @@ import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' import CheckIcon from 'vue-material-design-icons/CheckCircle.vue' +import CircleIcon from 'vue-material-design-icons/Circle.vue' import PencilIcon from 'vue-material-design-icons/Pencil.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue' import TagIcon from 'vue-material-design-icons/Tag.vue' -import { createTag, fetchTag, fetchTags, getTagObjects, setTagObjects } from '../services/api' +import { createTag, fetchTag, fetchTags, getTagObjects, setTagObjects, updateTag, updateTagColor } from '../services/api' import { getNodeSystemTags, setNodeSystemTags } from '../utils' -import { invertTextColor } from '../utils/colorUtils' +import { elementColor, invertTextColor, isDarkModeEnabled } from '../utils/colorUtils' import logger from '../services/logger' -const primaryColor = getComputedStyle(document.documentElement) +const primaryColor = getComputedStyle(document.body) .getPropertyValue('--color-primary-element') .replace('#', '') || '0069c3' +const mainBackgroundColor = getComputedStyle(document.body) + .getPropertyValue('--color-main-background') + .replace('#', '') || (isDarkModeEnabled() ? '000000' : 'ffffff') type TagListCount = { string: number @@ -165,6 +170,7 @@ export default defineComponent({ components: { CheckIcon, + CircleIcon, NcButton, NcCheckboxRadioSwitch, // eslint-disable-next-line vue/no-unused-components @@ -383,6 +389,7 @@ export default defineComponent({ onColorChange(tag: TagWithId, color: string) { tag.color = color.replace('#', '') + updateTag(tag) }, isChecked(tag: TagWithId): boolean { @@ -522,17 +529,15 @@ export default defineComponent({ }, tagListStyle(tag: TagWithId): Record { + const primaryElement = elementColor(`#${tag.color || primaryColor}`, `#${mainBackgroundColor}`) + const textColor = invertTextColor(primaryElement) ? '#000000' : '#ffffff' return { - '--color-primary': `#${tag.color || primaryColor}`, - '--color-primary-text': this.primaryElementTextColor(tag.color || primaryColor), - '--color-primary-element': `#${tag.color || primaryColor}`, - '--color-primary-element-text': this.primaryElementTextColor(tag.color || primaryColor), + '--color-primary': primaryElement, + '--color-primary-text': textColor, + '--color-primary-element': primaryElement, + '--color-primary-element-text': textColor, } }, - - primaryElementTextColor(color: string): string { - return invertTextColor(color) ? '#000000' : '#ffffff' - }, }, }) @@ -580,23 +585,24 @@ export default defineComponent({ } } - .systemtags-picker__tag-color { + .systemtags-picker__tag-color button { margin-inline-start: calc(var(--default-grid-baseline) * 2); color: var(--color-primary-element); - width: var(--default-clickable-area); - height: var(--default-clickable-area); - display: flex; - button { - border-radius: 50%; - span { - display: none; - } + span.pencil-icon { + display: none; + color: var(--color-main-text); + } - &:focus span, - &:hover span { + &:focus, + &:hover, + &[aria-expanded='true'] { + .pencil-icon { display: block; } + .circle-icon { + display: none; + } } } diff --git a/apps/systemtags/src/services/api.ts b/apps/systemtags/src/services/api.ts index 4651c739b9488..b98bfcb47cff3 100644 --- a/apps/systemtags/src/services/api.ts +++ b/apps/systemtags/src/services/api.ts @@ -99,12 +99,13 @@ export const createTag = async (tag: Tag | ServerTag): Promise => { export const updateTag = async (tag: TagWithId): Promise => { const path = '/systemtags/' + tag.id const data = ` - + ${tag.displayName} ${tag.userVisible} ${tag.userAssignable} + ${tag.color} ` diff --git a/apps/systemtags/src/utils/colorUtils.ts b/apps/systemtags/src/utils/colorUtils.ts index dfe41217eff6b..bf97a6caa017b 100644 --- a/apps/systemtags/src/utils/colorUtils.ts +++ b/apps/systemtags/src/utils/colorUtils.ts @@ -1,41 +1,189 @@ +import Color from 'color' + +type hexColor = `#${string & ( + `${string}${string}${string}` | + `${string}${string}${string}${string}${string}${string}` +)}`; + /** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * Is the current theme dark? */ +export function isDarkModeEnabled() { + const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches + const darkModeSetting = document.body.getAttribute('data-themes')?.includes('dark') + return darkModeSetting || darkModePreference || false +} -type rgb = { - red: number - green: number - blue: number +/** + * Is the current theme high contrast? + */ +export function isHighContrastModeEnabled() { + const highContrastPreference = window.matchMedia('(forced-colors: active)').matches + const highContrastSetting = document.body.getAttribute('data-themes')?.includes('highcontrast') + return highContrastSetting || highContrastPreference || false } /** - * Convert hex color to RGB + * Should we invert the text on this background color? + * @param color RGB color value as a hex string + * @return boolean + */ +export function invertTextColor(color: hexColor): boolean { + return colorContrast(color, '#ffffff') < 4.5 +} + +/** + * Is this color too bright? + * @param color RGB color value as a hex string + * @return boolean */ -export function hexToRGB(hex: string): rgb { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - if (!result) { - throw new Error('Invalid color') +export function isBrightColor(color: hexColor): boolean { + return calculateLuma(color) > 0.6 +} + +/** + * Get color for on-page elements + * theme color by default, grey if theme color is too bright. + * @param color the color to contrast against, e.g. #ffffff + * @param backgroundColor the background color to contrast against, e.g. #000000 + */ +export function elementColor( + color: hexColor, + backgroundColor: hexColor, +): hexColor { + const brightBackground = isBrightColor(backgroundColor) + const blurredBackground = mix( + backgroundColor, + brightBackground ? color : '#ffffff', + 66, + ) + + let contrast = colorContrast(color, blurredBackground) + const minContrast = isHighContrastModeEnabled() ? 5.6 : 3.2 + + let iteration = 0 + let result = color + const epsilon = 1.0 / 255.0 + while (contrast < minContrast && iteration++ < 100) { + const hsl = hexToHSL(result) + const l = Math.max( + 0, + Math.min(255, hsl.l + (brightBackground ? -epsilon : epsilon)), + ) + result = hslToHex({ h: hsl.h, s: hsl.s, l }) + contrast = colorContrast(result, blurredBackground) } - return { - red: parseInt(result[1], 16), - green: parseInt(result[2], 16), - blue: parseInt(result[3], 16), + return result +} + +/** + * Get color for on-page text: + * black if background is bright, white if background is dark. + * @param color1 the color to contrast against, e.g. #ffffff + * @param color2 the background color to contrast against, e.g. #000000 + * @param factor the factor to mix the colors between -100 and 100, e.g. 66 + */ +export function mix(color1: hexColor, color2: hexColor, factor: number): hexColor { + if (factor < -100 || factor > 100) { + throw new RangeError('Factor must be between -100 and 100') } + return new Color(color2).mix(new Color(color1), (factor + 100) / 200).hex() } /** - * Calculate luminance of provided hex color + * Lighten a color by a factor + * @param color the color to lighten, e.g. #000000 + * @param factor the factor to lighten the color by between -100 and 100, e.g. -41 */ -export function calculateLuma(color: string): number { - const rgb = hexToRGB(color) - return (0.2126 * rgb.red + 0.7152 * rgb.green + 0.0722 * rgb.blue) / 255 +export function lighten(color: hexColor, factor: number): hexColor { + if (factor < -100 || factor > 100) { + throw new RangeError('Factor must be between -100 and 100') + } + return new Color(color).lighten((factor + 100) / 200).hex() } /** - * Do we need to invert the text if color is too bright? + * Darken a color by a factor + * @param color the color to darken, e.g. #ffffff + * @param factor the factor to darken the color by between -100 and 100, e.g. 32 */ -export function invertTextColor(color: string): boolean { - return calculateLuma(color) > 0.6 +export function darken(color: hexColor, factor: number): hexColor { + if (factor < -100 || factor > 100) { + throw new RangeError('Factor must be between -100 and 100') + } + return new Color(color).darken((factor + 100) / 200).hex() +} + +/** + * Calculate the luminance of a color + * @param color the color to calculate the luminance of, e.g. #ffffff + */ +export function calculateLuminance(color: hexColor): number { + return hexToHSL(color).l +} + +/** + * Calculate the luma of a color + * @param color the color to calculate the luma of, e.g. #ffffff + */ +export function calculateLuma(color: hexColor): number { + const rgb = hexToRGB(color).map((value) => { + value /= 255 + return value <= 0.03928 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4) + }) + const [red, green, blue] = rgb + return 0.2126 * red + 0.7152 * green + 0.0722 * blue +} + +/** + * Calculate the contrast between two colors + * @param color1 the first color to calculate the contrast of, e.g. #ffffff + * @param color2 the second color to calculate the contrast of, e.g. #000000 + */ +export function colorContrast(color1: hexColor, color2: hexColor): number { + const luminance1 = calculateLuma(color1) + 0.05 + const luminance2 = calculateLuma(color2) + 0.05 + return Math.max(luminance1, luminance2) / Math.min(luminance1, luminance2) +} + +/** + * Convert hex color to RGB + * @param color RGB color value as a hex string + */ +export function hexToRGB(color: hexColor): [number, number, number] { + return new Color(color).rgb().array() +} + +/** + * Convert RGB color to hex + * @param color RGB color value as a hex string + */ +export function hexToHSL(color: hexColor): { h: number; s: number; l: number } { + const hsl = new Color(color).hsl() + return { h: hsl.color[0], s: hsl.color[1], l: hsl.color[2] } +} + +/** + * Convert HSL color to hex + * @param hsl HSL color value as an object + * @param hsl.h hue + * @param hsl.s saturation + * @param hsl.l lightness + */ +export function hslToHex(hsl: { h: number; s: number; l: number }): hexColor { + return new Color(hsl).hex() +} + +/** + * Convert RGB color to hex + * @param r red + * @param g green + * @param b blue + */ +export function rgbToHex(r: number, g: number, b: number): hexColor { + const hex = ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1) + return `#${hex}` } diff --git a/lib/private/SystemTag/SystemTagManager.php b/lib/private/SystemTag/SystemTagManager.php index b72d23d2a1eb2..45315ea1ef34a 100644 --- a/lib/private/SystemTag/SystemTagManager.php +++ b/lib/private/SystemTag/SystemTagManager.php @@ -45,9 +45,6 @@ public function __construct( ->andWhere($query->expr()->eq('editable', $query->createParameter('editable'))); } - /** - * {@inheritdoc} - */ public function getTagsByIds($tagIds, ?IUser $user = null): array { if (!\is_array($tagIds)) { $tagIds = [$tagIds]; @@ -92,9 +89,6 @@ public function getTagsByIds($tagIds, ?IUser $user = null): array { return $tags; } - /** - * {@inheritdoc} - */ public function getAllTags($visibilityFilter = null, $nameSearchPattern = null): array { $tags = []; @@ -130,9 +124,6 @@ public function getAllTags($visibilityFilter = null, $nameSearchPattern = null): return $tags; } - /** - * {@inheritdoc} - */ public function getTag(string $tagName, bool $userVisible, bool $userAssignable): ISystemTag { // Length of name column is 64 $truncatedTagName = substr($tagName, 0, 64); @@ -153,9 +144,6 @@ public function getTag(string $tagName, bool $userVisible, bool $userAssignable) return $this->createSystemTagFromRow($row); } - /** - * {@inheritdoc} - */ public function createTag(string $tagName, bool $userVisible, bool $userAssignable): ISystemTag { // Length of name column is 64 $truncatedTagName = substr($tagName, 0, 64); @@ -194,14 +182,12 @@ public function createTag(string $tagName, bool $userVisible, bool $userAssignab return $tag; } - /** - * {@inheritdoc} - */ public function updateTag( string $tagId, string $newName, bool $userVisible, bool $userAssignable, + string $color = '' ): void { try { $tags = $this->getTagsByIds($tagId); @@ -218,7 +204,9 @@ public function updateTag( $tagId, $truncatedNewName, $userVisible, - $userAssignable + $userAssignable, + $beforeUpdate->getETag(), + $color ); $query = $this->connection->getQueryBuilder(); @@ -226,11 +214,13 @@ public function updateTag( ->set('name', $query->createParameter('name')) ->set('visibility', $query->createParameter('visibility')) ->set('editable', $query->createParameter('editable')) + ->set('color', $query->createParameter('color')) ->where($query->expr()->eq('id', $query->createParameter('tagid'))) ->setParameter('name', $truncatedNewName) ->setParameter('visibility', $userVisible ? 1 : 0) ->setParameter('editable', $userAssignable ? 1 : 0) - ->setParameter('tagid', $tagId); + ->setParameter('tagid', $tagId) + ->setParameter('color', $color); try { if ($query->execute() === 0) { @@ -251,9 +241,6 @@ public function updateTag( )); } - /** - * {@inheritdoc} - */ public function deleteTags($tagIds): void { if (!\is_array($tagIds)) { $tagIds = [$tagIds]; @@ -303,9 +290,6 @@ public function deleteTags($tagIds): void { } } - /** - * {@inheritdoc} - */ public function canUserAssignTag(ISystemTag $tag, ?IUser $user): bool { if ($user === null) { return false; @@ -335,9 +319,6 @@ public function canUserAssignTag(ISystemTag $tag, ?IUser $user): bool { return false; } - /** - * {@inheritdoc} - */ public function canUserSeeTag(ISystemTag $tag, ?IUser $user): bool { // If no user, then we only show public tags if (!$user && $tag->getAccessLevel() === ISystemTag::ACCESS_LEVEL_PUBLIC) { @@ -364,9 +345,6 @@ private function createSystemTagFromRow($row): SystemTag { return new SystemTag((string)$row['id'], $row['name'], (bool)$row['visibility'], (bool)$row['editable'], $row['etag'], $row['color']); } - /** - * {@inheritdoc} - */ public function setTagGroups(ISystemTag $tag, array $groupIds): void { // delete relationships first $this->connection->beginTransaction(); @@ -398,9 +376,6 @@ public function setTagGroups(ISystemTag $tag, array $groupIds): void { } } - /** - * {@inheritdoc} - */ public function getTagGroups(ISystemTag $tag): array { $groupIds = []; $query = $this->connection->getQueryBuilder(); @@ -418,4 +393,5 @@ public function getTagGroups(ISystemTag $tag): array { return $groupIds; } + } diff --git a/package-lock.json b/package-lock.json index 11f2e4d59383d..f366a3cb722c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "camelcase": "^8.0.0", "cancelable-promise": "^4.3.1", "clipboard": "^2.0.11", + "color": "^4.2.3", "core-js": "^3.38.1", "davclient.js": "github:owncloud/davclient.js.git#0.2.2", "debounce": "^2.1.0", @@ -9122,6 +9123,19 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -9134,8 +9148,35 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, "node_modules/colord": { "version": "2.9.3", @@ -22722,6 +22763,21 @@ "integrity": "sha512-+OmPgi01yHK/bRNQDoehUcV8fqs9nNJkG2DoWCnnLvj0lmowab7BH3v9776BG0y7dGEOLh0F7mfd37k+ht26Yw==", "license": "MIT" }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, "node_modules/sinon": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/sinon/-/sinon-5.0.7.tgz", diff --git a/package.json b/package.json index c77c22109f01a..a4b99fe08508a 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "camelcase": "^8.0.0", "cancelable-promise": "^4.3.1", "clipboard": "^2.0.11", + "color": "^4.2.3", "core-js": "^3.38.1", "davclient.js": "github:owncloud/davclient.js.git#0.2.2", "debounce": "^2.1.0",