From 6bf50f7dd8db497d6003f9a6eb17ecd0d55e8133 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 15 Nov 2024 11:07:37 +0100 Subject: [PATCH] feat(systemtags): add colors in bulk tagging action Signed-off-by: skjnldsv --- .../src/components/SystemTagPicker.vue | 156 ++++++++++++--- apps/systemtags/src/services/api.ts | 6 +- apps/systemtags/src/types.ts | 2 + apps/systemtags/src/utils/colorUtils.ts | 189 ++++++++++++++++++ package-lock.json | 60 +++++- package.json | 1 + 6 files changed, 382 insertions(+), 32 deletions(-) create mode 100644 apps/systemtags/src/utils/colorUtils.ts diff --git a/apps/systemtags/src/components/SystemTagPicker.vue b/apps/systemtags/src/components/SystemTagPicker.vue index 8ed26ce9cb3b0..bd8c35782d77a 100644 --- a/apps/systemtags/src/components/SystemTagPicker.vue +++ b/apps/systemtags/src/components/SystemTagPicker.vue @@ -31,34 +31,54 @@ -
- - {{ formatTagName(tag) }} - - - {{ input.trim() }}
- {{ t('systemtags', 'Create new tag') }} - -
-
+ :style="tagListStyle(tag)" + class="systemtags-picker__tag"> + + {{ formatTagName(tag) }} + + + + + + + + + + + +
  • + + {{ input.trim() }}
    + {{ t('systemtags', 'Create new tag') }} + +
    +
  • +
    @@ -110,19 +130,30 @@ import escapeHTML from 'escape-html' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcChip from '@nextcloud/vue/dist/Components/NcChip.js' +import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js' import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' 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 TagIcon from 'vue-material-design-icons/Tag.vue' 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, updateTag, updateTagColor } from '../services/api' import { getNodeSystemTags, setNodeSystemTags } from '../utils' -import { createTag, fetchTag, fetchTags, getTagObjects, setTagObjects } from '../services/api' +import { elementColor, invertTextColor, isDarkModeEnabled } from '../utils/colorUtils' import logger from '../services/logger' +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 } @@ -139,15 +170,18 @@ export default defineComponent({ components: { CheckIcon, + CircleIcon, NcButton, NcCheckboxRadioSwitch, // eslint-disable-next-line vue/no-unused-components NcChip, + NcColorPicker, NcDialog, NcEmptyContent, NcLoadingIcon, NcNoteCard, NcTextField, + PencilIcon, PlusIcon, TagIcon, }, @@ -162,6 +196,7 @@ export default defineComponent({ setup() { return { emit, + primaryColor, Status, t, } @@ -329,7 +364,14 @@ export default defineComponent({ // Format & sanitize a tag chip for v-html tag rendering formatTagChip(tag: TagWithId): string { const chip = this.$refs.chip as NcChip - const chipHtml = chip.$el.outerHTML + const chipCloneEl = chip.$el.cloneNode(true) as HTMLElement + if (tag.color) { + const style = this.tagListStyle(tag) + Object.entries(style).forEach(([key, value]) => { + chipCloneEl.style.setProperty(key, value) + }) + } + const chipHtml = chipCloneEl.outerHTML return chipHtml.replace('%s', escapeHTML(sanitize(tag.displayName))) }, @@ -345,6 +387,11 @@ export default defineComponent({ return tag.displayName }, + onColorChange(tag: TagWithId, color: string) { + tag.color = color.replace('#', '') + updateTag(tag) + }, + isChecked(tag: TagWithId): boolean { return tag.displayName in this.tagList && this.tagList[tag.displayName] === this.nodes.length @@ -480,6 +527,17 @@ export default defineComponent({ showInfo(t('systemtags', 'File tags modification canceled')) this.$emit('close', null) }, + + tagListStyle(tag: TagWithId): Record { + const primaryElement = elementColor(`#${tag.color || primaryColor}`, `#${mainBackgroundColor}`) + const textColor = invertTextColor(primaryElement) ? '#000000' : '#ffffff' + return { + '--color-primary': primaryElement, + '--color-primary-text': textColor, + '--color-primary-element': primaryElement, + '--color-primary-element-text': textColor, + } + }, }, }) @@ -506,6 +564,48 @@ export default defineComponent({ gap: var(--default-grid-baseline); display: flex; flex-direction: column; + + li { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + // Make switch full width + :deep(.checkbox-radio-switch) { + width: 100%; + + .checkbox-content { + // adjust width + max-width: none; + // recalculate padding + box-sizing: border-box; + min-height: calc(var(--default-grid-baseline) * 2 + var(--default-clickable-area)); + } + } + } + + .systemtags-picker__tag-color button { + margin-inline-start: calc(var(--default-grid-baseline) * 2); + color: var(--color-primary-element); + + span.pencil-icon { + display: none; + color: var(--color-main-text); + } + + &:focus, + &:hover, + &[aria-expanded='true'] { + .pencil-icon { + display: block; + } + .circle-icon { + display: none; + } + } + } + .systemtags-picker__tag-create { :deep(span) { text-align: start; diff --git a/apps/systemtags/src/services/api.ts b/apps/systemtags/src/services/api.ts index 3262ccd3a878f..b98bfcb47cff3 100644 --- a/apps/systemtags/src/services/api.ts +++ b/apps/systemtags/src/services/api.ts @@ -15,7 +15,7 @@ import { formatTag, parseIdFromLocation, parseTags } from '../utils' import { logger } from '../logger.js' export const fetchTagsPayload = ` - + @@ -23,6 +23,7 @@ export const fetchTagsPayload = ` + ` @@ -98,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/types.ts b/apps/systemtags/src/types.ts index 161e4d742475a..6e4f03227e05c 100644 --- a/apps/systemtags/src/types.ts +++ b/apps/systemtags/src/types.ts @@ -8,6 +8,8 @@ export interface BaseTag { userVisible: boolean userAssignable: boolean readonly canAssign: boolean // Computed server-side + etag?: string + color?: string } export type Tag = BaseTag & { diff --git a/apps/systemtags/src/utils/colorUtils.ts b/apps/systemtags/src/utils/colorUtils.ts new file mode 100644 index 0000000000000..bf97a6caa017b --- /dev/null +++ b/apps/systemtags/src/utils/colorUtils.ts @@ -0,0 +1,189 @@ +import Color from 'color' + +type hexColor = `#${string & ( + `${string}${string}${string}` | + `${string}${string}${string}${string}${string}${string}` +)}`; + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 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 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() +} + +/** + * 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 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() +} + +/** + * 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 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/package-lock.json b/package-lock.json index 4364f51d590e5..45818aa9f0842 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,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", @@ -8981,6 +8982,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", @@ -8993,8 +9007,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", @@ -22585,6 +22626,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 b5fdcad96ce94..d3d3ed89bfb49 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,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",