mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 01:39:47 +00:00
Fixed Square Brush, Improve Brush Hardness and Smoothing Precision (#4519)
* Fixed square brush with hardness <1; improved the effect of hardness, improved the effect of smoothing precision * Improved square hardness and code quality with performance optimizations * Fix brush rendering anti-aliasing and optimized square brushes using texture caching * Switched to QuickLRU for brush cache * Cleaned up exports from testing * Removed SOFT_BRUSH_STEPS unused variable
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import QuickLRU from '@alloc/quick-lru'
|
||||||
import { debounce } from 'es-toolkit/compat'
|
import { debounce } from 'es-toolkit/compat'
|
||||||
import _ from 'es-toolkit/compat'
|
import _ from 'es-toolkit/compat'
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { ComfyApp } from '../../scripts/app'
|
|||||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||||
import { getStorageValue, setStorageValue } from '../../scripts/utils'
|
import { getStorageValue, setStorageValue } from '../../scripts/utils'
|
||||||
import { hexToRgb } from '../../utils/colorUtil'
|
import { hexToRgb } from '../../utils/colorUtil'
|
||||||
|
import { parseToRgb } from '../../utils/colorUtil'
|
||||||
import { ClipspaceDialog } from './clipspace'
|
import { ClipspaceDialog } from './clipspace'
|
||||||
import {
|
import {
|
||||||
imageLayerFilenamesByTimestamp,
|
imageLayerFilenamesByTimestamp,
|
||||||
@@ -811,7 +813,7 @@ interface Offset {
|
|||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Brush {
|
interface Brush {
|
||||||
type: BrushShape
|
type: BrushShape
|
||||||
size: number
|
size: number
|
||||||
opacity: number
|
opacity: number
|
||||||
@@ -2049,9 +2051,16 @@ class BrushTool {
|
|||||||
rgbCtx: CanvasRenderingContext2D | null = null
|
rgbCtx: CanvasRenderingContext2D | null = null
|
||||||
initialDraw: boolean = true
|
initialDraw: boolean = true
|
||||||
|
|
||||||
|
private static brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
|
||||||
|
maxSize: 8 // Reasonable limit for brush texture variations?
|
||||||
|
})
|
||||||
|
|
||||||
brushStrokeCanvas: HTMLCanvasElement | null = null
|
brushStrokeCanvas: HTMLCanvasElement | null = null
|
||||||
brushStrokeCtx: CanvasRenderingContext2D | null = null
|
brushStrokeCtx: CanvasRenderingContext2D | null = null
|
||||||
|
|
||||||
|
private static readonly SMOOTHING_MAX_STEPS = 30
|
||||||
|
private static readonly SMOOTHING_MIN_STEPS = 2
|
||||||
|
|
||||||
//brush adjustment
|
//brush adjustment
|
||||||
isBrushAdjusting: boolean = false
|
isBrushAdjusting: boolean = false
|
||||||
brushPreviewGradient: HTMLElement | null = null
|
brushPreviewGradient: HTMLElement | null = null
|
||||||
@@ -2254,6 +2263,10 @@ class BrushTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clampSmoothingPrecision(value: number): number {
|
||||||
|
return Math.min(Math.max(value, 1), 100)
|
||||||
|
}
|
||||||
|
|
||||||
private drawWithBetterSmoothing(point: Point) {
|
private drawWithBetterSmoothing(point: Point) {
|
||||||
// Add current point to the smoothing array
|
// Add current point to the smoothing array
|
||||||
if (!this.smoothingCordsArray) {
|
if (!this.smoothingCordsArray) {
|
||||||
@@ -2285,9 +2298,21 @@ class BrushTool {
|
|||||||
totalLength += Math.sqrt(dx * dx + dy * dy)
|
totalLength += Math.sqrt(dx * dx + dy * dy)
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceBetweenPoints =
|
const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
|
||||||
(this.brushSettings.size / this.brushSettings.smoothingPrecision) * 6
|
const minSteps = BrushTool.SMOOTHING_MIN_STEPS
|
||||||
const stepNr = Math.ceil(totalLength / distanceBetweenPoints)
|
|
||||||
|
const smoothing = this.clampSmoothingPrecision(
|
||||||
|
this.brushSettings.smoothingPrecision
|
||||||
|
)
|
||||||
|
const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
|
||||||
|
|
||||||
|
// Optionality to use exponential curve
|
||||||
|
const stepNr = Math.round(
|
||||||
|
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate step distance capped by brush size
|
||||||
|
const distanceBetweenPoints = totalLength / stepNr
|
||||||
|
|
||||||
let interpolatedPoints = points
|
let interpolatedPoints = points
|
||||||
|
|
||||||
@@ -2435,101 +2460,205 @@ class BrushTool {
|
|||||||
const hardness = brushSettings.hardness
|
const hardness = brushSettings.hardness
|
||||||
const x = point.x
|
const x = point.x
|
||||||
const y = point.y
|
const y = point.y
|
||||||
// Extend the gradient radius beyond the brush size
|
|
||||||
const extendedSize = size * (2 - hardness)
|
|
||||||
|
|
||||||
|
const brushRadius = size
|
||||||
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
|
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
|
||||||
const currentTool = await this.messageBroker.pull('currentTool')
|
const currentTool = await this.messageBroker.pull('currentTool')
|
||||||
|
|
||||||
// handle paint pen
|
// Helper function to get or create cached brush texture
|
||||||
|
const getCachedBrushTexture = (
|
||||||
|
radius: number,
|
||||||
|
hardness: number,
|
||||||
|
color: string,
|
||||||
|
opacity: number
|
||||||
|
): HTMLCanvasElement => {
|
||||||
|
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
|
||||||
|
|
||||||
|
if (BrushTool.brushTextureCache.has(cacheKey)) {
|
||||||
|
return BrushTool.brushTextureCache.get(cacheKey)!
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempCanvas = document.createElement('canvas')
|
||||||
|
const tempCtx = tempCanvas.getContext('2d')!
|
||||||
|
const size = radius * 2
|
||||||
|
tempCanvas.width = size
|
||||||
|
tempCanvas.height = size
|
||||||
|
|
||||||
|
const centerX = size / 2
|
||||||
|
const centerY = size / 2
|
||||||
|
const hardRadius = radius * hardness
|
||||||
|
|
||||||
|
const imageData = tempCtx.createImageData(size, size)
|
||||||
|
const data = imageData.data
|
||||||
|
const { r, g, b } = parseToRgb(color)
|
||||||
|
|
||||||
|
// Pre-calculate values to avoid repeated computations
|
||||||
|
const fadeRange = radius - hardRadius
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const dy = y - centerY
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const dx = x - centerX
|
||||||
|
const index = (y * size + x) * 4
|
||||||
|
|
||||||
|
// Calculate square distance (Chebyshev distance)
|
||||||
|
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
|
||||||
|
|
||||||
|
let pixelOpacity = 0
|
||||||
|
if (distFromEdge <= hardRadius) {
|
||||||
|
pixelOpacity = opacity
|
||||||
|
} else if (distFromEdge <= radius) {
|
||||||
|
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
|
||||||
|
pixelOpacity = opacity * (1 - fadeProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
data[index] = r
|
||||||
|
data[index + 1] = g
|
||||||
|
data[index + 2] = b
|
||||||
|
data[index + 3] = pixelOpacity * 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCtx.putImageData(imageData, 0, 0)
|
||||||
|
|
||||||
|
// Cache the texture
|
||||||
|
BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
|
||||||
|
|
||||||
|
return tempCanvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB brush logic
|
||||||
if (
|
if (
|
||||||
this.activeLayer === 'rgb' &&
|
this.activeLayer === 'rgb' &&
|
||||||
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
|
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
|
||||||
) {
|
) {
|
||||||
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
|
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
|
||||||
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
|
|
||||||
if (hardness === 1) {
|
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||||
gradient.addColorStop(0, rgbaColor)
|
const brushTexture = getCachedBrushTexture(
|
||||||
gradient.addColorStop(
|
brushRadius,
|
||||||
1,
|
hardness,
|
||||||
this.formatRgba(this.rgbColor, brushSettingsSliderOpacity)
|
rgbaColor,
|
||||||
|
opacity
|
||||||
)
|
)
|
||||||
} else {
|
rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||||
gradient.addColorStop(0, rgbaColor)
|
return
|
||||||
gradient.addColorStop(hardness, rgbaColor)
|
|
||||||
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For max hardness, use solid fill to avoid anti-aliasing
|
||||||
|
if (hardness === 1) {
|
||||||
|
rgbCtx.fillStyle = rgbaColor
|
||||||
|
rgbCtx.beginPath()
|
||||||
|
if (brushType === BrushShape.Rect) {
|
||||||
|
rgbCtx.rect(
|
||||||
|
x - brushRadius,
|
||||||
|
y - brushRadius,
|
||||||
|
brushRadius * 2,
|
||||||
|
brushRadius * 2
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||||
|
}
|
||||||
|
rgbCtx.fill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For soft brushes, use gradient
|
||||||
|
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||||
|
gradient.addColorStop(0, rgbaColor)
|
||||||
|
gradient.addColorStop(
|
||||||
|
hardness,
|
||||||
|
this.formatRgba(this.rgbColor, opacity * 0.5)
|
||||||
|
)
|
||||||
|
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
|
||||||
|
|
||||||
rgbCtx.fillStyle = gradient
|
rgbCtx.fillStyle = gradient
|
||||||
rgbCtx.beginPath()
|
rgbCtx.beginPath()
|
||||||
if (brushType === BrushShape.Rect) {
|
if (brushType === BrushShape.Rect) {
|
||||||
rgbCtx.rect(
|
rgbCtx.rect(
|
||||||
x - extendedSize,
|
x - brushRadius,
|
||||||
y - extendedSize,
|
y - brushRadius,
|
||||||
extendedSize * 2,
|
brushRadius * 2,
|
||||||
extendedSize * 2
|
brushRadius * 2
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rgbCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
|
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||||
}
|
}
|
||||||
rgbCtx.fill()
|
rgbCtx.fill()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
|
// Mask brush logic
|
||||||
|
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||||
|
const baseColor = isErasing
|
||||||
|
? `rgba(255, 255, 255, ${opacity})`
|
||||||
|
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||||
|
|
||||||
|
const brushTexture = getCachedBrushTexture(
|
||||||
|
brushRadius,
|
||||||
|
hardness,
|
||||||
|
baseColor,
|
||||||
|
opacity
|
||||||
|
)
|
||||||
|
maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For max hardness, use solid fill to avoid anti-aliasing
|
||||||
if (hardness === 1) {
|
if (hardness === 1) {
|
||||||
|
const solidColor = isErasing
|
||||||
|
? `rgba(255, 255, 255, ${opacity})`
|
||||||
|
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||||
|
|
||||||
|
maskCtx.fillStyle = solidColor
|
||||||
|
maskCtx.beginPath()
|
||||||
|
if (brushType === BrushShape.Rect) {
|
||||||
|
maskCtx.rect(
|
||||||
|
x - brushRadius,
|
||||||
|
y - brushRadius,
|
||||||
|
brushRadius * 2,
|
||||||
|
brushRadius * 2
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||||
|
}
|
||||||
|
maskCtx.fill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For soft brushes, use gradient
|
||||||
|
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||||
|
|
||||||
|
if (isErasing) {
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
||||||
|
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
||||||
|
} else {
|
||||||
gradient.addColorStop(
|
gradient.addColorStop(
|
||||||
0,
|
0,
|
||||||
isErasing
|
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||||
? `rgba(255, 255, 255, ${opacity})`
|
)
|
||||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
gradient.addColorStop(
|
||||||
|
hardness,
|
||||||
|
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
|
||||||
)
|
)
|
||||||
gradient.addColorStop(
|
gradient.addColorStop(
|
||||||
1,
|
1,
|
||||||
isErasing
|
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
|
||||||
? `rgba(255, 255, 255, ${opacity})`
|
|
||||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
let softness = 1 - hardness
|
|
||||||
let innerStop = Math.max(0, hardness - softness)
|
|
||||||
let outerStop = size / extendedSize
|
|
||||||
|
|
||||||
if (isErasing) {
|
|
||||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
|
||||||
gradient.addColorStop(innerStop, `rgba(255, 255, 255, ${opacity})`)
|
|
||||||
gradient.addColorStop(outerStop, `rgba(255, 255, 255, ${opacity / 2})`)
|
|
||||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
|
||||||
} else {
|
|
||||||
gradient.addColorStop(
|
|
||||||
0,
|
|
||||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
|
||||||
)
|
|
||||||
gradient.addColorStop(
|
|
||||||
innerStop,
|
|
||||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
|
||||||
)
|
|
||||||
gradient.addColorStop(
|
|
||||||
outerStop,
|
|
||||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity / 2})`
|
|
||||||
)
|
|
||||||
gradient.addColorStop(
|
|
||||||
1,
|
|
||||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
maskCtx.fillStyle = gradient
|
maskCtx.fillStyle = gradient
|
||||||
maskCtx.beginPath()
|
maskCtx.beginPath()
|
||||||
if (brushType === BrushShape.Rect) {
|
if (brushType === BrushShape.Rect) {
|
||||||
maskCtx.rect(
|
maskCtx.rect(
|
||||||
x - extendedSize,
|
x - brushRadius,
|
||||||
y - extendedSize,
|
y - brushRadius,
|
||||||
extendedSize * 2,
|
brushRadius * 2,
|
||||||
extendedSize * 2
|
brushRadius * 2
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
maskCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
|
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||||
}
|
}
|
||||||
maskCtx.fill()
|
maskCtx.fill()
|
||||||
}
|
}
|
||||||
@@ -4185,30 +4314,35 @@ class UIManager {
|
|||||||
const centerY = cursorPoint.y + pan_offset.y
|
const centerY = cursorPoint.y + pan_offset.y
|
||||||
const brush = this.brush
|
const brush = this.brush
|
||||||
const hardness = brushSettings.hardness
|
const hardness = brushSettings.hardness
|
||||||
const extendedSize = brushSettings.size * (2 - hardness) * 2 * zoom_ratio
|
|
||||||
|
// Now that brush size is constant, preview is simple
|
||||||
|
const brushRadius = brushSettings.size * zoom_ratio
|
||||||
|
const previewSize = brushRadius * 2
|
||||||
|
|
||||||
this.brushSizeSlider.value = String(brushSettings.size)
|
this.brushSizeSlider.value = String(brushSettings.size)
|
||||||
this.brushHardnessSlider.value = String(hardness)
|
this.brushHardnessSlider.value = String(hardness)
|
||||||
|
|
||||||
brush.style.width = extendedSize + 'px'
|
brush.style.width = previewSize + 'px'
|
||||||
brush.style.height = extendedSize + 'px'
|
brush.style.height = previewSize + 'px'
|
||||||
brush.style.left = centerX - extendedSize / 2 + 'px'
|
brush.style.left = centerX - brushRadius + 'px'
|
||||||
brush.style.top = centerY - extendedSize / 2 + 'px'
|
brush.style.top = centerY - brushRadius + 'px'
|
||||||
|
|
||||||
if (hardness === 1) {
|
if (hardness === 1) {
|
||||||
this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)'
|
this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const opacityStop = hardness / 4 + 0.25
|
// Simplified gradient - hardness controls where the fade starts
|
||||||
|
const midStop = hardness * 100
|
||||||
|
const outerStop = 100
|
||||||
|
|
||||||
this.brushPreviewGradient.style.background = `
|
this.brushPreviewGradient.style.background = `
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
circle,
|
circle,
|
||||||
rgba(255, 0, 0, 0.5) 0%,
|
rgba(255, 0, 0, 0.5) 0%,
|
||||||
rgba(255, 0, 0, ${opacityStop}) ${hardness * 100}%,
|
rgba(255, 0, 0, 0.25) ${midStop}%,
|
||||||
rgba(255, 0, 0, 0) 100%
|
rgba(255, 0, 0, 0) ${outerStop}%
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,59 @@ export function hexToRgb(hex: string): RGB {
|
|||||||
return { r, g, b }
|
return { r, g, b }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseToRgb(color: string): RGB {
|
||||||
|
const format = identifyColorFormat(color)
|
||||||
|
if (!format) return { r: 0, g: 0, b: 0 }
|
||||||
|
|
||||||
|
const hsla = parseToHSLA(color, format)
|
||||||
|
if (!isHSLA(hsla)) return { r: 0, g: 0, b: 0 }
|
||||||
|
|
||||||
|
// Convert HSL to RGB
|
||||||
|
const h = hsla.h / 360
|
||||||
|
const s = hsla.s / 100
|
||||||
|
const l = hsla.l / 100
|
||||||
|
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||||
|
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
|
||||||
|
const m = l - c / 2
|
||||||
|
|
||||||
|
let r = 0,
|
||||||
|
g = 0,
|
||||||
|
b = 0
|
||||||
|
|
||||||
|
if (h < 1 / 6) {
|
||||||
|
r = c
|
||||||
|
g = x
|
||||||
|
b = 0
|
||||||
|
} else if (h < 2 / 6) {
|
||||||
|
r = x
|
||||||
|
g = c
|
||||||
|
b = 0
|
||||||
|
} else if (h < 3 / 6) {
|
||||||
|
r = 0
|
||||||
|
g = c
|
||||||
|
b = x
|
||||||
|
} else if (h < 4 / 6) {
|
||||||
|
r = 0
|
||||||
|
g = x
|
||||||
|
b = c
|
||||||
|
} else if (h < 5 / 6) {
|
||||||
|
r = x
|
||||||
|
g = 0
|
||||||
|
b = c
|
||||||
|
} else {
|
||||||
|
r = c
|
||||||
|
g = 0
|
||||||
|
b = x
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round((r + m) * 255),
|
||||||
|
g: Math.round((g + m) * 255),
|
||||||
|
b: Math.round((b + m) * 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const identifyColorFormat = (color: string): ColorFormat | null => {
|
const identifyColorFormat = (color: string): ColorFormat | null => {
|
||||||
if (!color) return null
|
if (!color) return null
|
||||||
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
|
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
|
||||||
|
|||||||
Reference in New Issue
Block a user