feat: add color swatch + picker for property values
Add inline color swatch preview next to hex/CSS color property values in the Properties panel. Clicking the swatch opens the native OS color picker. Add 'color' property display mode with auto-detection for hex values and color-related key names (background, primary, etc.). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
96
src/components/ColorInput.test.tsx
Normal file
96
src/components/ColorInput.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ColorSwatch, ColorEditableValue } from './ColorInput'
|
||||
|
||||
describe('ColorSwatch', () => {
|
||||
it('renders a swatch button with the given color', () => {
|
||||
render(<ColorSwatch color="#3b82f6" />)
|
||||
const swatch = screen.getByTestId('color-swatch')
|
||||
expect(swatch).toBeTruthy()
|
||||
// Browser normalizes hex to rgb() in computed style
|
||||
expect(swatch.style.background).toBeTruthy()
|
||||
})
|
||||
|
||||
it('contains a hidden color input', () => {
|
||||
render(<ColorSwatch color="#ff0000" />)
|
||||
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
|
||||
expect(input.type).toBe('color')
|
||||
expect(input.value).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('calls onChange when color is picked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ColorSwatch color="#ff0000" onChange={onChange} />)
|
||||
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: '#00ff00' } })
|
||||
expect(onChange).toHaveBeenCalledWith('#00ff00')
|
||||
})
|
||||
|
||||
it('opens color input on Enter key', () => {
|
||||
const clickSpy = vi.fn()
|
||||
render(<ColorSwatch color="#ff0000" />)
|
||||
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
|
||||
input.click = clickSpy
|
||||
const swatch = screen.getByTestId('color-swatch')
|
||||
fireEvent.keyDown(swatch, { key: 'Enter' })
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens color input on Space key', () => {
|
||||
const clickSpy = vi.fn()
|
||||
render(<ColorSwatch color="#ff0000" />)
|
||||
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
|
||||
input.click = clickSpy
|
||||
const swatch = screen.getByTestId('color-swatch')
|
||||
fireEvent.keyDown(swatch, { key: ' ' })
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ColorEditableValue', () => {
|
||||
it('shows swatch when value is a valid hex color', () => {
|
||||
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
|
||||
expect(screen.getByTestId('color-swatch')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show swatch when value is not a color', () => {
|
||||
render(<ColorEditableValue value="hello world" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
|
||||
expect(screen.queryByTestId('color-swatch')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show swatch for empty string', () => {
|
||||
render(<ColorEditableValue value="" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
|
||||
expect(screen.queryByTestId('color-swatch')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows swatch in edit mode for valid color', () => {
|
||||
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
|
||||
expect(screen.getByTestId('color-swatch')).toBeTruthy()
|
||||
expect(screen.getByTestId('color-text-input')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('updates value when color is picked', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<ColorEditableValue value="#ff0000" isEditing={false} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
|
||||
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: '#00ff00' } })
|
||||
expect(onSave).toHaveBeenCalledWith('#00ff00')
|
||||
})
|
||||
|
||||
it('text field works independently in edit mode', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
|
||||
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
|
||||
fireEvent.change(textInput, { target: { value: '#0000ff' } })
|
||||
fireEvent.keyDown(textInput, { key: 'Enter' })
|
||||
expect(onSave).toHaveBeenCalledWith('#0000ff')
|
||||
})
|
||||
|
||||
it('Escape cancels edit', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={onCancel} />)
|
||||
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
|
||||
fireEvent.keyDown(textInput, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
113
src/components/ColorInput.tsx
Normal file
113
src/components/ColorInput.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { isValidCssColor, toHexColor } from '../utils/colorUtils'
|
||||
|
||||
/**
|
||||
* Inline color swatch button that opens a native color picker.
|
||||
* Shows nothing if the value is not a valid CSS color.
|
||||
*/
|
||||
export function ColorSwatch({ color, onChange }: {
|
||||
color: string
|
||||
onChange?: (hex: string) => void
|
||||
}) {
|
||||
const hex = toHexColor(color) ?? '#000000'
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
inputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.value)
|
||||
}, [onChange])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="relative size-4 shrink-0 cursor-pointer rounded-[3px] border border-border p-0 transition-shadow hover:ring-1 hover:ring-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ background: color }}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
title="Open color picker"
|
||||
aria-label={`Color: ${color}. Click to open color picker`}
|
||||
data-testid="color-swatch"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="color"
|
||||
value={hex}
|
||||
onChange={handleChange}
|
||||
className="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
data-testid="color-picker-input"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable text field with an inline color swatch.
|
||||
* The swatch only appears when the value is a valid CSS color.
|
||||
*/
|
||||
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: {
|
||||
value: string
|
||||
isEditing: boolean
|
||||
onStartEdit: () => void
|
||||
onSave: (newValue: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
const showSwatch = isValidCssColor(isEditing ? editValue : value)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') onSave(editValue)
|
||||
else if (e.key === 'Escape') { setEditValue(value); onCancel() }
|
||||
}
|
||||
|
||||
const handlePickerChange = useCallback((hex: string) => {
|
||||
if (isEditing) {
|
||||
setEditValue(hex)
|
||||
}
|
||||
onSave(hex)
|
||||
}, [isEditing, onSave])
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<span className="flex w-full items-center gap-1.5">
|
||||
{showSwatch && <ColorSwatch color={editValue} onChange={handlePickerChange} />}
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSave(editValue)}
|
||||
autoFocus
|
||||
data-testid="color-text-input"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
{value || '\u2014'}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
|
||||
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { isValidCssColor } from '../utils/colorUtils'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { TagsDropdown } from './TagsDropdown'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
import { ColorEditableValue } from './ColorInput'
|
||||
|
||||
// Keys that are relationships (contain wikilinks)
|
||||
export const RELATIONSHIP_KEYS = new Set([
|
||||
@@ -234,6 +236,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
|
||||
{ value: 'status', label: 'Status' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
{ value: 'color', label: 'Color' },
|
||||
]
|
||||
|
||||
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
@@ -312,7 +315,7 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
}
|
||||
|
||||
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
}
|
||||
|
||||
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
@@ -546,6 +549,7 @@ function toBooleanValue(value: FrontmatterValue): boolean {
|
||||
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'string' && isUrlValue(value)) return 'url'
|
||||
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
@@ -572,6 +576,8 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses
|
||||
}
|
||||
case 'url':
|
||||
return <UrlValue {...editProps} />
|
||||
case 'color':
|
||||
return <ColorEditableValue {...editProps} />
|
||||
default:
|
||||
return <EditableValue {...editProps} />
|
||||
}
|
||||
|
||||
134
src/utils/colorUtils.test.ts
Normal file
134
src/utils/colorUtils.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isValidCssColor, expandShortHex, toHexColor, isColorKeyName } from './colorUtils'
|
||||
|
||||
describe('isValidCssColor', () => {
|
||||
it('recognizes 6-digit hex colors', () => {
|
||||
expect(isValidCssColor('#3b82f6')).toBe(true)
|
||||
expect(isValidCssColor('#FFFFFF')).toBe(true)
|
||||
expect(isValidCssColor('#000000')).toBe(true)
|
||||
expect(isValidCssColor('#abcdef')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes 3-digit hex colors', () => {
|
||||
expect(isValidCssColor('#fff')).toBe(true)
|
||||
expect(isValidCssColor('#000')).toBe(true)
|
||||
expect(isValidCssColor('#abc')).toBe(true)
|
||||
expect(isValidCssColor('#F0A')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes 8-digit hex colors (with alpha)', () => {
|
||||
expect(isValidCssColor('#3b82f6ff')).toBe(true)
|
||||
expect(isValidCssColor('#00000080')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes rgb() colors', () => {
|
||||
expect(isValidCssColor('rgb(255, 0, 0)')).toBe(true)
|
||||
expect(isValidCssColor('rgb(0, 128, 255)')).toBe(true)
|
||||
expect(isValidCssColor('rgb( 255 , 255 , 255 )')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes rgba() colors', () => {
|
||||
expect(isValidCssColor('rgba(255, 0, 0, 0.5)')).toBe(true)
|
||||
expect(isValidCssColor('rgba(0, 128, 255, 1)')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes hsl() colors', () => {
|
||||
expect(isValidCssColor('hsl(120, 50%, 50%)')).toBe(true)
|
||||
expect(isValidCssColor('hsl(0, 100%, 50%)')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes hsla() colors', () => {
|
||||
expect(isValidCssColor('hsla(120, 50%, 50%, 0.5)')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes named CSS colors', () => {
|
||||
expect(isValidCssColor('red')).toBe(true)
|
||||
expect(isValidCssColor('blue')).toBe(true)
|
||||
expect(isValidCssColor('cornflowerblue')).toBe(true)
|
||||
expect(isValidCssColor('rebeccapurple')).toBe(true)
|
||||
expect(isValidCssColor('White')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid color strings', () => {
|
||||
expect(isValidCssColor('#zzzzzz')).toBe(false)
|
||||
expect(isValidCssColor('not-a-color')).toBe(false)
|
||||
expect(isValidCssColor('')).toBe(false)
|
||||
expect(isValidCssColor('hello')).toBe(false)
|
||||
expect(isValidCssColor('#12')).toBe(false)
|
||||
expect(isValidCssColor('#1234')).toBe(false)
|
||||
expect(isValidCssColor('#12345')).toBe(false)
|
||||
expect(isValidCssColor('#1234567')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects null/undefined/non-string', () => {
|
||||
expect(isValidCssColor(null as never)).toBe(false)
|
||||
expect(isValidCssColor(undefined as never)).toBe(false)
|
||||
expect(isValidCssColor(123 as never)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles whitespace-padded values', () => {
|
||||
expect(isValidCssColor(' #fff ')).toBe(true)
|
||||
expect(isValidCssColor(' red ')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandShortHex', () => {
|
||||
it('expands #rgb to #rrggbb', () => {
|
||||
expect(expandShortHex('#fff')).toBe('#ffffff')
|
||||
expect(expandShortHex('#abc')).toBe('#aabbcc')
|
||||
expect(expandShortHex('#F0A')).toBe('#FF00AA')
|
||||
})
|
||||
|
||||
it('returns non-short-hex values unchanged', () => {
|
||||
expect(expandShortHex('#abcdef')).toBe('#abcdef')
|
||||
expect(expandShortHex('red')).toBe('red')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHexColor', () => {
|
||||
it('converts 6-digit hex (pass-through, lowercased)', () => {
|
||||
expect(toHexColor('#3b82f6')).toBe('#3b82f6')
|
||||
expect(toHexColor('#FFFFFF')).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('expands 3-digit hex to 6-digit', () => {
|
||||
expect(toHexColor('#fff')).toBe('#ffffff')
|
||||
expect(toHexColor('#abc')).toBe('#aabbcc')
|
||||
})
|
||||
|
||||
it('strips alpha from 8-digit hex', () => {
|
||||
expect(toHexColor('#3b82f6ff')).toBe('#3b82f6')
|
||||
expect(toHexColor('#00000080')).toBe('#000000')
|
||||
})
|
||||
|
||||
it('returns null for rgb/hsl/named when OffscreenCanvas unavailable', () => {
|
||||
expect(toHexColor('rgb(255, 0, 0)')).toBeNull()
|
||||
expect(toHexColor('red')).toBeNull()
|
||||
expect(toHexColor('hsl(120, 50%, 50%)')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles whitespace around hex values', () => {
|
||||
expect(toHexColor(' #ffffff ')).toBe('#ffffff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isColorKeyName', () => {
|
||||
it('recognizes color-related key names', () => {
|
||||
expect(isColorKeyName('color')).toBe(true)
|
||||
expect(isColorKeyName('background')).toBe(true)
|
||||
expect(isColorKeyName('foreground')).toBe(true)
|
||||
expect(isColorKeyName('primary')).toBe(true)
|
||||
expect(isColorKeyName('border')).toBe(true)
|
||||
expect(isColorKeyName('muted')).toBe(true)
|
||||
expect(isColorKeyName('accent-color')).toBe(true)
|
||||
expect(isColorKeyName('fill')).toBe(true)
|
||||
expect(isColorKeyName('stroke')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-color key names', () => {
|
||||
expect(isColorKeyName('name')).toBe(false)
|
||||
expect(isColorKeyName('status')).toBe(false)
|
||||
expect(isColorKeyName('deadline')).toBe(false)
|
||||
expect(isColorKeyName('description')).toBe(false)
|
||||
})
|
||||
})
|
||||
91
src/utils/colorUtils.ts
Normal file
91
src/utils/colorUtils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/** Regex patterns for common CSS color formats. */
|
||||
const HEX3_RE = /^#[0-9a-f]{3}$/i
|
||||
const HEX6_RE = /^#[0-9a-f]{6}$/i
|
||||
const HEX8_RE = /^#[0-9a-f]{8}$/i
|
||||
const RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
|
||||
const HSL_RE = /^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
|
||||
|
||||
/** CSS named colors (lowercase). */
|
||||
const NAMED_COLORS = new Set([
|
||||
'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
|
||||
'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse',
|
||||
'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan',
|
||||
'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
|
||||
'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen',
|
||||
'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink',
|
||||
'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen',
|
||||
'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow',
|
||||
'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
|
||||
'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan',
|
||||
'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon',
|
||||
'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue',
|
||||
'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine',
|
||||
'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue',
|
||||
'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream',
|
||||
'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange',
|
||||
'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred',
|
||||
'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple',
|
||||
'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell',
|
||||
'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen',
|
||||
'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white',
|
||||
'whitesmoke', 'yellow', 'yellowgreen',
|
||||
])
|
||||
|
||||
/** Check if a string is a valid CSS color value. */
|
||||
export function isValidCssColor(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') return false
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
return HEX3_RE.test(trimmed) || HEX6_RE.test(trimmed) || HEX8_RE.test(trimmed)
|
||||
|| RGB_RE.test(trimmed) || HSL_RE.test(trimmed) || NAMED_COLORS.has(trimmed.toLowerCase())
|
||||
}
|
||||
|
||||
/** Expand #rgb to #rrggbb. */
|
||||
export function expandShortHex(hex: string): string {
|
||||
if (!HEX3_RE.test(hex)) return hex
|
||||
const r = hex[1], g = hex[2], b = hex[3]
|
||||
return `#${r}${r}${g}${g}${b}${b}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a valid CSS color to #rrggbb format for use with <input type="color">.
|
||||
* Returns null if conversion is not possible without DOM.
|
||||
*/
|
||||
export function toHexColor(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (HEX6_RE.test(trimmed)) return trimmed.toLowerCase()
|
||||
if (HEX3_RE.test(trimmed)) return expandShortHex(trimmed).toLowerCase()
|
||||
if (HEX8_RE.test(trimmed)) return trimmed.slice(0, 7).toLowerCase()
|
||||
// For rgb/hsl/named, use an offscreen canvas if available
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
return canvasColorToHex(trimmed)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function canvasColorToHex(color: string): string | null {
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(1, 1)
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.fillStyle = color
|
||||
const result = ctx.fillStyle
|
||||
if (typeof result === 'string' && result.startsWith('#')) {
|
||||
return result.toLowerCase()
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a property key name suggests a color value. */
|
||||
export function isColorKeyName(key: string): boolean {
|
||||
const lower = key.toLowerCase()
|
||||
return lower === 'color' || lower.endsWith('-color') || lower.endsWith('_color')
|
||||
|| lower.includes('colour') || lower.startsWith('accent')
|
||||
|| lower === 'background' || lower === 'foreground' || lower === 'border'
|
||||
|| lower === 'primary' || lower === 'secondary' || lower === 'muted'
|
||||
|| lower === 'fill' || lower === 'stroke' || lower === 'tint'
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { isValidCssColor, isColorKeyName } from './colorUtils'
|
||||
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags'
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
|
||||
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
|
||||
@@ -28,6 +29,7 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode {
|
||||
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
|
||||
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
|
||||
if (isDateString(strValue)) return 'date'
|
||||
if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user