[PORT] gitea##30237: Fix and rewrite contrast color calculation, fix project-related bugs
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. Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: Giteabot <teabot@gitea.io> --- Conflict resolution: Trivial. (cherry picked from commit 36887ed3921d03f1864360c95bd2ecf853bfbe72)
This commit is contained in:
parent
6f4827b3e0
commit
f6c0c39f1a
|
@ -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
|
||||||
|
|
|
@ -135,16 +135,9 @@ 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
|
|
||||||
// this doesn't account for saturation or transparency
|
|
||||||
if util.UseLightTextOnBackground(r, g, b) {
|
|
||||||
textColor = "#eee"
|
|
||||||
}
|
|
||||||
|
|
||||||
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
|
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
|
||||||
|
|
||||||
|
@ -168,7 +161,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)
|
||||||
|
@ -178,6 +171,7 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
|
||||||
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)
|
||||||
|
|
||||||
opacity := GetLabelOpacityByte(label.IsArchived())
|
opacity := GetLabelOpacityByte(label.IsArchived())
|
||||||
|
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 {
|
||||||
|
|
|
@ -2312,8 +2312,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 */
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,23 +69,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';
|
||||||
import {emojiHTML} from '../features/emoji.js';
|
import {emojiHTML} from '../features/emoji.js';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {htmlEscape} from 'escape-goat';
|
||||||
|
@ -61,20 +60,13 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
labels() {
|
labels() {
|
||||||
return this.issue.labels.map((label) => {
|
return this.issue.labels.map((label) => ({
|
||||||
let textColor;
|
name: htmlEscape(label.name).replaceAll(/:[-+\w]+:/g, (emoji) => {
|
||||||
const {r, g, b} = tinycolor(label.color).toRgb();
|
|
||||||
if (useLightTextOnBackground(r, g, b)) {
|
|
||||||
textColor = '#eeeeee';
|
|
||||||
} else {
|
|
||||||
textColor = '#111111';
|
|
||||||
}
|
|
||||||
label.name = htmlEscape(label.name);
|
|
||||||
label.name = label.name.replaceAll(/:[-+\w]+:/g, (emoji) => {
|
|
||||||
return emojiHTML(emoji.substring(1, emoji.length - 1));
|
return emojiHTML(emoji.substring(1, emoji.length - 1));
|
||||||
});
|
}),
|
||||||
return {name: label.name, color: `#${label.color}`, textColor};
|
color: `#${label.color}`,
|
||||||
});
|
textColor: contrastColor(`#${label.color}`),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -114,7 +106,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">
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/>
|
<div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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…
Reference in New Issue