/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import KTComponent from '../component'; import KTData from '../../helpers/data'; import KTDom from '../../helpers/dom'; import { KTClipboardActionType, KTClipboardConfigInterface, KTClipboardInterface, } from './types'; type KTClipboardEventPayload = { action: KTClipboardActionType; text: string | null; target: string | null; error?: string; }; declare global { interface Window { KTClipboard: typeof KTClipboard; } } export class KTClipboard extends KTComponent implements KTClipboardInterface { protected override _name: string = 'clipboard'; protected override _defaultConfig: KTClipboardConfigInterface = { target: '', text: '', action: 'copy', copiedClass: '', successEvent: 'kt.clipboard.success', errorEvent: 'kt.clipboard.error', }; protected override _config: KTClipboardConfigInterface = this ._defaultConfig as KTClipboardConfigInterface; private _activateHandler: ((event: Event) => void) | null = null; constructor( element: HTMLElement, config: KTClipboardConfigInterface | null = null, ) { super(); // Ensure we don't double bind handlers on the same trigger. if (this._shouldSkipInit(element)) { return; } this._init(element); this._buildConfig(config); if (!this._element) return; this._activateHandler = this._handleActivate.bind(this); this._element.addEventListener('click', this._activateHandler); } private _getSuccessEventName(): string { const eventName = this._getOption('successEvent'); return typeof eventName === 'string' && eventName.length > 0 ? eventName : 'kt.clipboard.success'; } private _getErrorEventName(): string { const eventName = this._getOption('errorEvent'); return typeof eventName === 'string' && eventName.length > 0 ? eventName : 'kt.clipboard.error'; } private _getAction(): KTClipboardActionType { const action = this._getOption('action'); return action === 'cut' ? 'cut' : 'copy'; } private _getTrimmedString(optionValue: unknown): string { return typeof optionValue === 'string' ? optionValue.trim() : ''; } private _getPredefinedText(): string { const text = this._getOption('text'); return this._getTrimmedString(text); } private _getTargetSelector(): string { const target = this._getOption('target'); return this._getTrimmedString(target); } private _getCopiedClass(): string { const copiedClass = this._getOption('copiedClass'); return this._getTrimmedString(copiedClass); } private _setCopiedClass(shouldSet: boolean): void { const copiedClass = this._getCopiedClass(); if (!copiedClass || !this._element) return; // Keep deterministic behavior: remove any previous state before toggling. KTDom.removeClass(this._element, copiedClass); if (shouldSet) { KTDom.addClass(this._element, copiedClass); } } private _isInputLike(element: HTMLElement): boolean { return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'; } private _readTargetValue(target: HTMLElement): string { if (this._isInputLike(target)) { return (target as HTMLInputElement | HTMLTextAreaElement).value ?? ''; } return target.textContent ?? ''; } private _execCommandCopy(text: string): boolean { const textarea = document.createElement('textarea'); textarea.value = text; // Avoid scrolling to bottom on iOS/Safari and keep it out of layout. textarea.style.position = 'fixed'; textarea.style.top = '0'; textarea.style.left = '0'; textarea.style.opacity = '0'; textarea.style.pointerEvents = 'none'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); // Some browsers require explicit range selection. textarea.setSelectionRange(0, textarea.value.length); try { return document.execCommand('copy'); } catch { return false; } finally { textarea.remove(); } } private async _writeText(text: string): Promise { // Native Clipboard API (requires secure context). const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : null; const writeText = clipboard && typeof clipboard.writeText === 'function' ? clipboard.writeText.bind(clipboard) : null; if (writeText) { await writeText(text); return true; } const ok = this._execCommandCopy(text); if (!ok) { throw new Error('Clipboard copy failed.'); } return true; } private async _handleActivate(event: Event): Promise { event.preventDefault(); const action = this._getAction(); const successEventName = this._getSuccessEventName(); const errorEventName = this._getErrorEventName(); const textFromConfig = this._getPredefinedText(); const targetSelector = this._getTargetSelector(); const hasPredefinedText = Boolean( this._element?.hasAttribute('data-kt-clipboard-text'), ); // Deterministic precedence: // - If `data-kt-clipboard-text` attribute is present, it wins over target. if (hasPredefinedText) { let targetElForCut: HTMLElement | null = null; // `cut` requires an editable target (input/textarea) even when predefined text is used. if (action === 'cut') { if (!targetSelector) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: null, error: 'Cut action requires data-kt-clipboard-target pointing to an input/textarea.', }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); return; } targetElForCut = KTDom.getElement(targetSelector) as HTMLElement | null; if (!targetElForCut || !this._isInputLike(targetElForCut)) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: targetSelector, error: 'Cut action is only supported for input/textarea targets.', }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); return; } } // Treat empty/whitespace-only predefined text as invalid. if (!textFromConfig) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: targetSelector || null, error: 'Predefined clipboard text is empty.', }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); return; } try { await this._writeText(textFromConfig); // For `cut`, clear the editable target after successful clipboard write. if (action === 'cut' && targetElForCut) { (targetElForCut as HTMLInputElement | HTMLTextAreaElement).value = ''; targetElForCut.dispatchEvent(new Event('input', { bubbles: true })); } this._setCopiedClass(true); const payload: KTClipboardEventPayload = { action, text: textFromConfig, target: targetSelector || null, }; this._fireEvent(successEventName, payload); this._dispatchEvent(successEventName, payload); } catch (error) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: textFromConfig, target: targetSelector || null, error: error instanceof Error ? error.message : String(error ?? 'Unknown'), }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); } return; } // No usable predefined text; copy from target. if (!targetSelector) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: null, error: 'Missing clipboard source (provide data-kt-clipboard-text or data-kt-clipboard-target).', }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); return; } const targetEl = KTDom.getElement(targetSelector) as HTMLElement | null; if (!targetEl) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: targetSelector, error: `Clipboard target not found: ${targetSelector}`, }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); return; } const value = this._readTargetValue(targetEl).trim(); if (!value) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: targetSelector, error: 'Target content is empty.', }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); return; } // Cut action: only allowed for input/textarea. if (action === 'cut' && !this._isInputLike(targetEl)) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: targetSelector, error: 'Cut action is only supported for input/textarea targets.', }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); return; } try { await this._writeText(value); this._setCopiedClass(true); if (action === 'cut') { (targetEl as HTMLInputElement | HTMLTextAreaElement).value = ''; targetEl.dispatchEvent(new Event('input', { bubbles: true })); } const payload: KTClipboardEventPayload = { action, text: value, target: targetSelector, }; this._fireEvent(successEventName, payload); this._dispatchEvent(successEventName, payload); } catch (error) { this._setCopiedClass(false); const payload: KTClipboardEventPayload = { action, text: null, target: targetSelector, error: error instanceof Error ? error.message : String(error ?? 'Unknown'), }; this._fireEvent(errorEventName, payload); this._dispatchEvent(errorEventName, payload); } } public override dispose(): void { if (this._element && this._activateHandler) { this._element.removeEventListener('click', this._activateHandler); } this._activateHandler = null; super.dispose(); } public static getInstance(element: HTMLElement): KTClipboard | null { if (!element) return null; if (KTData.has(element, 'clipboard')) { return KTData.get(element, 'clipboard') as KTClipboard; } return null; } public static getOrCreateInstance( element: HTMLElement, config?: KTClipboardConfigInterface, ): KTClipboard { return ( this.getInstance(element) || new KTClipboard(element, config ?? undefined) ); } public static createInstances(): void { document.querySelectorAll('[data-kt-clipboard]').forEach((el) => { new KTClipboard(el as HTMLElement); }); } public static init(): void { KTClipboard.createInstances(); } } if (typeof window !== 'undefined') { window.KTClipboard = KTClipboard; }