ChatGPT Character Counter

Adds a live character counter to the ChatGPT message composer.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ChatGPT Character Counter
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  Adds a live character counter to the ChatGPT message composer.
// @author       Emree.el on Instagram
// @match        https://chatgpt.com/*
// @match        https://www.chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        none
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const LIMIT = 32732;
    const COUNTER_ID = 'emree-chatgpt-char-counter';

    let editor = null;
    let counter = null;

    function findEditor() {
        // Current ChatGPT editor.
        const proseMirror = document.querySelector(
            '#prompt-textarea.ProseMirror[contenteditable="true"]'
        );

        if (proseMirror) return proseMirror;

        // Fallback if ChatGPT changes the class structure.
        const editable = document.querySelector(
            '#prompt-textarea[contenteditable="true"]'
        );

        if (editable) return editable;

        // Older/fallback textarea.
        return document.querySelector(
            'textarea[name="prompt-textarea"]'
        );
    }

    function getCharacterCount() {
        if (!editor) return 0;

        if (editor instanceof HTMLTextAreaElement) {
            return editor.value.length;
        }

        // ProseMirror / contenteditable editor.
        return (editor.textContent || '').length;
    }

    function updateCounter() {
        if (!editor || !counter || !editor.isConnected) return;

        const count = getCharacterCount();

        counter.textContent =
            `${count.toLocaleString()}/${LIMIT.toLocaleString()}`;

        if (count > LIMIT) {
            counter.style.color = '#ff5555';
            counter.style.textShadow = '0 0 8px rgba(255, 0, 0, 0.7)';
        } else if (count > 0) {
            counter.style.color = '#55ff88';
            counter.style.textShadow = '0 0 8px rgba(0, 255, 100, 0.45)';
        } else {
            counter.style.color = '';
            counter.style.textShadow = 'none';
        }
    }

    function attach(editorElement) {
        // Already attached to this editor.
        if (editor === editorElement && counter?.isConnected) {
            updateCounter();
            return;
        }

        // Remove old counter.
        document.getElementById(COUNTER_ID)?.remove();

        editor = editorElement;

        counter = document.createElement('div');
        counter.id = COUNTER_ID;

        Object.assign(counter.style, {
            fontSize: '12px',
            fontWeight: '600',
            marginTop: '4px',
            paddingRight: '8px',
            textAlign: 'right',
            opacity: '0.8',
            pointerEvents: 'none',
            userSelect: 'none'
        });

        const form = editor.closest('form');

        if (form) {
            form.insertAdjacentElement('afterend', counter);
        } else {
            editor.insertAdjacentElement('afterend', counter);
        }

        // The main event: catches typing, deleting and pasting.
        editor.addEventListener('input', () => {
            updateCounter();

            // Let ProseMirror/React finish any DOM updates.
            requestAnimationFrame(updateCounter);
        });

        // Extra fallback for keyboard input.
        editor.addEventListener('keyup', () => {
            requestAnimationFrame(updateCounter);
        });

        // Extra fallback for paste.
        editor.addEventListener('paste', () => {
            requestAnimationFrame(() => {
                requestAnimationFrame(updateCounter);
            });
        });

        updateCounter();
    }

    function checkEditor() {
        const found = findEditor();

        if (!found) return;

        if (found !== editor || !counter?.isConnected) {
            attach(found);
        }
    }

    // Very lightweight SPA check.
    setInterval(checkEditor, 1000);

    checkEditor();

})();