TSK-1323: Fix colors for list (#3069)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-04-25 23:11:50 +07:00 committed by GitHub
parent 123eb6e3ad
commit ef987eef56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 275 additions and 112 deletions

View File

@ -82,6 +82,27 @@ export function hexColorToNumber (hexColor: string): number {
return parseInt(hexColor.replace('#', ''), 16)
}
/**
* @public
*/
export function hexToRgb (color: string): { r: number, g: number, b: number } {
if (!color.startsWith('#')) {
return { r: 128, g: 128, b: 128 }
}
color = color.replace('#', '')
if (color.length === 3) {
color = color
.split('')
.map((c) => c + c)
.join('')
}
return {
r: parseInt(color.slice(0, 2), 16),
g: parseInt(color.slice(2, 4), 16),
b: parseInt(color.slice(4, 6), 16)
}
}
/**
* @public
*/
@ -110,54 +131,106 @@ export function numberToRGB (color: number, alpha?: number): string {
/**
* @public
*/
export function hsvToRGB (h: number, s: number, v: number): { r: number, g: number, b: number, rgb: string } {
export function hslToRgb (h: number, s: number, l: number): { r: number, g: number, b: number } {
s /= 100
l /= 100
const k = (n: number): number => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = (n: number): number => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
return { r: 255 * f(0), g: 255 * f(8), b: 255 * f(4) }
}
/**
* @public
*/
export function rgbToHex (color: { r: number, g: number, b: number }): string {
function addZero (d: string): string {
if (d.length < 2) {
return '0' + d
}
return d
}
return (
'#' +
addZero((Math.round(color.r) % 255).toString(16)) +
addZero((Math.round(color.g) % 255).toString(16)) +
addZero((Math.round(color.b) % 255).toString(16))
)
}
export async function svgToColor (img: SVGSVGElement): Promise<{ r: number, g: number, b: number } | undefined> {
const outerHTML = img.outerHTML
const blob = new Blob([outerHTML], { type: 'image/svg+xml;charset=utf-8' })
const blobURL = URL.createObjectURL(blob)
const image = new Image()
return await new Promise((resolve) => {
image.setAttribute('crossOrigin', '')
image.src = blobURL
image.onload = () => {
resolve(imageToColor(image))
}
})
}
/**
* @public
*/
export function imageToColor (image: HTMLImageElement): { r: number, g: number, b: number } | undefined {
const canvas = document.createElement('canvas')
const height = (canvas.height = image.naturalHeight ?? image.offsetHeight ?? image.height)
const width = (canvas.width = image.naturalWidth ?? image.offsetWidth ?? image.width)
canvas.width = width
canvas.height = height
const blockSize = 5
let r: number = 0
let g: number = 0
let b: number = 0
const i = Math.floor(h * 6)
const f = h * 6 - i
const p = v * (1 - s)
const q = v * (1 - f * s)
const t = v * (1 - (1 - f) * s)
switch (i % 6) {
case 0:
r = v
g = t
b = p
break
case 1:
r = q
g = v
b = p
break
case 2:
r = p
g = v
b = t
break
case 3:
r = p
g = q
b = v
break
case 4:
r = t
g = p
b = v
break
case 5:
r = v
g = p
b = q
break
}
r = Math.round(r * 255)
g = Math.round(g * 255)
b = Math.round(b * 255)
return {
r,
g,
b,
rgb: '#' + r.toString(16) + g.toString(16) + b.toString(16)
let count = 0
const context = canvas.getContext('2d')
if (context != null) {
context.drawImage(image, 0, 0, width, height)
context.beginPath()
context.arc(0, 0, 60, 0, Math.PI * 2, true)
context.clip()
context.fillRect(0, 0, width, height)
const data = context?.getImageData(0, 0, width, height).data
const length = data.length
let i = 0
while (i < length) {
if (data[i] > 5 && data[i + 1] > 5 && data[i + 2] > 5 && data[i + 3] > 50) {
++count
r += data[i]
g += data[i + 1]
b += data[i + 2]
}
i += blockSize * 4
}
r = Math.round(r / count)
g = Math.round(g / count)
b = Math.round(b / count)
return { r, g, b }
}
}
export function rgbToHsl (r: number, g: number, b: number): { h: number, s: number, l: number } {
r /= 255
g /= 255
b /= 255
const l = Math.max(r, g, b)
const s = l - Math.min(r, g, b)
const h = s > 0 ? (l === r ? (g - b) / s : l === g ? 2 + (b - r) / s : 4 + (r - g) / s) : 0
return {
h: 60 * h < 0 ? 60 * h + 360 : 60 * h,
s: 100 * (s > 0 ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0),
l: (100 * (2 * l - s)) / 2
}
}

View File

@ -1,6 +1,6 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">

View File

@ -15,7 +15,7 @@
import { Timestamp } from '@hcengineering/core'
import type { Asset, IntlString } from '@hcengineering/platform'
import { /* Metadata, Plugin, plugin, */ Resource /*, Service */ } from '@hcengineering/platform'
import { /* getContext, */ SvelteComponent } from 'svelte'
import { /* getContext, */ ComponentType } from 'svelte'
/**
* Describe a browser URI location parsed to path, query and fragment.
@ -55,7 +55,7 @@ export function areLocationsEqual (loc1: Location, loc2: Location): boolean {
return keys1.findIndex((k) => loc1.query?.[k] !== loc2.query?.[k]) < 0
}
export type AnySvelteComponent = typeof SvelteComponent
export type AnySvelteComponent = ComponentType
export type Component<C extends AnySvelteComponent> = Resource<C>
export type AnyComponent = Resource<AnySvelteComponent>

View File

@ -145,7 +145,7 @@
>
{#if selected}
{#if hideIcon || selected}
<UserInfo value={selected} size={kind === 'link' ? 'x-small' : 'tiny'} {icon} />
<UserInfo value={selected} size={kind === 'link' ? 'x-small' : 'tiny'} {icon} on:accent-color />
{:else}
{getName(selected)}
{/if}

View File

@ -31,7 +31,8 @@
import { Client, Ref } from '@hcengineering/core'
import { Asset, getResource } from '@hcengineering/platform'
import { getBlobURL, getClient } from '@hcengineering/presentation'
import { AnySvelteComponent, Icon, IconSize } from '@hcengineering/ui'
import { AnySvelteComponent, Icon, IconSize, hexToRgb, imageToColor } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { getAvatarProviderId } from '../utils'
import AvatarIcon from './icons/Avatar.svelte'
@ -43,6 +44,8 @@
let url: string | undefined
let avatarProvider: AvatarProvider | undefined
const dispatch = createEventDispatcher()
async function update (size: IconSize, avatar?: string | null, direct?: Blob) {
if (direct !== undefined) {
getBlobURL(direct).then((blobURL) => {
@ -78,17 +81,44 @@
const color = (await getResource(avatarProvider.getUrl))(uri, size)
style = `background-color: ${color}`
accentColor = hexToRgb(color)
dispatch('accent-color', accentColor)
}
}
$: updateStyle(avatar, avatarProvider)
let imageElement: HTMLImageElement | undefined = undefined
let accentColor: any | undefined
</script>
<div class="ava-{size} flex-center avatar-container" class:no-img={!url} {style}>
{#if url}
{#if size === 'large' || size === 'x-large'}
<img class="ava-{size} ava-blur" src={url} alt={''} />
<img
class="ava-{size} ava-blur"
src={url}
alt={''}
bind:this={imageElement}
on:load={(data) => {
if (imageElement !== undefined) {
accentColor = imageToColor(imageElement)
dispatch('accent-color', accentColor)
}
}}
/>
{/if}
<img class="ava-{size} ava-mask" src={url} alt={''} />
<img
class="ava-{size} ava-mask"
src={url}
alt={''}
bind:this={imageElement}
on:load={(data) => {
if (imageElement !== undefined) {
accentColor = imageToColor(imageElement)
dispatch('accent-color', accentColor)
}
}}
/>
{:else}
<Icon icon={icon ?? AvatarIcon} {size} />
{/if}

View File

@ -38,6 +38,7 @@
showNavigate={false}
justify={'left'}
on:change={({ detail }) => onChange?.(detail)}
on:accent-color
/>
{:else}
<EmployeePresenter
@ -53,5 +54,6 @@
disableClick
{colorInherit}
{accent}
on:accent-color
/>
{/if}

View File

@ -38,4 +38,5 @@
{accent}
{defaultName}
statusLabel={value?.active === false && shouldShowName ? contact.string.Inactive : undefined}
on:accent-color
/>

View File

@ -17,11 +17,29 @@
{#if Array.isArray(value)}
<div class="inline-content">
{#each value as employee}
<EmployeeAttributePresenter value={employee} {kind} {tooltipLabels} {onChange} {inline} {colorInherit} {accent} />
<EmployeeAttributePresenter
value={employee}
{kind}
{tooltipLabels}
{onChange}
{inline}
{colorInherit}
{accent}
on:accent-color
/>
{/each}
</div>
{:else}
<EmployeeAttributePresenter {value} {kind} {tooltipLabels} {onChange} {inline} {colorInherit} {accent} />
<EmployeeAttributePresenter
{value}
{kind}
{tooltipLabels}
{onChange}
{inline}
{colorInherit}
{accent}
on:accent-color
/>
{/if}
<style lang="scss">

View File

@ -57,7 +57,7 @@
class:mr-2={shouldShowName && !enlargedText}
class:mr-3={shouldShowName && enlargedText}
>
<Avatar size={avatarSize} avatar={value.avatar} />
<Avatar size={avatarSize} avatar={value.avatar} on:accent-color />
</span>
{/if}
{#if shouldShowName}
@ -79,7 +79,7 @@
class:mr-2={shouldShowName && !enlargedText}
class:mr-3={shouldShowName && enlargedText}
>
<Avatar size={avatarSize} />
<Avatar size={avatarSize} on:accent-color />
</span>
{/if}
{#if shouldShowName && defaultName}

View File

@ -78,5 +78,6 @@
{colorInherit}
{accent}
bind:element
on:accent-color
/>
{/if}

View File

@ -27,7 +27,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex-row-center" on:click>
<Avatar avatar={value.avatar} {size} {icon} />
<Avatar avatar={value.avatar} {size} {icon} on:accent-color />
<div class="flex-col min-w-0 {size === 'tiny' || size === 'inline' ? 'ml-1' : 'ml-2'}">
{#if subtitle}<div class="content-dark-color text-sm">{subtitle}</div>{/if}
<div class="content-accent-color overflow-label text-left">{getName(value)}</div>

View File

@ -26,6 +26,6 @@
<div class="icon">
{#if status}
<IssueStatusIcon value={status} size="small" />
<IssueStatusIcon value={status} size="small" on:accent-color />
{/if}
</div>

View File

@ -1,18 +1,34 @@
<script lang="ts">
import { StatusCategory } from '@hcengineering/core'
import { IconSize } from '@hcengineering/ui'
import { IconSize, hexToRgb } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import tracker from '../../plugin'
export let size: IconSize
export let fill: string = 'currentColor'
const defaultFill = 'currentColor'
export let fill: string = defaultFill
export let category: StatusCategory
export let statusIcon: {
index: number | undefined
count: number | undefined
} = { index: 0, count: 0 }
let element: SVGSVGElement
const dispatch = createEventDispatcher()
const dispatchAccentColor = (fill: string) =>
dispatch('accent-color', fill !== defaultFill ? hexToRgb(fill) : 'var(--theme-halfcontent-color)')
$: dispatchAccentColor(fill)
onMount(() => {
dispatchAccentColor(fill)
})
</script>
<svg
bind:this={element}
class="svg-{size}"
{fill}
id={category._id}

View File

@ -14,11 +14,10 @@
-->
<script lang="ts">
import core, { StatusCategory, WithLookup } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { createQuery, getClient, statusStore } from '@hcengineering/presentation'
import { IssueStatus } from '@hcengineering/tracker'
import { getPlatformColor, IconSize } from '@hcengineering/ui'
import { IconSize, getPlatformColor } from '@hcengineering/ui'
import tracker from '../../plugin'
import { statusStore } from '@hcengineering/presentation'
import StatusIcon from '../icons/StatusIcon.svelte'
export let value: WithLookup<IssueStatus>
@ -78,5 +77,5 @@
</script>
{#if icon !== undefined && color !== undefined && category !== undefined}
<StatusIcon {category} {size} fill={color} {statusIcon} />
<StatusIcon on:accent-color {category} {size} fill={color} {statusIcon} />
{/if}

View File

@ -27,7 +27,7 @@
{#if value}
<div class="flex-presenter cursor-default" style:color={'inherit'}>
{#if !inline}
<IssueStatusIcon {value} {size} />
<IssueStatusIcon {value} {size} on:accent-color />
{/if}
<span
class="overflow-label"

View File

@ -31,5 +31,6 @@
{kind}
{colorInherit}
{accent}
on:accent-color
/>
{/if}

View File

@ -13,55 +13,65 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc, Ref, Space } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import ui, {
ActionIcon,
AnyComponent,
Button,
IconAdd,
IconCollapseArrow,
IconMoreH,
Label,
hsvToRGB,
IconCollapseArrow,
deviceOptionsStore as deviceInfo
deviceOptionsStore as deviceInfo,
eventToHTMLElement,
hslToRgb,
rgbToHsl,
showPopup
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { noCategory } from '../../viewOptions'
import view from '../../plugin'
import { Doc, Ref, Space } from '@hcengineering/core'
import { AttributeModel } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import view from '../../plugin'
import { noCategory } from '../../viewOptions'
export let groupByKey: string
export let category: any
export let headerComponent: AttributeModel | undefined
export let space: Ref<Space> | undefined
export let createItemDialog: AnyComponent | undefined
export let createItemLabel: IntlString | undefined
export let limited: number
export let items: Doc[]
export let extraHeaders: AnyComponent[] | undefined
export let flat = false
export let collapsed = false
export let lastCat = false
export let props: Record<string, any> = {}
export let level: number
export let createItemDialog: AnyComponent | undefined
export let createItemLabel: IntlString | undefined
export let extraHeaders: AnyComponent[] | undefined
export let props: Record<string, any> = {}
export let newObjectProps: (doc: Doc) => Record<string, any> | undefined
const dispatch = createEventDispatcher()
// const handleCreateItem = (event: MouseEvent) => {
// if (createItemDialog === undefined) return
// showPopup(createItemDialog, newObjectProps(items[0]), eventToHTMLElement(event))
// }
const hue = Math.random()
$: lth = $deviceInfo.theme === 'theme-light'
$: headerBGColor = lth ? hsvToRGB(hue, 0.15, 0.9) : hsvToRGB(hue, 0.15, 0.3)
$: headerTextColor = lth ? hsvToRGB(hue, 0.5, 0.5) : hsvToRGB(hue, 0.6, 0.95)
let accentColor = { h: 0, s: 0, l: 65 }
$: headerBGColor = !lth
? hslToRgb(accentColor.h, accentColor.s, accentColor.l / 1.5 + (mouseOver ? -20 : 0))
: hslToRgb(accentColor.h, accentColor.s, accentColor.l * 1.5 + (mouseOver ? -20 : 0))
const handleCreateItem = (event: MouseEvent) => {
if (createItemDialog === undefined) return
showPopup(createItemDialog, newObjectProps(items[0]), eventToHTMLElement(event))
}
let mouseOver = false
</script>
{#if headerComponent || groupByKey === noCategory}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
style:z-index={10 - level}
style:--list-header-color={headerBGColor.rgb}
style:--list-header-rgb-color={`${headerBGColor.r}, ${headerBGColor.g}, ${headerBGColor.b}`}
class="flex-between categoryHeader row"
class:gradient={!lth}
@ -69,19 +79,30 @@
class:collapsed
class:subLevel={level !== 0}
class:lastCat
on:focus={() => {
mouseOver = true
}}
on:mouseenter={() => {
mouseOver = true
}}
on:mouseover={() => {
mouseOver = true
}}
on:mouseleave={() => {
mouseOver = false
}}
on:click={() => dispatch('collapse')}
>
<div class="flex-row-center clear-mins">
{#if level === 0}
<div class="chevron"><IconCollapseArrow size={'small'} /></div>
{/if}
<!-- <FixedColumn key={`list_groupBy_${groupByKey}`} justify={'left'}> -->
{#if groupByKey === noCategory}
<span style:color={headerTextColor.rgb} class="text-base fs-bold overflow-label pointer-events-none">
<span class="text-base fs-bold overflow-label pointer-events-none">
<Label label={view.string.NoGrouping} />
</span>
{:else if headerComponent}
<span class="clear-mins" style:color={headerTextColor.rgb}>
<span class="clear-mins">
<svelte:component
this={headerComponent.presenter}
value={category}
@ -90,17 +111,12 @@
kind={'list-header'}
colorInherit={lth && level === 0}
accent={level === 0}
on:accent-color={(evt) => {
accentColor = rgbToHsl(evt.detail.r, evt.detail.g, evt.detail.b)
}}
/>
</span>
{/if}
<!-- </FixedColumn> -->
<!-- {#if extraHeaders}
{#each extraHeaders as extra}
<FixedColumn key={`list_groupBy_${groupByKey}_extra_${extra}`} justify={'left'}>
<Component is={extra} props={{ ...props, value: category, docs: items }} />
</FixedColumn>
{/each}
{/if} -->
{#if limited < items.length}
<div class="antiSection-header__counter flex-row-center mx-2">
<span class="caption-color">{limited}</span>
@ -119,14 +135,16 @@
<span class="antiSection-header__counter ml-2">{items.length}</span>
{/if}
</div>
<!-- {#if createItemDialog !== undefined && createItemLabel !== undefined}
<Button
icon={IconAdd}
kind={'transparent'}
showTooltip={{ label: createItemLabel }}
on:click={handleCreateItem}
/>
{/if} -->
{#if createItemDialog !== undefined && createItemLabel !== undefined}
<div class:on-hover={!mouseOver}>
<Button
icon={IconAdd}
kind={'transparent'}
showTooltip={{ label: createItemLabel }}
on:click={handleCreateItem}
/>
</div>
{/if}
</div>
{/if}
@ -135,12 +153,16 @@
position: relative;
position: sticky;
top: 0;
padding: 0 2.5rem 0 0.75rem;
padding: 0 0.75rem 0 0.75rem;
height: 2.75rem;
min-height: 2.75rem;
min-width: 0;
background: var(--theme-bg-color);
.on-hover {
visibility: hidden;
}
.chevron {
flex-shrink: 0;
min-width: 0;
@ -151,10 +173,14 @@
transition: transform 0.15s ease-in-out;
}
&:not(.gradient)::before {
background: var(--list-header-color);
background: rgba(var(--list-header-rgb-color), 0.15);
}
&.gradient::before {
background: linear-gradient(90deg, var(--list-header-color), rgba(var(--list-header-rgb-color), 0.3));
background: linear-gradient(
90deg,
rgba(var(--list-header-rgb-color), 0.15),
rgba(var(--list-header-rgb-color), 0.05)
);
}
&::before {
box-sizing: border-box;
@ -187,7 +213,7 @@
border-left: 1px solid var(--theme-list-border-color);
border-right: 1px solid var(--theme-list-border-color);
border-bottom: 1px solid var(--theme-list-subheader-divider);
// here shoul be top 3rem for sticky, but with ExpandCollapse it gives strange behavior
// here should be top 3rem for sticky, but with ExpandCollapse it gives strange behavior
&::before {
content: none;
@ -206,8 +232,4 @@
padding: 0 0.25rem 0 0.25rem;
}
}
// .row:not(:last-child) {
// border-bottom: 1px solid var(--accent-bg-color);
// }
</style>