mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 00:54:43 +01:00 
			
		
		
		
	Fix and rewrite contrast color calculation, fix project-related bugs (#30237)
1. The previous color contrast calculation function was incorrect at least for the `#84b6eb` where it output low-contrast white instead of black. I've rewritten these functions now to accept hex colors and to match GitHub's calculation and to output pure white/black for maximum contrast. Before and after: <img width="94" alt="Screenshot 2024-04-02 at 01 53 46" src="https://github.com/go-gitea/gitea/assets/115237/00b39e15-a377-4458-95cf-ceec74b78228"><img width="90" alt="Screenshot 2024-04-02 at 01 51 30" src="https://github.com/go-gitea/gitea/assets/115237/1677067a-8d8f-47eb-82c0-76330deeb775"> 2. Fix project-related issues: - Expose the new `ContrastColor` function as template helper and use it for project cards, replacing the previous JS solution which eliminates a flash of wrong color on page load. - Fix a bug where if editing a project title, the counter would get lost. - Move `rgbToHex` function to color utils. @HesterG fyi --------- Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
		
							parent
							
								
									019857a701
								
							
						
					
					
						commit
						36887ed392
					
				| @ -53,13 +53,13 @@ func NewFuncMap() template.FuncMap { | |||||||
| 		"JsonUtils":   NewJsonUtils, | 		"JsonUtils":   NewJsonUtils, | ||||||
| 
 | 
 | ||||||
| 		// ----------------------------------------------------------------- | 		// ----------------------------------------------------------------- | ||||||
| 		// svg / avatar / icon | 		// svg / avatar / icon / color | ||||||
| 		"svg":           svg.RenderHTML, | 		"svg":           svg.RenderHTML, | ||||||
| 		"EntryIcon":     base.EntryIcon, | 		"EntryIcon":     base.EntryIcon, | ||||||
| 		"MigrationIcon": MigrationIcon, | 		"MigrationIcon": MigrationIcon, | ||||||
| 		"ActionIcon":    ActionIcon, | 		"ActionIcon":    ActionIcon, | ||||||
| 
 | 		"SortArrow":     SortArrow, | ||||||
| 		"SortArrow": SortArrow, | 		"ContrastColor": util.ContrastColor, | ||||||
| 
 | 
 | ||||||
| 		// ----------------------------------------------------------------- | 		// ----------------------------------------------------------------- | ||||||
| 		// time / number / format | 		// time / number / format | ||||||
|  | |||||||
| @ -123,16 +123,10 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) | |||||||
| func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { | func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { | ||||||
| 	var ( | 	var ( | ||||||
| 		archivedCSSClass string | 		archivedCSSClass string | ||||||
| 		textColor        = "#111" | 		textColor        = util.ContrastColor(label.Color) | ||||||
| 		labelScope       = label.ExclusiveScope() | 		labelScope       = label.ExclusiveScope() | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	r, g, b := util.HexToRBGColor(label.Color) |  | ||||||
| 	// Determine if label text should be light or dark to be readable on background color |  | ||||||
| 	if util.UseLightTextOnBackground(r, g, b) { |  | ||||||
| 		textColor = "#eee" |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) | 	description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) | ||||||
| 
 | 
 | ||||||
| 	if label.IsArchived() { | 	if label.IsArchived() { | ||||||
| @ -153,7 +147,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m | |||||||
| 
 | 
 | ||||||
| 	// Make scope and item background colors slightly darker and lighter respectively. | 	// Make scope and item background colors slightly darker and lighter respectively. | ||||||
| 	// More contrast needed with higher luminance, empirically tweaked. | 	// More contrast needed with higher luminance, empirically tweaked. | ||||||
| 	luminance := util.GetLuminance(r, g, b) | 	luminance := util.GetRelativeLuminance(label.Color) | ||||||
| 	contrast := 0.01 + luminance*0.03 | 	contrast := 0.01 + luminance*0.03 | ||||||
| 	// Ensure we add the same amount of contrast also near 0 and 1. | 	// Ensure we add the same amount of contrast also near 0 and 1. | ||||||
| 	darken := contrast + math.Max(luminance+contrast-1.0, 0.0) | 	darken := contrast + math.Max(luminance+contrast-1.0, 0.0) | ||||||
| @ -162,6 +156,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m | |||||||
| 	darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) | 	darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) | ||||||
| 	lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) | 	lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) | ||||||
| 
 | 
 | ||||||
|  | 	r, g, b := util.HexToRBGColor(label.Color) | ||||||
| 	scopeBytes := []byte{ | 	scopeBytes := []byte{ | ||||||
| 		uint8(math.Min(math.Round(r*darkenFactor), 255)), | 		uint8(math.Min(math.Round(r*darkenFactor), 255)), | ||||||
| 		uint8(math.Min(math.Round(g*darkenFactor), 255)), | 		uint8(math.Min(math.Round(g*darkenFactor), 255)), | ||||||
|  | |||||||
| @ -4,22 +4,10 @@ package util | |||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"math" |  | ||||||
| 	"strconv" | 	"strconv" | ||||||
| 	"strings" | 	"strings" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Check similar implementation in web_src/js/utils/color.js and keep synchronization |  | ||||||
| 
 |  | ||||||
| // Return R, G, B values defined in reletive luminance |  | ||||||
| func getLuminanceRGB(channel float64) float64 { |  | ||||||
| 	sRGB := channel / 255 |  | ||||||
| 	if sRGB <= 0.03928 { |  | ||||||
| 		return sRGB / 12.92 |  | ||||||
| 	} |  | ||||||
| 	return math.Pow((sRGB+0.055)/1.055, 2.4) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Get color as RGB values in 0..255 range from the hex color string (with or without #) | // Get color as RGB values in 0..255 range from the hex color string (with or without #) | ||||||
| func HexToRBGColor(colorString string) (float64, float64, float64) { | func HexToRBGColor(colorString string) (float64, float64, float64) { | ||||||
| 	hexString := colorString | 	hexString := colorString | ||||||
| @ -47,19 +35,23 @@ func HexToRBGColor(colorString string) (float64, float64, float64) { | |||||||
| 	return r, g, b | 	return r, g, b | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // return luminance given RGB channels | // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance | ||||||
| // Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance | // Keep this in sync with web_src/js/utils/color.js | ||||||
| func GetLuminance(r, g, b float64) float64 { | func GetRelativeLuminance(color string) float64 { | ||||||
| 	R := getLuminanceRGB(r) | 	r, g, b := HexToRBGColor(color) | ||||||
| 	G := getLuminanceRGB(g) | 	return (0.2126729*r + 0.7151522*g + 0.0721750*b) / 255 | ||||||
| 	B := getLuminanceRGB(b) |  | ||||||
| 	luminance := 0.2126*R + 0.7152*G + 0.0722*B |  | ||||||
| 	return luminance |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Reference from: https://firsching.ch/github_labels.html | func UseLightText(backgroundColor string) bool { | ||||||
| // In the future WCAG 3 APCA may be a better solution. | 	return GetRelativeLuminance(backgroundColor) < 0.453 | ||||||
| // Check if text should use light color based on RGB of background | } | ||||||
| func UseLightTextOnBackground(r, g, b float64) bool { | 
 | ||||||
| 	return GetLuminance(r, g, b) < 0.453 | // Given a background color, returns a black or white foreground color that the highest | ||||||
|  | // contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better. | ||||||
|  | // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42 | ||||||
|  | func ContrastColor(backgroundColor string) string { | ||||||
|  | 	if UseLightText(backgroundColor) { | ||||||
|  | 		return "#fff" | ||||||
|  | 	} | ||||||
|  | 	return "#000" | ||||||
| } | } | ||||||
|  | |||||||
| @ -33,33 +33,31 @@ func Test_HexToRBGColor(t *testing.T) { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func Test_UseLightTextOnBackground(t *testing.T) { | func Test_UseLightText(t *testing.T) { | ||||||
| 	cases := []struct { | 	cases := []struct { | ||||||
| 		r        float64 | 		color    string | ||||||
| 		g        float64 | 		expected string | ||||||
| 		b        float64 |  | ||||||
| 		expected bool |  | ||||||
| 	}{ | 	}{ | ||||||
| 		{215, 58, 74, true}, | 		{"#d73a4a", "#fff"}, | ||||||
| 		{0, 117, 202, true}, | 		{"#0075ca", "#fff"}, | ||||||
| 		{207, 211, 215, false}, | 		{"#cfd3d7", "#000"}, | ||||||
| 		{162, 238, 239, false}, | 		{"#a2eeef", "#000"}, | ||||||
| 		{112, 87, 255, true}, | 		{"#7057ff", "#fff"}, | ||||||
| 		{0, 134, 114, true}, | 		{"#008672", "#fff"}, | ||||||
| 		{228, 230, 105, false}, | 		{"#e4e669", "#000"}, | ||||||
| 		{216, 118, 227, true}, | 		{"#d876e3", "#000"}, | ||||||
| 		{255, 255, 255, false}, | 		{"#ffffff", "#000"}, | ||||||
| 		{43, 134, 133, true}, | 		{"#2b8684", "#fff"}, | ||||||
| 		{43, 135, 134, true}, | 		{"#2b8786", "#fff"}, | ||||||
| 		{44, 135, 134, true}, | 		{"#2c8786", "#000"}, | ||||||
| 		{59, 182, 179, true}, | 		{"#3bb6b3", "#000"}, | ||||||
| 		{124, 114, 104, true}, | 		{"#7c7268", "#fff"}, | ||||||
| 		{126, 113, 108, true}, | 		{"#7e716c", "#fff"}, | ||||||
| 		{129, 112, 109, true}, | 		{"#81706d", "#fff"}, | ||||||
| 		{128, 112, 112, true}, | 		{"#807070", "#fff"}, | ||||||
|  | 		{"#84b6eb", "#000"}, | ||||||
| 	} | 	} | ||||||
| 	for n, c := range cases { | 	for n, c := range cases { | ||||||
| 		result := UseLightTextOnBackground(c.r, c.g, c.b) | 		assert.Equal(t, c.expected, ContrastColor(c.color), "case %d: error should match", n) | ||||||
| 		assert.Equal(t, c.expected, result, "case %d: error should match", n) |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -66,13 +66,13 @@ | |||||||
| <div id="project-board"> | <div id="project-board"> | ||||||
| 	<div class="board {{if .CanWriteProjects}}sortable{{end}}"> | 	<div class="board {{if .CanWriteProjects}}sortable{{end}}"> | ||||||
| 		{{range .Columns}} | 		{{range .Columns}} | ||||||
| 			<div class="ui segment project-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}"> | 			<div class="ui segment project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}"> | ||||||
| 				<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}"> | 				<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}"> | ||||||
| 					<div class="ui large label project-column-title tw-py-1"> | 					<div class="ui large label project-column-title tw-py-1"> | ||||||
| 						<div class="ui small circular grey label project-column-issue-count"> | 						<div class="ui small circular grey label project-column-issue-count"> | ||||||
| 							{{.NumIssues ctx}} | 							{{.NumIssues ctx}} | ||||||
| 						</div> | 						</div> | ||||||
| 						{{.Title}} | 						<span class="project-column-title-label">{{.Title}}</span> | ||||||
| 					</div> | 					</div> | ||||||
| 					{{if $canWriteProject}} | 					{{if $canWriteProject}} | ||||||
| 						<div class="ui dropdown jump item"> | 						<div class="ui dropdown jump item"> | ||||||
| @ -153,9 +153,7 @@ | |||||||
| 						</div> | 						</div> | ||||||
| 					{{end}} | 					{{end}} | ||||||
| 				</div> | 				</div> | ||||||
| 
 | 				<div class="divider"{{if .Color}} style="color: {{ContrastColor .Color}} !important"{{end}}></div> | ||||||
| 				<div class="divider"></div> |  | ||||||
| 
 |  | ||||||
| 				<div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> | 				<div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}"> | ||||||
| 					{{range (index $.IssuesMap .ID)}} | 					{{range (index $.IssuesMap .ID)}} | ||||||
| 						<div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}"> | 						<div class="issue-card gt-word-break {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}"> | ||||||
|  | |||||||
| @ -22,34 +22,27 @@ | |||||||
|   cursor: default; |   cursor: default; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .project-column .issue-card { | ||||||
|  |   color: var(--color-text); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .project-column-header { | .project-column-header { | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: space-between; |   justify-content: space-between; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .project-column-header.dark-label { |  | ||||||
|   color: var(--color-project-board-dark-label) !important; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .project-column-header.dark-label .project-column-title { |  | ||||||
|   color: var(--color-project-board-dark-label) !important; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .project-column-header.light-label { |  | ||||||
|   color: var(--color-project-board-light-label) !important; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .project-column-header.light-label .project-column-title { |  | ||||||
|   color: var(--color-project-board-light-label) !important; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .project-column-title { | .project-column-title { | ||||||
|   background: none !important; |   background: none !important; | ||||||
|   line-height: 1.25 !important; |   line-height: 1.25 !important; | ||||||
|   cursor: inherit; |   cursor: inherit; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .project-column-title, | ||||||
|  | .project-column-issue-count { | ||||||
|  |   color: inherit !important; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .project-column > .cards { | .project-column > .cards { | ||||||
|   flex: 1; |   flex: 1; | ||||||
|   display: flex; |   display: flex; | ||||||
| @ -64,6 +57,8 @@ | |||||||
| 
 | 
 | ||||||
| .project-column > .divider { | .project-column > .divider { | ||||||
|   margin: 5px 0; |   margin: 5px 0; | ||||||
|  |   border-color: currentcolor; | ||||||
|  |   opacity: .5; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .project-column:first-child { | .project-column:first-child { | ||||||
|  | |||||||
| @ -2273,8 +2273,21 @@ | |||||||
|   height: 0.5em; |   height: 0.5em; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .labels-list { | ||||||
|  |   display: flex; | ||||||
|  |   flex-wrap: wrap; | ||||||
|  |   gap: 0.25em; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .labels-list a { | ||||||
|  |   display: flex; | ||||||
|  |   text-decoration: none; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .labels-list .label { | .labels-list .label { | ||||||
|   margin: 2px 0; |   padding: 0 6px; | ||||||
|  |   margin: 0 !important; | ||||||
|  |   min-height: 20px; | ||||||
|   display: inline-flex !important; |   display: inline-flex !important; | ||||||
|   line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */ |   line-height: 1.3; /* there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly */ | ||||||
| } | } | ||||||
|  | |||||||
| @ -34,23 +34,6 @@ | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #issue-list .flex-item-title .labels-list { |  | ||||||
|   display: flex; |  | ||||||
|   flex-wrap: wrap; |  | ||||||
|   gap: 0.25em; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #issue-list .flex-item-title .labels-list a { |  | ||||||
|   display: flex; |  | ||||||
|   text-decoration: none; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #issue-list .flex-item-title .labels-list .label { |  | ||||||
|   padding: 0 6px; |  | ||||||
|   margin: 0; |  | ||||||
|   min-height: 20px; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #issue-list .flex-item-body .branches { | #issue-list .flex-item-body .branches { | ||||||
|   display: inline-flex; |   display: inline-flex; | ||||||
| } | } | ||||||
|  | |||||||
| @ -215,8 +215,6 @@ | |||||||
|   --color-placeholder-text: var(--color-text-light-3); |   --color-placeholder-text: var(--color-text-light-3); | ||||||
|   --color-editor-line-highlight: var(--color-primary-light-5); |   --color-editor-line-highlight: var(--color-primary-light-5); | ||||||
|   --color-project-board-bg: var(--color-secondary-light-2); |   --color-project-board-bg: var(--color-secondary-light-2); | ||||||
|   --color-project-board-dark-label: #0e1011; |  | ||||||
|   --color-project-board-light-label: #dde0e2; |  | ||||||
|   --color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */ |   --color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */ | ||||||
|   --color-reaction-bg: #e8e8ff12; |   --color-reaction-bg: #e8e8ff12; | ||||||
|   --color-reaction-hover-bg: var(--color-primary-light-4); |   --color-reaction-hover-bg: var(--color-primary-light-4); | ||||||
|  | |||||||
| @ -215,8 +215,6 @@ | |||||||
|   --color-placeholder-text: var(--color-text-light-3); |   --color-placeholder-text: var(--color-text-light-3); | ||||||
|   --color-editor-line-highlight: var(--color-primary-light-6); |   --color-editor-line-highlight: var(--color-primary-light-6); | ||||||
|   --color-project-board-bg: var(--color-secondary-light-4); |   --color-project-board-bg: var(--color-secondary-light-4); | ||||||
|   --color-project-board-dark-label: #0e1114; |  | ||||||
|   --color-project-board-light-label: #eaeef2; |  | ||||||
|   --color-caret: var(--color-text-dark); |   --color-caret: var(--color-text-dark); | ||||||
|   --color-reaction-bg: #0000170a; |   --color-reaction-bg: #0000170a; | ||||||
|   --color-reaction-hover-bg: var(--color-primary-light-5); |   --color-reaction-hover-bg: var(--color-primary-light-5); | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| <script> | <script> | ||||||
| import {SvgIcon} from '../svg.js'; | import {SvgIcon} from '../svg.js'; | ||||||
| import {useLightTextOnBackground} from '../utils/color.js'; | import {contrastColor} from '../utils/color.js'; | ||||||
| import tinycolor from 'tinycolor2'; |  | ||||||
| import {GET} from '../modules/fetch.js'; | import {GET} from '../modules/fetch.js'; | ||||||
| 
 | 
 | ||||||
| const {appSubUrl, i18n} = window.config; | const {appSubUrl, i18n} = window.config; | ||||||
| @ -59,16 +58,11 @@ export default { | |||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     labels() { |     labels() { | ||||||
|       return this.issue.labels.map((label) => { |       return this.issue.labels.map((label) => ({ | ||||||
|         let textColor; |         name: label.name, | ||||||
|         const {r, g, b} = tinycolor(label.color).toRgb(); |         color: `#${label.color}`, | ||||||
|         if (useLightTextOnBackground(r, g, b)) { |         textColor: contrastColor(`#${label.color}`), | ||||||
|           textColor = '#eeeeee'; |       })); | ||||||
|         } else { |  | ||||||
|           textColor = '#111111'; |  | ||||||
|         } |  | ||||||
|         return {name: label.name, color: `#${label.color}`, textColor}; |  | ||||||
|       }); |  | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   mounted() { |   mounted() { | ||||||
| @ -108,7 +102,7 @@ export default { | |||||||
|       <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p> |       <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p> | ||||||
|       <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p> |       <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p> | ||||||
|       <p>{{ body }}</p> |       <p>{{ body }}</p> | ||||||
|       <div> |       <div class="labels-list"> | ||||||
|         <div |         <div | ||||||
|           v-for="label in labels" |           v-for="label in labels" | ||||||
|           :key="label.name" |           :key="label.name" | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| import $ from 'jquery'; | import $ from 'jquery'; | ||||||
| import {useLightTextOnBackground} from '../utils/color.js'; | import {contrastColor} from '../utils/color.js'; | ||||||
| import tinycolor from 'tinycolor2'; |  | ||||||
| import {createSortable} from '../modules/sortable.js'; | import {createSortable} from '../modules/sortable.js'; | ||||||
| import {POST, DELETE, PUT} from '../modules/fetch.js'; | import {POST, DELETE, PUT} from '../modules/fetch.js'; | ||||||
|  | import tinycolor from 'tinycolor2'; | ||||||
| 
 | 
 | ||||||
| function updateIssueCount(cards) { | function updateIssueCount(cards) { | ||||||
|   const parent = cards.parentElement; |   const parent = cards.parentElement; | ||||||
| @ -65,14 +65,11 @@ async function initRepoProjectSortable() { | |||||||
|       boardColumns = mainBoard.getElementsByClassName('project-column'); |       boardColumns = mainBoard.getElementsByClassName('project-column'); | ||||||
|       for (let i = 0; i < boardColumns.length; i++) { |       for (let i = 0; i < boardColumns.length; i++) { | ||||||
|         const column = boardColumns[i]; |         const column = boardColumns[i]; | ||||||
|         if (parseInt($(column).data('sorting')) !== i) { |         if (parseInt(column.getAttribute('data-sorting')) !== i) { | ||||||
|           try { |           try { | ||||||
|             await PUT($(column).data('url'), { |             const bgColor = column.style.backgroundColor; // will be rgb() string
 | ||||||
|               data: { |             const color = bgColor ? tinycolor(bgColor).toHexString() : ''; | ||||||
|                 sorting: i, |             await PUT(column.getAttribute('data-url'), {data: {sorting: i, color}}); | ||||||
|                 color: rgbToHex(window.getComputedStyle($(column)[0]).backgroundColor), |  | ||||||
|               }, |  | ||||||
|             }); |  | ||||||
|           } catch (error) { |           } catch (error) { | ||||||
|             console.error(error); |             console.error(error); | ||||||
|           } |           } | ||||||
| @ -102,16 +99,10 @@ export function initRepoProject() { | |||||||
| 
 | 
 | ||||||
|   for (const modal of document.getElementsByClassName('edit-project-column-modal')) { |   for (const modal of document.getElementsByClassName('edit-project-column-modal')) { | ||||||
|     const projectHeader = modal.closest('.project-column-header'); |     const projectHeader = modal.closest('.project-column-header'); | ||||||
|     const projectTitleLabel = projectHeader?.querySelector('.project-column-title'); |     const projectTitleLabel = projectHeader?.querySelector('.project-column-title-label'); | ||||||
|     const projectTitleInput = modal.querySelector('.project-column-title-input'); |     const projectTitleInput = modal.querySelector('.project-column-title-input'); | ||||||
|     const projectColorInput = modal.querySelector('#new_project_column_color'); |     const projectColorInput = modal.querySelector('#new_project_column_color'); | ||||||
|     const boardColumn = modal.closest('.project-column'); |     const boardColumn = modal.closest('.project-column'); | ||||||
|     const bgColor = boardColumn?.style.backgroundColor; |  | ||||||
| 
 |  | ||||||
|     if (bgColor) { |  | ||||||
|       setLabelColor(projectHeader, rgbToHex(bgColor)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) { |     modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) { | ||||||
|       e.preventDefault(); |       e.preventDefault(); | ||||||
|       try { |       try { | ||||||
| @ -126,10 +117,21 @@ export function initRepoProject() { | |||||||
|       } finally { |       } finally { | ||||||
|         projectTitleLabel.textContent = projectTitleInput?.value; |         projectTitleLabel.textContent = projectTitleInput?.value; | ||||||
|         projectTitleInput.closest('form')?.classList.remove('dirty'); |         projectTitleInput.closest('form')?.classList.remove('dirty'); | ||||||
|         if (projectColorInput?.value) { |         const dividers = boardColumn.querySelectorAll(':scope > .divider'); | ||||||
|           setLabelColor(projectHeader, projectColorInput.value); |         if (projectColorInput.value) { | ||||||
|  |           const color = contrastColor(projectColorInput.value); | ||||||
|  |           boardColumn.style.setProperty('background', projectColorInput.value, 'important'); | ||||||
|  |           boardColumn.style.setProperty('color', color, 'important'); | ||||||
|  |           for (const divider of dividers) { | ||||||
|  |             divider.style.setProperty('color', color); | ||||||
|  |           } | ||||||
|  |         } else { | ||||||
|  |           boardColumn.style.removeProperty('background'); | ||||||
|  |           boardColumn.style.removeProperty('color'); | ||||||
|  |           for (const divider of dividers) { | ||||||
|  |             divider.style.removeProperty('color'); | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|         boardColumn.style = `background: ${projectColorInput.value} !important`; |  | ||||||
|         $('.ui.modal').modal('hide'); |         $('.ui.modal').modal('hide'); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
| @ -182,24 +184,3 @@ export function initRepoProject() { | |||||||
|     createNewColumn(url, $columnTitle, $projectColorInput); |     createNewColumn(url, $columnTitle, $projectColorInput); | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 |  | ||||||
| function setLabelColor(label, color) { |  | ||||||
|   const {r, g, b} = tinycolor(color).toRgb(); |  | ||||||
|   if (useLightTextOnBackground(r, g, b)) { |  | ||||||
|     label.classList.remove('dark-label'); |  | ||||||
|     label.classList.add('light-label'); |  | ||||||
|   } else { |  | ||||||
|     label.classList.remove('light-label'); |  | ||||||
|     label.classList.add('dark-label'); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function rgbToHex(rgb) { |  | ||||||
|   rgb = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+).*\)$/); |  | ||||||
|   return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function hex(x) { |  | ||||||
|   const hexDigits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; |  | ||||||
|   return Number.isNaN(x) ? '00' : hexDigits[(x - x % 16) / 16] + hexDigits[x % 16]; |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -1,23 +1,21 @@ | |||||||
| // Check similar implementation in modules/util/color.go and keep synchronization
 | import tinycolor from 'tinycolor2'; | ||||||
| // Return R, G, B values defined in reletive luminance
 | 
 | ||||||
| function getLuminanceRGB(channel) { | // Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
 | ||||||
|   const sRGB = channel / 255; | // Keep this in sync with modules/util/color.go
 | ||||||
|   return (sRGB <= 0.03928) ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4; | function getRelativeLuminance(color) { | ||||||
|  |   const {r, g, b} = tinycolor(color).toRgb(); | ||||||
|  |   return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Reference from: https://www.w3.org/WAI/GL/wiki/Relative_luminance
 | function useLightText(backgroundColor) { | ||||||
| function getLuminance(r, g, b) { |   return getRelativeLuminance(backgroundColor) < 0.453; | ||||||
|   const R = getLuminanceRGB(r); |  | ||||||
|   const G = getLuminanceRGB(g); |  | ||||||
|   const B = getLuminanceRGB(b); |  | ||||||
|   return 0.2126 * R + 0.7152 * G + 0.0722 * B; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Reference from: https://firsching.ch/github_labels.html
 | // Given a background color, returns a black or white foreground color that the highest
 | ||||||
| // In the future WCAG 3 APCA may be a better solution.
 | // contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
 | ||||||
| // Check if text should use light color based on RGB of background
 | // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
 | ||||||
| export function useLightTextOnBackground(r, g, b) { | export function contrastColor(backgroundColor) { | ||||||
|   return getLuminance(r, g, b) < 0.453; |   return useLightText(backgroundColor) ? '#fff' : '#000'; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function resolveColors(obj) { | function resolveColors(obj) { | ||||||
|  | |||||||
| @ -1,21 +1,22 @@ | |||||||
| import {useLightTextOnBackground} from './color.js'; | import {contrastColor} from './color.js'; | ||||||
| 
 | 
 | ||||||
| test('useLightTextOnBackground', () => { | test('contrastColor', () => { | ||||||
|   expect(useLightTextOnBackground(215, 58, 74)).toBe(true); |   expect(contrastColor('#d73a4a')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(0, 117, 202)).toBe(true); |   expect(contrastColor('#0075ca')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(207, 211, 215)).toBe(false); |   expect(contrastColor('#cfd3d7')).toBe('#000'); | ||||||
|   expect(useLightTextOnBackground(162, 238, 239)).toBe(false); |   expect(contrastColor('#a2eeef')).toBe('#000'); | ||||||
|   expect(useLightTextOnBackground(112, 87, 255)).toBe(true); |   expect(contrastColor('#7057ff')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(0, 134, 114)).toBe(true); |   expect(contrastColor('#008672')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(228, 230, 105)).toBe(false); |   expect(contrastColor('#e4e669')).toBe('#000'); | ||||||
|   expect(useLightTextOnBackground(216, 118, 227)).toBe(true); |   expect(contrastColor('#d876e3')).toBe('#000'); | ||||||
|   expect(useLightTextOnBackground(255, 255, 255)).toBe(false); |   expect(contrastColor('#ffffff')).toBe('#000'); | ||||||
|   expect(useLightTextOnBackground(43, 134, 133)).toBe(true); |   expect(contrastColor('#2b8684')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(43, 135, 134)).toBe(true); |   expect(contrastColor('#2b8786')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(44, 135, 134)).toBe(true); |   expect(contrastColor('#2c8786')).toBe('#000'); | ||||||
|   expect(useLightTextOnBackground(59, 182, 179)).toBe(true); |   expect(contrastColor('#3bb6b3')).toBe('#000'); | ||||||
|   expect(useLightTextOnBackground(124, 114, 104)).toBe(true); |   expect(contrastColor('#7c7268')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(126, 113, 108)).toBe(true); |   expect(contrastColor('#7e716c')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(129, 112, 109)).toBe(true); |   expect(contrastColor('#81706d')).toBe('#fff'); | ||||||
|   expect(useLightTextOnBackground(128, 112, 112)).toBe(true); |   expect(contrastColor('#807070')).toBe('#fff'); | ||||||
|  |   expect(contrastColor('#84b6eb')).toBe('#000'); | ||||||
| }); | }); | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user