import { getCSSNumberFormat } from '@vev/utils'; import { get, isNumber, isString } from 'lodash'; import React, { Children, ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useCallbackRef, useFormField } from '../../hooks'; import { SilkeBox } from '../silke-box'; import { FormFieldProps } from '../silke-form'; import { SilkeIcon } from '../silke-icon'; import { SilkeOverflowMenuItem } from '../silke-overflow-menu'; import { SilkePopover } from '../silke-popover'; import { SilkeTextFieldItem } from '../silke-text-field'; import { SilkeTextFieldOutline, SilkeTextFieldOutlineProps, } from '../silke-text-field/silke-text-field-outline'; import { useSilkeCSSContext } from './silke-css-context'; import styles from './silke-css-number-field.scss'; import { convertCSSNumberFormat, FORMAT_LABELS, formatNumberForDisplay, getDragCursorImage, getFormatFromText, roundCSSNumber, } from './utils'; import { useTextFieldContext } from '../silke-text-field/text-field-context'; import { SilkeDivider } from '../silke-divider'; export type SilkeCssNumberFieldProps = FormFieldProps & SilkeTextFieldOutlineProps & { formats?: string[]; options?: (SilkeOverflowMenuItem | 'divider')[]; min?: number; max?: number; /** Override the default decimal precision for the current format */ precision?: number; autoFocus?: boolean; allowAuto?: boolean | string; disableVariables?: boolean; disabled?: boolean; /** Define witch axis the number belongs to (used when converting values for the CSSContext) */ axis?: 'x' | 'y'; onEnter?: (e: React.KeyboardEvent) => void; }; const handleSelectText = (el: HTMLDivElement, selectAll: boolean) => { const range = document.createRange(); if (selectAll) range.selectNodeContents(el); else range.setStart(el, 1); window.getSelection()?.removeAllRanges(); window.getSelection()?.addRange(range); }; const preventDefault = (e: React.SyntheticEvent) => e.preventDefault(); export function SilkeCssNumberField(props: SilkeCssNumberFieldProps) { const { children, disabled, value, min, max, precision, formats, axis, allowAuto, autoFocus, className, options, onEnter, onChange, ...rest } = useFormField(props); const [openFormat, setFormatOpen] = useState(false); const rootRef = useRef(null); const numberRef = useRef(null); const context = useSilkeCSSContext(); const textFieldContext = useTextFieldContext(); const isNotDefined = value === undefined || value === null; const isAuto = allowAuto && value === 'auto'; const isMixed = value === 'mixed'; const isCustomLabel = !isAuto && !isMixed && /^[a-z]/i.test(value || ''); const number = isNotDefined ? 'None' : isAuto ? isString(allowAuto) ? allowAuto : 'Auto' : isMixed ? 'Mixed' : isCustomLabel ? value : parseFloat(value || '0') || 0; const format = isAuto || isMixed || isNotDefined || isCustomLabel ? '' : getCSSNumberFormat(value || '0', formats?.includes('None')); let cl = styles.root; if (openFormat) cl += ' ' + styles.openFormat; if (className) cl += ' ' + className; if (disabled) cl += ' ' + styles.disabled; if (isNotDefined) cl += ' ' + styles.noneValue; const variables = React.useMemo(() => { return (context.variablesv2 || []).filter((v) => formats?.includes(v.unit || '')); }, [context.variablesv2, formats]); const handleNumberChange = () => { // const formatEl = formatRef.current; const numberEl = numberRef.current; if (numberEl) { const numberText = (numberEl.innerText || '').replace(/,/g, '.') || '0'; if (allowAuto && numberText && /au?t?o?/i.test(numberText)) { // numberEl.innerText = 'Auto'; onChange('auto'); // handleSelectText(numberEl, false); } else { const newFormat = getFormatFromText(numberText, format, formats); let value: string | number; try { value = eval(numberText.replace(/[^()*+-./%0-9]/g, '')); } catch { value = parseFloat(numberText) || 0; } if (!value) value = 0; handleChange(value, newFormat); } } }; const handleOpenFormat = (e: React.MouseEvent) => { e.preventDefault(); setFormatOpen(true); }; const handleFocus = () => { if (disabled) return; const selection = window.getSelection(); const el = numberRef.current; if (selection?.isCollapsed && el) { handleSelectText(el, true); } }; const handleNumberBlur = () => { if (openFormat) setFormatOpen(false); const numberEl = numberRef.current; if (numberEl) { const parent = numberEl.parentElement; if (parent) parent.scrollLeft = 0; numberEl.innerText = formatNumberForDisplay( format === '' ? `${number}` : isNumber(number) ? roundCSSNumber(number, format, undefined, undefined, precision).toString() + format : number + format, ); } }; const handleChange = (number: number | string, format: string) => { const newValue = isString(number) ? number + format : roundCSSNumber(number, format, min, max, precision) + format; onChange(newValue); }; const handleFormatSelect = (newFormat: string) => { const el = numberRef.current; setFormatOpen(false); if (el && newFormat !== format) { const fromFormat = isAuto ? 'auto' : format.toLowerCase(); const newValue = convertCSSNumberFormat( isString(number) ? 0 : number, fromFormat, newFormat.toLowerCase(), context, min, max, axis, ); onChange(newValue); handleSelectText(el, true); } }; useEffect(() => { if (!disabled && autoFocus) { // Delay so focus trap dosn't steal the focus setTimeout(() => { const el = numberRef.current; if (el) el.focus(); }, 100); } }, [autoFocus]); useLayoutEffect(() => { const el = numberRef.current; if (el && document.activeElement !== el) { el.innerText = formatNumberForDisplay( isNumber(number) ? roundCSSNumber(number, format, undefined, undefined, precision).toString() + format : number + format, ); } }, [number, format, variables, isAuto, isMixed]); const handleNumberKeyDown = (e: React.KeyboardEvent) => { if (openFormat) setFormatOpen(false); let step = 0; if (e.key === 'Enter') { e.preventDefault(); if (onEnter) onEnter(e); } if (e.key === 'ArrowUp') step = 1; else if (e.key === 'ArrowDown') step = -1; if (step && !isString(number)) { e.preventDefault(); if (e.shiftKey) step *= 10; else if (e.altKey) step /= 10; handleNumberStepRef.current(step); } }; const handleNumberStepRef = useCallbackRef((step: number) => { const numberEl = numberRef.current; if (!isString(number) && numberEl) { const newNumber = roundCSSNumber(number + step, format, min, max, precision); handleChange(newNumber, format); numberEl.innerText = formatNumberForDisplay(newNumber.toString()); handleSelectText(numberEl, false); } }); const before: ReactNode[] = []; const after: ReactNode[] = []; Children.forEach(children, (child) => { if (React.isValidElement(child) && child.type === SilkeTextFieldItem && child.props.before) { before.push(child); } else { after.push(child); } }); const handleStartDrag = async (e: React.MouseEvent) => { const canvas = document.createElement('canvas'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; canvas.classList.add(styles.counterCanvas); document.body.appendChild(canvas); const img = await getDragCursorImage(); const ctx = canvas.getContext('2d'); canvas.requestPointerLock(); const y = e.pageY - 20; let x = e.pageX - 16; const handleMouseMove = (e: MouseEvent) => { let step = Math.round(e.movementX) / 2; if (e.altKey) step /= 10; if (e.shiftKey) step *= 2; if (step) { handleNumberStepRef.current(step); x += step; draw(); } }; const handleMouseUp = () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); canvas.remove(); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); const draw = () => { if (x > canvas.width) x = 0; else if (x < 0) x = canvas.width; ctx?.clearRect(0, 0, canvas.width, canvas.height); ctx?.drawImage(img, x, y); }; draw(); }; const showFormatsArrow = !disabled && !context.readonly && ((formats?.length || 0) > 1 || (options?.length || 0) > 1); let inputCl = styles.field; if (showFormatsArrow) inputCl += ' ' + styles.hasFormats; const size = rest.size || textFieldContext.size; return ( { e.preventDefault(); handleOpenFormat(e); }} > {before}
{ handleFocus(); if (props.onFocus) props.onFocus(e.nativeEvent); }} onBlur={(e) => { handleNumberBlur(); if (props.onBlur) props.onBlur(e.nativeEvent); }} /> {showFormatsArrow && ( )} {!disabled && !context.readonly && !isAuto && (
)} {after} {openFormat && ( setFormatOpen(false)} > {formats?.map((item) => ( handleFormatSelect(item)} label={item in FORMAT_LABELS ? `${get(FORMAT_LABELS, item)} (${item})` : item} /> ))} {allowAuto && ( onChange('auto')} style={{ justifyContent: 'start' }} /> )} {(options?.length || 0) > 0 && ( <> {options?.map((item, index) => !item ? null : item === 'divider' ? ( ) : ( { setFormatOpen(false); item.onClick?.(e); }} label={item.label} /> ), )} )} )} ); }