diff --git a/apps/client/src/common/utils/__tests__/styleUtils.test.ts b/apps/client/src/common/utils/__tests__/styleUtils.test.ts index 0fe9c05f6..f5d753759 100644 --- a/apps/client/src/common/utils/__tests__/styleUtils.test.ts +++ b/apps/client/src/common/utils/__tests__/styleUtils.test.ts @@ -1,4 +1,4 @@ -import { cx } from '../styleUtils'; +import { cx, getAccessibleColour } from '../styleUtils'; import style from './styleUtils.module.scss'; @@ -13,3 +13,24 @@ describe('cx()', () => { expect(merged).toMatchSnapshot(); }); }); + +describe('getAccessibleColour()', () => { + it('handles named colours', () => { + const colour = 'red'; + const { backgroundColor, color } = getAccessibleColour(colour); + expect(backgroundColor).toBe('#FF0000FF'); + expect(color).toBe('#fffffa'); + }); + it('handles hex colours', () => { + const colour = '#0F0'; + const { backgroundColor, color } = getAccessibleColour(colour); + expect(backgroundColor).toBe('#00FF00FF'); + expect(color).toBe('black'); + }); + it('handles transparens', () => { + const colour = '#0F08'; + const { backgroundColor, color } = getAccessibleColour(colour); + expect(backgroundColor).toBe('#0C940CFF'); + expect(color).toBe('#fffffa'); + }); +}); diff --git a/apps/client/src/common/utils/styleUtils.ts b/apps/client/src/common/utils/styleUtils.ts index 5453221f7..a7bbbd7a2 100644 --- a/apps/client/src/common/utils/styleUtils.ts +++ b/apps/client/src/common/utils/styleUtils.ts @@ -13,13 +13,15 @@ type ColourCombination = { export const getAccessibleColour = (bgColour?: string): ColourCombination => { if (bgColour) { try { - const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa'; - return { backgroundColor: bgColour, color: textColor }; + const originalColour = Color(bgColour); + const backgroundColorMix = originalColour.alpha(1).mix(Color('#1a1a1a'), 1 - originalColour.alpha()); + const textColor = backgroundColorMix.isLight() ? 'black' : '#fffffa'; + return { backgroundColor: backgroundColorMix.hexa(), color: textColor }; } catch (_error) { /* we do not handle errors here */ } } - return { backgroundColor: '#000', color: '#fffffa' }; + return { backgroundColor: '#1a1a1a', color: '#fffffa' }; }; /** diff --git a/apps/client/src/features/cuesheet/Cuesheet.tsx b/apps/client/src/features/cuesheet/Cuesheet.tsx index e9e00ed6a..bd9ed3358 100644 --- a/apps/client/src/features/cuesheet/Cuesheet.tsx +++ b/apps/client/src/features/cuesheet/Cuesheet.tsx @@ -5,6 +5,7 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundo import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useLocalStorage } from '../../common/hooks/useLocalStorage'; +import { getAccessibleColour } from '../../common/utils/styleUtils'; import BlockRow from './cuesheet-table-elements/BlockRow'; import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader'; @@ -120,8 +121,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu } else if (row.original.colour) { try { // the colour is user defined and might be invalid - const colour = new Color(row.original.colour).alpha(0.25); - rowBgColour = colour.hsl().string(); + const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor); + rowBgColour = accessibleBackgroundColor.fade(0.75).hexa(); } catch (_error) { /* we do not handle errors here */ } diff --git a/apps/client/src/features/cuesheet/cuesheet-table-elements/EventRow.tsx b/apps/client/src/features/cuesheet/cuesheet-table-elements/EventRow.tsx index 3e575f42b..61bdba737 100644 --- a/apps/client/src/features/cuesheet/cuesheet-table-elements/EventRow.tsx +++ b/apps/client/src/features/cuesheet/cuesheet-table-elements/EventRow.tsx @@ -19,8 +19,8 @@ function EventRow(props: PropsWithChildren) { const ownRef = useRef(null); const [isVisible, setIsVisible] = useState(false); - const bgColour = colour; - const textColour = getAccessibleColour(bgColour); + const textColour = getAccessibleColour(colour); + const bgColour = textColour.backgroundColor; useLayoutEffect(() => { const observer = new IntersectionObserver( @@ -55,7 +55,7 @@ function EventRow(props: PropsWithChildren) { style={{ opacity: `${isPast ? pastOpacity : '1'}` }} ref={selectedRef ?? ownRef} > - + {eventIndex} {isVisible ? children : null} diff --git a/apps/server/src/controllers/integrationController.config.ts b/apps/server/src/controllers/integrationController.config.ts index 27b93744c..6f43ac5f4 100644 --- a/apps/server/src/controllers/integrationController.config.ts +++ b/apps/server/src/controllers/integrationController.config.ts @@ -1,8 +1,9 @@ import { LogOrigin, OntimeEvent } from 'ontime-types'; import { EventLoader } from '../classes/event-loader/EventLoader.js'; import { editEvent } from '../services/rundown-service/RundownService.js'; -import { coerceString, coerceNumber, coerceBoolean } from '../utils/coerceType.js'; +import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js'; import { logger } from '../classes/Logger.js'; +import { isKeyOfType, isOntimeEvent } from 'ontime-types/src/utils/guards.js'; const whitelistedPayload = { title: coerceString, @@ -16,7 +17,8 @@ const whitelistedPayload = { isPublic: coerceBoolean, skip: coerceBoolean, - colour: coerceString, + colour: coerceColour, + user0: coerceString, user1: coerceString, user2: coerceString, @@ -29,12 +31,12 @@ const whitelistedPayload = { user9: coerceString, }; -export function parse(field: string, value: unknown) { - if (!Object.hasOwn(whitelistedPayload, field)) { - throw new Error(`Field ${field} not permitted`); +export function parse(property: string, value: unknown) { + if (!isKeyOfType(property, whitelistedPayload)) { + throw new Error(`Property ${property} not permitted`); } - const parserFn = whitelistedPayload[field]; - return parserFn(value); + const parserFn = whitelistedPayload[property]; + return { parsedProperty: property, parsedPayload: parserFn(value) }; } /** @@ -49,8 +51,10 @@ export function updateEvent( newValue: OntimeEvent[typeof propertyName], ) { const event = EventLoader.getEventWithId(eventId); - if (event) { + if (!isOntimeEvent(event)) { + throw new Error(`Can only update events`); + } const propertiesToUpdate = { [propertyName]: newValue }; // Handles the special case for duration diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index 243d60b65..e95acab7b 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -2,8 +2,6 @@ import { messageService } from '../services/message-service/MessageService.js'; import { PlaybackService } from '../services/PlaybackService.js'; import { eventStore } from '../stores/EventStore.js'; import { parse, updateEvent } from './integrationController.config.js'; -import { isKeyOfType } from 'ontime-types/src/utils/guards.js'; -import { event } from '../models/eventsDefinition.js'; export type ChangeOptions = { eventId: string; @@ -272,11 +270,8 @@ export function dispatchFromAdapter( // WS: {type: 'change', payload: { eventId, property, value } } case 'change': { const { eventId, property, value } = payload as ChangeOptions; - if (!isKeyOfType(property, event)) { - throw new Error(`Cannot update unknown event property ${property}`); - } - const parsedPayload = parse(property, value); - return updateEvent(eventId, property, parsedPayload); + const { parsedPayload, parsedProperty } = parse(property, value); + return updateEvent(eventId, parsedProperty, parsedPayload); } default: { diff --git a/apps/server/src/utils/__tests__/coerceType.test.ts b/apps/server/src/utils/__tests__/coerceType.test.ts new file mode 100644 index 000000000..eeaa43b37 --- /dev/null +++ b/apps/server/src/utils/__tests__/coerceType.test.ts @@ -0,0 +1,21 @@ +import { coerceColour } from '../coerceType.js'; + +describe('parses a colour string that is', () => { + it('valid hex', () => { + const color = coerceColour('#000'); + expect(color).toBe('#000'); + }); + it('valid name', () => { + const color = coerceColour('darkgoldenrod'); + expect(color).toBe('darkgoldenrod'); + }); + it('invalid hex', () => { + expect(() => coerceColour('#not a hex color')).toThrowError(Error('Invalid hex colour received')); + }); + it('invalid name', () => { + expect(() => coerceColour('bad name')).toThrowError(Error('Invalid colour name received')); + }); + it('not a string', () => { + expect(() => coerceColour(5)).toThrowError(Error('Invalid colour value received')); + }); +}); diff --git a/apps/server/src/utils/coerceType.ts b/apps/server/src/utils/coerceType.ts index ff0dd8563..8360f3db2 100644 --- a/apps/server/src/utils/coerceType.ts +++ b/apps/server/src/utils/coerceType.ts @@ -1,5 +1,8 @@ +import { isColourHex } from 'ontime-utils'; + +//TODO: write tests /** - * @description Converts a value to a number if possible, throws otherwise + * @description Converts a value to a string if possible, throws otherwise * @param {unknown} value - Value to be converted to a string. * @returns {string} - The converted value as a string. * @throws {Error} Throws an error if the value is null or undefined. @@ -11,8 +14,9 @@ export function coerceString(value: unknown): string { return String(value); } +//TODO: write tests /** - * @description Converts a value to a number if possible, throws otherwise + * @description Converts a value to a boolean if possible, throws otherwise * @param {unknown} value - Value to be converted to a boolean. * @returns {boolean} - The converted value as a boolean. * @throws {Error} Throws an error if the value is null or undefined. @@ -21,9 +25,26 @@ export function coerceBoolean(value: unknown): boolean { if (value == null) { throw new Error('Invalid value received'); } + if (typeof value === 'string') { + const lowerCaseValue = value.toLocaleLowerCase(); + switch (lowerCaseValue) { + case 'true': + case '1': + case 'yes': + return true; + case 'false': + case '0': + case 'no': + case '': + return false; + default: + throw new Error('Invalid value received'); + } + } return Boolean(value); } +//TODO: write tests /** * @description Converts a value to a number if possible, throws otherwise * @param {unknown} value - Value to be converted to a number. @@ -40,3 +61,176 @@ export function coerceNumber(value: unknown): number { } return parsedValue; } + +/** + * @description Converts a value to a colour if possible, throws otherwise + * @param {unknown} value - Value to be converted to a colour. + * @returns {string} - The converted value as a string. + * @throws {Error} Throws an error if the value is null or undefined. + */ +export function coerceColour(value: unknown): string { + if (typeof value !== 'string') { + throw new Error('Invalid colour value received'); + } + const lowerCaseValue = value.toLocaleLowerCase(); + if (lowerCaseValue.startsWith('#')) { + if (!isColourHex(lowerCaseValue)) { + throw new Error('Invalid hex colour received'); + } + } else if (!(lowerCaseValue in cssColours)) { + throw new Error('Invalid colour name received'); + } + return lowerCaseValue; +} + +//https://developer.mozilla.org/en-US/docs/Web/CSS/named-color +const cssColours = { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgreen: '#006400', + darkgrey: '#a9a9a9', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dimgrey: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + goldenrod: '#daa520', + gold: '#ffd700', + gray: '#808080', + green: '#008000', + greenyellow: '#adff2f', + grey: '#808080', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + indianred: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavenderblush: '#fff0f5', + lavender: '#e6e6fa', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', + lightgreen: '#90ee90', + lightgrey: '#d3d3d3', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightslategrey: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370db', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#db7093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + rebeccapurple: '#663399', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + slategrey: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd3', +} as const; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index ac0060d56..aeb363dc7 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -15,6 +15,7 @@ export { formatDisplay } from './src/date-utils/formatDisplay.js'; export { formatFromMillis } from './src/date-utils/formatFromMillis.js'; export { isTimeString } from './src/date-utils/isTimeString.js'; export { millisToString } from './src/date-utils/millisToString.js'; +export { isColourHex } from './src/regex-utils/isColourHex.js'; // time utils export { dayInMs, mts } from './src/timeConstants.js'; diff --git a/packages/utils/package.json b/packages/utils/package.json index 96d41f8ce..7ead3ab0f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,5 +28,6 @@ "prettier": "^3.0.3", "typescript": "^5.2.2", "vitest": "^0.30.1" - } + }, + "sideEffects": false } diff --git a/packages/utils/src/regex-utils/isColourHex.test.ts b/packages/utils/src/regex-utils/isColourHex.test.ts new file mode 100644 index 000000000..eeb525e14 --- /dev/null +++ b/packages/utils/src/regex-utils/isColourHex.test.ts @@ -0,0 +1,45 @@ +import { isColourHex } from './isColourHex'; + +describe('test isColourHex() function', () => { + it('it validates colour hex strings', () => { + const ts = ['#FFF', '#FFFF', '#FFFFFF', '#FFFFFFFF']; + for (const s of ts) { + expect(isColourHex(s)).toBe(true); + } + }); + + it('it validates colour hex strings', () => { + const ts = ['#F90', '#1234', '#56789A', '#BCDEF012']; + for (const s of ts) { + expect(isColourHex(s)).toBe(true); + } + }); + + it('it validates colour hex strings', () => { + const ts = ['#f90', '#1234', '#56789a', '#bcdef012']; + for (const s of ts) { + expect(isColourHex(s)).toBe(true); + } + }); + + it('it fails digits bigger than F', () => { + const ts = ['#FFG']; + for (const s of ts) { + expect(isColourHex(s)).toBe(false); + } + }); + + it('it fails incorect amout of digits', () => { + const ts = ['#F', '#FF', '#FFFFF', '#FFFFFFF', '#FFFFFFFFF']; + for (const s of ts) { + expect(isColourHex(s)).toBe(false); + } + }); + + it('it fails missing #', () => { + const ts = ['FFF', 'FFFF', 'FFFFFF', 'FFFFFFFF']; + for (const s of ts) { + expect(isColourHex(s)).toBe(false); + } + }); +}); diff --git a/packages/utils/src/regex-utils/isColourHex.ts b/packages/utils/src/regex-utils/isColourHex.ts new file mode 100644 index 000000000..5bba772f2 --- /dev/null +++ b/packages/utils/src/regex-utils/isColourHex.ts @@ -0,0 +1,10 @@ +/** + * @description Validates a colour hex string + * @param {string} text - colour hex string "#FFF" | "#FFFF" | "#FFFFFF" | "#FFFFFFFF" + * @returns {boolean} string represents time + */ +export const isColourHex = (text: string): boolean => { + const regexS = /^#((?:[a-f\d]{1}){3,4})$/i; + const regexD = /^#((?:[a-f\d]{2}){3,4})$/i; + return regexS.test(text) || regexD.test(text); +};