import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import { Prop, toNative } from 'vue-facing-decorator'; import { globalState } from '../../app/global-state'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import FormItemWrapper from '../form/form-item-wrapper'; import './css/minicolors.css'; import './css/color-picker.css'; interface ColorPickerArgs extends FormItemWrapperArgs { value: string; placeholder?: string; disabled?: boolean; changed: (newValue: string) => void; } interface Rgb { r: number; g: number; b: number } interface Hsv { h: number; s: number; v: number } const HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i; const clamp = ( n: number, lo: number, hi: number, ): number => Math.max(lo, Math.min(hi, n)); const parseHex = (value: string | null | undefined): Rgb | null => { const match = HEX_RE.exec(value?.trim() ?? ''); if (match == null) { return null; } let hex = match[1]; if (hex.length === 3) { hex = hex.split('').map(c => c + c).join(''); } return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), }; }; const rgbToHex = ({ r, g, b }: Rgb): string => { const toHex = (n: number) => clamp( Math.round(n), 0, 255, ).toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; }; const rgbToHsv = ({ r, g, b }: Rgb): Hsv => { const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max( rn, gn, bn, ); const min = Math.min( rn, gn, bn, ); const d = max - min; let h = 0; const s = max === 0 ? 0 : d / max; const v = max; if (d !== 0) { switch (max) { case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)); break; case gn: h = ((bn - rn) / d + 2); break; case bn: h = ((rn - gn) / d + 4); break; } h /= 6; } return { h, s, v }; }; const hsvToRgb = ({ h, s, v }: Hsv): Rgb => { const i = Math.floor(h * 6); const f = h * 6 - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); let r = 0; let g = 0; let b = 0; switch (i % 6) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break; case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break; case 5: r = v; g = p; b = q; break; } return { r: r * 255, g: g * 255, b: b * 255 }; }; type DragKind = 'slider' | 'grid' | null; @Component class ColorPickerComponent extends TsxComponent implements ColorPickerArgs { @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() subtitle!: string; @Prop() value!: string; @Prop() placeholder!: string; @Prop() disabled?: boolean; @Prop() mandatory!: boolean; @Prop() wrap!: boolean; @Prop() hint: string; @Prop() marginType?: MarginType; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() changed: (newValue: string) => void; hue: number = 0; saturation: number = 0; brightness: number = 0; hexValue: string = '#000000'; panelOpen: boolean = false; private suppressNextOutsideMousedown = false; private dragKind: DragKind = null; private moveListener: ((e: MouseEvent | TouchEvent) => void) | null = null; private upListener: (() => void) | null = null; private outsideListener: ((e: MouseEvent) => void) | null = null; mounted(): void { this.applyValue(this.value); this.outsideListener = (e: MouseEvent) => this.handleOutsideMouseDown(e); globalState.addEventListener('mousedown', this.outsideListener); } beforeUnmount(): void { if (this.outsideListener != null) { globalState.removeEventListener('mousedown', this.outsideListener); } this.detachDragListeners(); } private applyValue(raw: string): void { const rgb = parseHex(raw) ?? { r: 0, g: 0, b: 0 }; const hsv = rgbToHsv(rgb); this.hue = hsv.h; this.saturation = hsv.s; this.brightness = hsv.v; this.hexValue = rgbToHex(rgb); } private get pureHueColor(): string { const rgb = hsvToRgb({ h: this.hue, s: 1, v: 1 }); return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`; } private get sliderPickerY(): number { const rect = this.$refs.slider as HTMLElement | undefined; const h = rect?.clientHeight ?? 150; return (1 - this.hue) * h; } private get gridPickerX(): number { const rect = this.$refs.grid as HTMLElement | undefined; const w = rect?.clientWidth ?? 150; return this.saturation * w; } private get gridPickerY(): number { const rect = this.$refs.grid as HTMLElement | undefined; const h = rect?.clientHeight ?? 150; return (1 - this.brightness) * h; } private commitColor(): void { const rgb = hsvToRgb({ h: this.hue, s: this.saturation, v: this.brightness }); this.hexValue = rgbToHex(rgb); this.populateValidationDeclaration(); this.changed?.(this.hexValue); } private pointerX(e: MouseEvent | TouchEvent): number { if ('touches' in e) { return e.touches[0]?.clientX ?? (e as TouchEvent).changedTouches?.[0]?.clientX ?? 0; } return (e as MouseEvent).clientX; } private pointerY(e: MouseEvent | TouchEvent): number { if ('touches' in e) { return e.touches[0]?.clientY ?? (e as TouchEvent).changedTouches?.[0]?.clientY ?? 0; } return (e as MouseEvent).clientY; } private updateSliderFromEvent(e: MouseEvent | TouchEvent): void { const slider = this.$refs.slider as HTMLElement | undefined; if (slider == null) { return; } const rect = slider.getBoundingClientRect(); const y = clamp( this.pointerY(e) - rect.top, 0, rect.height, ); this.hue = rect.height === 0 ? 0 : 1 - (y / rect.height); this.commitColor(); } private updateGridFromEvent(e: MouseEvent | TouchEvent): void { const grid = this.$refs.grid as HTMLElement | undefined; if (grid == null) { return; } const rect = grid.getBoundingClientRect(); const x = clamp( this.pointerX(e) - rect.left, 0, rect.width, ); const y = clamp( this.pointerY(e) - rect.top, 0, rect.height, ); this.saturation = rect.width === 0 ? 0 : x / rect.width; this.brightness = rect.height === 0 ? 0 : 1 - (y / rect.height); this.commitColor(); } private startDrag(kind: DragKind, e: MouseEvent | TouchEvent): void { if (this.disabled) { return; } e.preventDefault(); this.dragKind = kind; // Apply the initial mousedown position if (kind === 'slider') { this.updateSliderFromEvent(e); } if (kind === 'grid') { this.updateGridFromEvent(e); } this.moveListener = (ev) => { if (this.dragKind === 'slider') { this.updateSliderFromEvent(ev); } if (this.dragKind === 'grid') { this.updateGridFromEvent(ev); } }; this.upListener = () => { this.detachDragListeners(); }; globalState.addEventListener('mousemove', this.moveListener); globalState.addEventListener('mouseup', this.upListener); globalState.addEventListener( 'touchmove', this.moveListener, { passive: false }, ); globalState.addEventListener('touchend', this.upListener); } private detachDragListeners(): void { if (this.moveListener != null) { globalState.removeEventListener('mousemove', this.moveListener); globalState.removeEventListener('touchmove', this.moveListener); } if (this.upListener != null) { globalState.removeEventListener('mouseup', this.upListener); globalState.removeEventListener('touchend', this.upListener); } this.moveListener = null; this.upListener = null; this.dragKind = null; } private handleOutsideMouseDown(e: MouseEvent): void { const root = this.$el as HTMLElement | null; if (root == null || !this.panelOpen) { return; } // Disabled while the panel was open — clear the flag so it does not pop // back open once the picker is enabled again. Rendering already ignores // it, so doing this on the next mousedown rather than on the prop change // itself costs nothing and keeps the render path free of extra passes. if (this.disabled || !root.contains(e.target as Node)) { this.panelOpen = false; } } private handleSwatchClick(e: MouseEvent): void { e.preventDefault(); if (this.disabled) { return; } this.panelOpen = !this.panelOpen; if (this.panelOpen) { (this.$refs.input as HTMLInputElement | undefined)?.focus(); } } private handleInputFocus(): void { if (this.disabled) { return; } this.panelOpen = true; } private handleInputBlur(_e: FocusEvent): void { // No-op for now — panel close is driven by the outside-mousedown handler // rather than blur, since focus may transiently leave the input as the // user drags the slider/grid handles inside the same wrapper. } private handleInputChange(e: Event): void { if (this.disabled) { return; } const newValue = (e.target as HTMLInputElement).value; this.applyValue(newValue); this.populateValidationDeclaration(); this.changed?.(this.hexValue); } private renderPicker(h: any): VNode { const isOpen = this.panelOpen && !this.disabled; const wrapperClass = [ 'minicolors', 'minicolors-theme-bootstrap', 'minicolors-position-bottom', 'minicolors-position-left', isOpen ? 'minicolors-focus' : '', this.disabled ? 'minicolors-disabled' : '', ].filter(Boolean).join(' '); return (
this.handleInputFocus()} onBlur={(e: FocusEvent) => this.handleInputBlur(e)} onChange={(e: Event) => this.handleInputChange(e)} onInput={(e: Event) => this.handleInputChange(e)} />
e.preventDefault()} >
this.startDrag('slider', e)} onTouchstart={(e: TouchEvent) => this.startDrag('slider', e)} >
this.startDrag('grid', e)} onTouchstart={(e: TouchEvent) => this.startDrag('grid', e)} >
this.handleSwatchClick(e)} >
); } render(h: any): VNode { return ( {this.renderPicker(h)} ); } } const ColorPicker = toNative(ColorPickerComponent); export default ColorPicker;