mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-18 05:31:03 +00:00
Compare commits
4 Commits
sno-qa-969
...
v1.44.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62779d3c51 | ||
|
|
4cf160d66e | ||
|
|
c0871ba219 | ||
|
|
6d4fc1bb10 |
31
.github/actions/lint-format-verify/action.yml
vendored
Normal file
31
.github/actions/lint-format-verify/action.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: 'Lint and format verify'
|
||||
description: >
|
||||
Runs the lint/format/knip verification suite plus a conditional
|
||||
browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and
|
||||
ci-lint-format-queue.yaml (merge queue) so both paths run the exact
|
||||
same checks. The caller is responsible for checkout and frontend setup
|
||||
before invoking this action.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect browser_tests changes
|
||||
id: changed-paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
browser_tests:
|
||||
- 'browser_tests/**'
|
||||
|
||||
- name: Verify lint and format
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm stylelint
|
||||
pnpm format:check
|
||||
pnpm knip
|
||||
|
||||
- name: Typecheck browser tests
|
||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||
shell: bash
|
||||
run: pnpm typecheck:browser
|
||||
29
.github/workflows/ci-lint-format-queue.yaml
vendored
Normal file
29
.github/workflows/ci-lint-format-queue.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Description: Lint and format verification for GitHub merge queue runs.
|
||||
# Paired with ci-lint-format.yaml — workflow name and job name must match
|
||||
# so branch protection resolves a single required check in both the
|
||||
# pull_request and merge_group contexts. This file runs verify-only steps
|
||||
# with a read-only token; auto-fix and PR comments live in the PR workflow.
|
||||
name: 'CI: Lint Format'
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-and-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout merge group ref
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Verify lint and format
|
||||
uses: ./.github/actions/lint-format-verify
|
||||
25
.github/workflows/ci-lint-format.yaml
vendored
25
.github/workflows/ci-lint-format.yaml
vendored
@@ -1,4 +1,7 @@
|
||||
# Description: Linting and code formatting validation for pull requests
|
||||
# Description: Linting and code formatting validation for pull requests.
|
||||
# Paired with ci-lint-format-queue.yaml - workflow name and job name must
|
||||
# match so branch protection resolves a single required check in both the
|
||||
# pull_request and merge_group contexts.
|
||||
name: 'CI: Lint Format'
|
||||
|
||||
on:
|
||||
@@ -26,14 +29,6 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Detect browser_tests changes
|
||||
id: changed-paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
browser_tests:
|
||||
- 'browser_tests/**'
|
||||
|
||||
- name: Run ESLint with auto-fix
|
||||
run: pnpm lint:fix
|
||||
|
||||
@@ -77,16 +72,8 @@ jobs:
|
||||
echo "See CONTRIBUTING.md for more details."
|
||||
exit 1
|
||||
|
||||
- name: Final validation
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm stylelint
|
||||
pnpm format:check
|
||||
pnpm knip
|
||||
|
||||
- name: Typecheck browser tests
|
||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||
run: pnpm typecheck:browser
|
||||
- name: Verify lint and format
|
||||
uses: ./.github/actions/lint-format-verify
|
||||
|
||||
- name: Comment on PR about auto-fix
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
1
.github/workflows/ci-tests-e2e.yaml
vendored
1
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/ci-tests-unit.yaml
vendored
1
.github/workflows/ci-tests-unit.yaml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -351,16 +351,9 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-off.png'
|
||||
)
|
||||
// Re-expand: clicking the canvas toggler on a collapsed node is
|
||||
// unreliable because DOM widget overlays may intercept the pointer
|
||||
// event. Use programmatic collapse() for the expand step.
|
||||
// TODO(#11006): Restore click-to-expand once DOM widget overlay pointer interception is fixed
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)!
|
||||
node.collapse()
|
||||
window.app!.canvas.setDirty(true, true)
|
||||
}, targetNode.id)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvas.click({
|
||||
position: togglerPos
|
||||
})
|
||||
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
|
||||
// Move mouse away to avoid hover highlight differences.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -114,4 +114,21 @@ describe('DomWidget disabled style', () => {
|
||||
expect(root.style.pointerEvents).toBe('none')
|
||||
expect(root.style.opacity).toBe('0.5')
|
||||
})
|
||||
|
||||
it('disables pointer events when widget is not visible', async () => {
|
||||
const widgetState = createWidgetState(false)
|
||||
widgetState.visible = false
|
||||
const { container } = render(DomWidget, {
|
||||
props: {
|
||||
widgetState
|
||||
}
|
||||
})
|
||||
|
||||
widgetState.zIndex = 3
|
||||
await nextTick()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const root = container.querySelector('.dom-widget') as HTMLElement
|
||||
expect(root.style.pointerEvents).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,7 +113,10 @@ function composeStyle() {
|
||||
...positionStyle.value,
|
||||
...(enableDomClipping.value ? clippingStyle.value : {}),
|
||||
zIndex: widgetState.zIndex,
|
||||
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
|
||||
pointerEvents:
|
||||
!widgetState.visible || widgetState.readonly || isDisabled
|
||||
? 'none'
|
||||
: 'auto',
|
||||
opacity: isDisabled ? 0.5 : 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,38 @@ describe('useGLSLPreview', () => {
|
||||
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses custom resolution when size_mode is custom', async () => {
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
store._widgetMap.set('size_mode', { value: 'custom' })
|
||||
store._widgetMap.set('size_mode.width', { value: 800 })
|
||||
store._widgetMap.set('size_mode.height', { value: 600 })
|
||||
|
||||
const node = createMockNode()
|
||||
await setupAndRender(node)
|
||||
|
||||
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(800, 600)
|
||||
|
||||
store._widgetMap.delete('size_mode')
|
||||
store._widgetMap.delete('size_mode.width')
|
||||
store._widgetMap.delete('size_mode.height')
|
||||
})
|
||||
|
||||
it('uses default resolution when size_mode is not custom', async () => {
|
||||
const store = fromAny<WidgetValueStoreStub, unknown>(
|
||||
useWidgetValueStore()
|
||||
)
|
||||
store._widgetMap.set('size_mode', { value: 'from_input' })
|
||||
|
||||
const node = createMockNode()
|
||||
await setupAndRender(node)
|
||||
|
||||
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(512, 512)
|
||||
|
||||
store._widgetMap.delete('size_mode')
|
||||
})
|
||||
|
||||
it('disposes renderer and cancels debounce on cleanup', async () => {
|
||||
const node = createMockNode()
|
||||
const { dispose } = await setupAndRender(node)
|
||||
|
||||
@@ -282,7 +282,44 @@ function createInnerPreview(
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomResolution(): [number, number] | null {
|
||||
const gId = graphId.value
|
||||
if (!gId) return null
|
||||
|
||||
const sizeModeNodeId = innerGLSLNode
|
||||
? (innerGLSLNode.id as NodeId)
|
||||
: nodeId.value
|
||||
if (sizeModeNodeId == null) return null
|
||||
|
||||
const sizeMode = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode'
|
||||
)
|
||||
if (sizeMode?.value !== 'custom') return null
|
||||
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (!widthWidget || !heightWidget) return null
|
||||
|
||||
return clampResolution(
|
||||
normalizeDimension(widthWidget.value),
|
||||
normalizeDimension(heightWidget.value)
|
||||
)
|
||||
}
|
||||
|
||||
function getResolution(): [number, number] {
|
||||
const custom = getCustomResolution()
|
||||
if (custom) return custom
|
||||
|
||||
const node = nodeRef.value
|
||||
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
|
||||
@@ -325,27 +362,6 @@ function createInnerPreview(
|
||||
}
|
||||
}
|
||||
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (gId && nId != null) {
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (widthWidget && heightWidget) {
|
||||
return clampResolution(
|
||||
normalizeDimension(widthWidget.value),
|
||||
normalizeDimension(heightWidget.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
}
|
||||
|
||||
|
||||
115
src/renderer/glsl/useGLSLUniforms.test.ts
Normal file
115
src/renderer/glsl/useGLSLUniforms.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
|
||||
import {
|
||||
extractUniformSources,
|
||||
toNumber
|
||||
} from '@/renderer/glsl/useGLSLUniforms'
|
||||
|
||||
function createMockSubgraph(
|
||||
links: Record<number, { origin_id: number; origin_slot: number }>,
|
||||
nodes: Record<
|
||||
number,
|
||||
{ id: number; widgets: Array<{ name: string; value: unknown }> }
|
||||
>
|
||||
) {
|
||||
return fromAny<Subgraph, unknown>({
|
||||
getLink: (id: number) => links[id] ?? null,
|
||||
getNodeById: (id: number) => nodes[id] ?? null
|
||||
})
|
||||
}
|
||||
|
||||
describe('extractUniformSources', () => {
|
||||
it('uses origin_slot to select the correct widget from source node', () => {
|
||||
const glslNode = fromAny<LGraphNode, unknown>({
|
||||
inputs: [
|
||||
{ name: 'ints.u_int0', link: 1 },
|
||||
{ name: 'ints.u_int1', link: 2 }
|
||||
]
|
||||
})
|
||||
|
||||
const subgraph = createMockSubgraph(
|
||||
{
|
||||
1: { origin_id: 10, origin_slot: 1 },
|
||||
2: { origin_id: 20, origin_slot: 0 }
|
||||
},
|
||||
{
|
||||
10: {
|
||||
id: 10,
|
||||
widgets: [
|
||||
{ name: 'choice', value: 'Master' },
|
||||
{ name: 'index', value: 0 }
|
||||
]
|
||||
},
|
||||
20: {
|
||||
id: 20,
|
||||
widgets: [{ name: 'value', value: 42 }]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const result = extractUniformSources(glslNode, subgraph)
|
||||
|
||||
expect(result.ints[0].widgetName).toBe('index')
|
||||
expect(result.ints[1].widgetName).toBe('value')
|
||||
})
|
||||
|
||||
it('skips source when origin_slot exceeds widget count', () => {
|
||||
const glslNode = fromAny<LGraphNode, unknown>({
|
||||
inputs: [{ name: 'floats.u_float0', link: 1 }]
|
||||
})
|
||||
|
||||
const subgraph = createMockSubgraph(
|
||||
{ 1: { origin_id: 10, origin_slot: 5 } },
|
||||
{ 10: { id: 10, widgets: [{ name: 'value', value: 3.14 }] } }
|
||||
)
|
||||
|
||||
const result = extractUniformSources(glslNode, subgraph)
|
||||
|
||||
expect(result.floats).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('provides directValue getter that reads from the widget', () => {
|
||||
const indexWidget = {
|
||||
name: 'index',
|
||||
get value() {
|
||||
return choiceWidget.value === 'Reds' ? 1 : 0
|
||||
}
|
||||
}
|
||||
const choiceWidget = { name: 'choice', value: 'Master' }
|
||||
|
||||
const glslNode = fromAny<LGraphNode, unknown>({
|
||||
inputs: [{ name: 'ints.u_int0', link: 1 }]
|
||||
})
|
||||
|
||||
const subgraph = createMockSubgraph(
|
||||
{ 1: { origin_id: 10, origin_slot: 1 } },
|
||||
{ 10: { id: 10, widgets: [choiceWidget, indexWidget] } }
|
||||
)
|
||||
|
||||
const result = extractUniformSources(glslNode, subgraph)
|
||||
|
||||
expect(result.ints[0].directValue()).toBe(0)
|
||||
|
||||
choiceWidget.value = 'Reds'
|
||||
expect(result.ints[0].directValue()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toNumber', () => {
|
||||
it('coerces hex color strings via hexToInt', () => {
|
||||
expect(toNumber('#45edf5')).toBe(0x45edf5)
|
||||
})
|
||||
|
||||
it('coerces plain numeric values', () => {
|
||||
expect(toNumber(42)).toBe(42)
|
||||
expect(toNumber('10')).toBe(10)
|
||||
})
|
||||
|
||||
it('returns 0 for non-numeric strings', () => {
|
||||
expect(toNumber('Master')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { isCurveData } from '@/components/curve/curveUtils'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import { hexToInt } from '@/utils/colorUtil'
|
||||
|
||||
interface AutogrowGroup {
|
||||
max: number
|
||||
@@ -20,6 +21,8 @@ interface AutogrowGroup {
|
||||
interface UniformSource {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
/** Fallback getter for widgets not registered in widgetValueStore (e.g. hidden computed widgets). */
|
||||
directValue: () => unknown
|
||||
}
|
||||
|
||||
interface UniformSources {
|
||||
@@ -78,16 +81,19 @@ export function extractUniformSources(
|
||||
if (!link || link.origin_id === SUBGRAPH_INPUT_ID) continue
|
||||
|
||||
const sourceNode = subgraph.getNodeById(link.origin_id)
|
||||
if (!sourceNode?.widgets?.[0]) continue
|
||||
if (!sourceNode?.widgets?.length) continue
|
||||
|
||||
const inputName = input.name ?? ''
|
||||
const dotIndex = inputName.indexOf('.')
|
||||
if (dotIndex === -1) continue
|
||||
|
||||
const prefix = inputName.slice(0, dotIndex)
|
||||
if (link.origin_slot >= sourceNode.widgets.length) continue
|
||||
const widget = sourceNode.widgets[link.origin_slot]
|
||||
const source: UniformSource = {
|
||||
nodeId: sourceNode.id as NodeId,
|
||||
widgetName: sourceNode.widgets[0].name
|
||||
widgetName: widget.name,
|
||||
directValue: () => widget.value
|
||||
}
|
||||
|
||||
if (prefix === 'floats') floats.push(source)
|
||||
@@ -99,6 +105,11 @@ export function extractUniformSources(
|
||||
return { floats, ints, bools, curves }
|
||||
}
|
||||
|
||||
export function toNumber(v: unknown): number {
|
||||
if (typeof v === 'string' && v.startsWith('#')) return hexToInt(v)
|
||||
return Number(v) || 0
|
||||
}
|
||||
|
||||
export function useGLSLUniforms(
|
||||
graphId: ComputedRef<UUID | undefined>,
|
||||
nodeId: ComputedRef<NodeId | undefined>,
|
||||
@@ -120,9 +131,9 @@ export function useGLSLUniforms(
|
||||
if (!gId) return []
|
||||
|
||||
if (subgraphSources) {
|
||||
return subgraphSources.map(({ nodeId: nId, widgetName }) => {
|
||||
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
return coerce(widget?.value ?? defaultValue)
|
||||
return coerce(widget?.value ?? directValue() ?? defaultValue)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,19 +153,24 @@ export function useGLSLUniforms(
|
||||
const slot = node.inputs?.findIndex((inp) => inp.name === inputName)
|
||||
if (slot == null || slot < 0) break
|
||||
|
||||
const link = node.getInputLink(slot)
|
||||
if (!link) break
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode) break
|
||||
const upstreamWidgets = widgetValueStore.getNodeWidgets(
|
||||
gId,
|
||||
upstreamNode.id as NodeId
|
||||
)
|
||||
if (upstreamWidgets.length === 0) break
|
||||
values.push(coerce(upstreamWidgets[0].value))
|
||||
if (
|
||||
upstreamWidgets.length === 0 ||
|
||||
link.origin_slot >= upstreamWidgets.length
|
||||
)
|
||||
break
|
||||
values.push(coerce(upstreamWidgets[link.origin_slot].value))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
const toNumber = (v: unknown): number => Number(v) || 0
|
||||
const toBool = (v: unknown): boolean => Boolean(v)
|
||||
|
||||
const floatValues = computed(() =>
|
||||
@@ -197,11 +213,10 @@ export function useGLSLUniforms(
|
||||
const sources = uniformSources.value?.curves
|
||||
if (sources && sources.length > 0) {
|
||||
return sources
|
||||
.map(({ nodeId: nId, widgetName }) => {
|
||||
.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
return widget && isCurveData(widget.value)
|
||||
? (widget.value as CurveData)
|
||||
: null
|
||||
const value = widget?.value ?? directValue()
|
||||
return isCurveData(value) ? (value as CurveData) : null
|
||||
})
|
||||
.filter((v): v is CurveData => v !== null)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
adjustColor,
|
||||
hexToHsva,
|
||||
hexToInt,
|
||||
hexToRgb,
|
||||
hsbToRgb,
|
||||
hsvaToHex,
|
||||
@@ -95,6 +96,20 @@ describe('colorUtil conversions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hexToInt', () => {
|
||||
it('converts 6-digit hex to packed integer', () => {
|
||||
expect(hexToInt('#ff0000')).toBe(0xff0000)
|
||||
expect(hexToInt('#00ff00')).toBe(0x00ff00)
|
||||
expect(hexToInt('#45edf5')).toBe(0x45edf5)
|
||||
expect(hexToInt('#000000')).toBe(0)
|
||||
})
|
||||
|
||||
it('converts 3-digit hex to packed integer', () => {
|
||||
expect(hexToInt('#fff')).toBe(0xffffff)
|
||||
expect(hexToInt('#f00')).toBe(0xff0000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseToRgb', () => {
|
||||
it('parses #hex', () => {
|
||||
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
|
||||
|
||||
@@ -82,6 +82,11 @@ export function hexToRgb(hex: string): RGB {
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
export function hexToInt(hex: string): number {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return (r << 16) | (g << 8) | b
|
||||
}
|
||||
|
||||
export function rgbToHex({ r, g, b }: RGB): string {
|
||||
const toHex = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round(n)))
|
||||
|
||||
Reference in New Issue
Block a user