AI Floating Bubble

Adds a draggable floating AI bubble to all webpages with an updated list of AI sites appearing above it on hover, with a delay and fade-out on mouse leave. Prevents image dragging. The bubble will not appear in the opened AI popup windows.

当前为 2025-06-19 提交的版本,查看 最新版本

// ==UserScript==
// @name         AI Floating Bubble
// @version      1.0
// @description  Adds a draggable floating AI bubble to all webpages with an updated list of AI sites appearing above it on hover, with a delay and fade-out on mouse leave. Prevents image dragging. The bubble will not appear in the opened AI popup windows.
// @author       monit8280
// @match        *://*/*
// @grant        GM_addStyle
// @license      MIT
// @namespace    http://tampermonkey.net/
// ==/UserScript==

(function() {
    'use strict';

    // 현재 창이 AI 팝업 창인지 확인합니다.
    // URL에 'bubble_popup' 쿼리 파라미터가 있는지 검사하여 팝업 여부를 판단합니다.
    const urlParams = new URLSearchParams(window.location.search);
    const isAIPopup = urlParams.has('bubble_popup'); // 'ai_popup'에서 'bubble_popup'으로 변경됨

    // 현재 창이 AI 팝업 창으로 감지되면 버블을 초기화하지 않고 스크립트 실행을 종료합니다.
    if (isAIPopup) {
        console.log("AI 플로팅 버블: AI 팝업 창으로 감지되어 버블을 초기화하지 않습니다.");
        return; // 스크립트 실행을 중단하여 팝업 창에 버블이 나타나지 않도록 합니다.
    }

    /**
     * @class AIIcons
     * AI 사이트 아이콘 URL을 관리하는 클래스입니다.
     * 각 AI 서비스에 사용될 아이콘 이미지의 URL을 정의합니다.
     */
    class AIIcons {
        // 메인 버블 버튼에 사용될 아이콘 이미지 URL
        static get BUBBLE() { return "https://i.namu.wiki/i/LrJz7uHTAdFkV7Q0Cl4L8HPntexp6KUcqrZErhUrl-41Vk-IJ6n4K5TUQ_9WP0cNWECdZdegYID1KNnhHE7jX-xmjFFtpgozb7hTVlhwONvWu5lD_lF2hTF6Z0sktRBakk2-a-UCpeOn1Kx8Dn_Lg.webp"; }
        // Gemini AI 서비스 아이콘 URL
        static get GEMINI() { return "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg"; }
        // ChatGPT AI 서비스 아이콘 URL
        static get CHATGPT() { return "https://chatgpt.com/favicon.ico"; }
        // Claude AI 서비스 아이콘 URL
        static get CLAUDE() { return "https://claude.ai/favicon.ico"; }
        // Copilot AI 서비스 아이콘 URL
        static get COPILOT() { return "https://copilot.microsoft.com/favicon.ico"; }
        // Grok AI 서비스 아이콘 URL
        static get GROK() { return "https://grok.com/favicon.ico"; }
        // Perplexity AI 서비스 아이콘 URL
        static get PERPLEXITY() { return "https://www.perplexity.ai/favicon.ico"; }
        // 아이콘 로드 실패 시 표시될 대체 이미지 (Placeholder)
        static get PLACEHOLDER() { return "https://placehold.co/16x16/cccccc/000000?text=AI"; }
    }

    /**
     * @class AISites
     * AI 사이트 이름과 URL 목록을 관리하는 클래스입니다.
     * 이 목록은 플로팅 버블 메뉴에 표시될 AI 서비스들의 정보를 담고 있습니다.
     * 목록 순서는 AI 목록 표시 순서와 동일하게 유지됩니다.
     */
    class AISites {
        static get LIST() {
            return [
                { name: "Perplexity", url: "https://www.perplexity.ai/", icon: AIIcons.PERPLEXITY },
                { name: "Grok", url: "https://grok.com/", icon: AIIcons.GROK },
                { name: "Gemini", url: "https://gemini.google.com/", icon: AIIcons.GEMINI },
                { name: "Copilot", url: "https://copilot.microsoft.com/", icon: AIIcons.COPILOT },
                { name: "Claude", url: "https://claude.ai/", icon: AIIcons.CLAUDE },
                { name: "ChatGPT", url: "https://chatgpt.com/", icon: AIIcons.CHATGPT }
            ];
        }
    }

    /**
     * @class BubbleConfig
     * 버블의 크기, 간격, 애니메이션 타이밍 등 모든 숫자형 설정 값을 관리하는 클래스입니다.
     * 모든 단위는 픽셀(px) 또는 밀리초(ms)입니다.
     * 이 값을 조정하여 버블의 외형과 동작을 커스터마이징할 수 있습니다.
     */
    class BubbleConfig {
        static get BUBBLE_SIZE() { return 50; } // 버블 버튼의 너비와 높이 (px)
        static get OPTION_ICON_SIZE() { return 16; } // AI 목록 각 항목의 아이콘 크기 (px)
        static get OPTION_MENU_GAP() { return 10; } // 버블 버튼과 AI 목록 메뉴 사이의 간격 (px)
        static get OPTION_ITEM_PADDING_VERTICAL() { return 10; } // 각 AI 목록 항목의 상하 패딩 (px)
        static get OPTION_ITEM_PADDING_HORIZONTAL() { return 15; } // 각 AI 목록 항목의 좌우 패딩 (px)
        static get OPTION_ITEM_ICON_MARGIN_RIGHT() { return 10; } // AI 목록 항목의 아이콘과 텍스트 사이 간격 (px)
        static get OPTION_MENU_WIDTH() { return 150; } // AI 목록 메뉴의 고정 너비 (px)
        static get MENU_TRANSITION_DURATION() { return 0.3; } // 메뉴가 나타나고 사라지는 애니메이션 시간 (초)
        static get MENU_HIDE_DELAY() { return 200; } // 마우스가 메뉴에서 벗어난 후 메뉴가 숨겨지기까지의 지연 시간 (밀리초)
        static get POPUP_WINDOW_WIDTH() { return 800; } // 새 창으로 열릴 AI 사이트 팝업의 기본 너비 (px)
        static get POPUP_WINDOW_HEIGHT() { return 600; } // 새 창으로 열릴 AI 사이트 팝업의 기본 높이 (px)
    }

    /**
     * @class AIFloatingBubble
     * AI 플로팅 버블을 관리하는 메인 클래스입니다.
     * 이 클래스는 버블의 생성, 드래그 기능, AI 목록 메뉴 표시/숨김,
     * AI 사이트 클릭 시 새 창 열기 등의 모든 기능을 담당합니다.
     */
    class AIFloatingBubble {
        constructor() {
            // DOM 요소 참조 변수 초기화
            this.bubbleContainer = null; // 전체 버블 컨테이너 (드래그 가능 영역)
            this.bubbleButton = null;    // 버블 아이콘이 표시되는 버튼 영역
            this.siteOptions = null;     // AI 사이트 목록 메뉴 영역
            this.hideTimeout = null;     // 메뉴 숨김 지연을 위한 타이머 ID
            this.isDragging = false;     // 버블 드래그 중인지 여부
            this.offsetX = 0;            // 드래그 시작 시 마우스 X 오프셋
            this.offsetY = 0;            // 드래그 시작 시 마우스 Y 오프셋

            this._init(); // 클래스 초기화 메서드 호출
        }

        /**
         * @private
         * 초기화 메서드: DOM 요소 생성, 스타일 적용, 이벤트 리스너 설정.
         * 스크립트가 로드될 때 가장 먼저 호출됩니다.
         */
        _init() {
            this._createElements();     // 필요한 HTML 요소들을 생성하고 문서에 추가합니다.
            this._applyStyles();        // 생성된 요소들에 CSS 스타일을 적용합니다.
            this._setupEventListeners(); // 드래그, 호버, 클릭 등의 이벤트 리스너를 설정합니다.
        }

        /**
         * @private
         * DOM 요소를 생성하고 문서에 추가합니다.
         * 플로팅 버블의 구조 (컨테이너, 버튼, 옵션 메뉴)를 만듭니다.
         */
        _createElements() {
            // AI 플로팅 버블 컨테이너 요소 생성
            this.bubbleContainer = document.createElement('div');
            this.bubbleContainer.id = 'aiFloatingBubbleContainer';
            // 초기 위치 설정 (화면 우측 하단에 배치)
            this.bubbleContainer.style.bottom = `${BubbleConfig.OPTION_ITEM_PADDING_VERTICAL * 2}px`; // 하단 패딩 확보
            this.bubbleContainer.style.right = `${BubbleConfig.OPTION_ITEM_PADDING_HORIZONTAL}px`;   // 우측 패딩 확보
            document.body.appendChild(this.bubbleContainer); // body에 컨테이너 추가

            // 플로팅 버블 버튼 요소 (메인 아이콘) 생성
            this.bubbleButton = document.createElement('div');
            this.bubbleButton.id = 'aiFloatingBubbleButton';
            // 버블 아이콘 이미지를 설정합니다.
            this.bubbleButton.innerHTML = `
                <img src="${AIIcons.BUBBLE}" alt="AI 아이콘" style="width: ${BubbleConfig.BUBBLE_SIZE * (2/3)}px; height: ${BubbleConfig.BUBBLE_SIZE * (2/3)}px;">
            `;
            this.bubbleContainer.appendChild(this.bubbleButton); // 컨테이너 안에 버튼 추가

            // AI 사이트 선택지 메뉴 요소 생성
            this.siteOptions = document.createElement('div');
            this.siteOptions.id = 'aiSiteOptions';

            // AISites 클래스에서 AI 목록을 가져와 메뉴 HTML을 동적으로 생성합니다.
            let optionsHtml = '';
            AISites.LIST.forEach(site => {
                optionsHtml += `
                    <div class="ai-option" data-url="${site.url}">
                        <img src="${site.icon}" alt="${site.name} 아이콘" class="option-icon" onerror="this.onerror=null;this.src='${AIIcons.PLACEHOLDER}';">
                        <span>${site.name}</span>
                    </div>
                `;
            });
            this.siteOptions.innerHTML = optionsHtml; // 생성된 HTML을 메뉴에 삽입
            this.bubbleContainer.appendChild(this.siteOptions); // 컨테이너 안에 메뉴 추가
        }

        /**
         * @private
         * 필요한 CSS 스타일을 문서에 동적으로 추가합니다.
         * Tampermonkey의 GM_addStyle 함수를 사용하여 전역 스타일을 적용합니다.
         * 모든 크기 관련 값은 BubbleConfig 클래스에서 가져옵니다.
         */
        _applyStyles() {
            GM_addStyle(`
                /* 플로팅 버블 전체 컨테이너 스타일 */
                #aiFloatingBubbleContainer {
                    position: fixed; /* 화면에 고정 */
                    z-index: 9999;   /* 다른 요소 위에 표시 */
                    width: ${BubbleConfig.BUBBLE_SIZE}px;
                    height: ${BubbleConfig.BUBBLE_SIZE}px;
                    cursor: grab;    /* 드래그 가능함을 나타내는 커서 */
                }

                /* 드래그 중일 때의 커서 스타일 */
                #aiFloatingBubbleContainer.grabbing {
                    cursor: grabbing;
                }

                /* 플로팅 버블 버튼 (원형 아이콘) 스타일 */
                #aiFloatingBubbleButton {
                    position: absolute; /* 컨테이너 내에서 절대 위치 */
                    bottom: 0;
                    right: 0;
                    background-color: #fff; /* 흰색 배경 */
                    border: 1px solid #ccc; /* 옅은 회색 테두리 */
                    border-radius: 50%;     /* 원형 모양 */
                    width: ${BubbleConfig.BUBBLE_SIZE}px;
                    height: ${BubbleConfig.BUBBLE_SIZE}px;
                    display: flex;          /* 내부 아이콘 중앙 정렬 */
                    justify-content: center;
                    align-items: center;
                    cursor: pointer;        /* 클릭 가능함을 나타내는 커서 */
                    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); /* 그림자 효과 */
                    transition: transform 0.2s; /* 호버 시 변형 애니메이션 */
                }

                /* 버블 버튼 내부 이미지 스타일 */
                #aiFloatingBubbleButton img {
                    user-select: none;     /* 드래그 방지 */
                    -webkit-user-drag: none; /* 드래그 방지 (웹킷 브라우저) */
                    pointer-events: none;  /* 이미지 클릭 시 버튼 이벤트 발생 */
                }

                /* 버블 버튼 호버 시 확대 효과 */
                #aiFloatingBubbleButton:hover {
                    transform: scale(1.1); /* 10% 확대 */
                }

                /* AI 사이트 옵션 메뉴 스타일 */
                #aiSiteOptions {
                    position: absolute; /* 컨테이너 내에서 절대 위치 */
                    bottom: ${BubbleConfig.BUBBLE_SIZE + BubbleConfig.OPTION_MENU_GAP}px; /* 버블 위에 배치 */
                    right: 0;
                    flex-direction: column; /* 세로 정렬 */
                    background-color: #fff; /* 흰색 배경 */
                    border: 1px solid #eee; /* 옅은 회색 테두리 */
                    border-radius: 8px;     /* 둥근 모서리 */
                    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); /* 그림자 효과 */
                    overflow: hidden;       /* 내용이 넘칠 경우 숨김 */
                    white-space: nowrap;    /* 줄바꿈 방지 */

                    max-height: 0;          /* 기본적으로 숨김 (높이 0) */
                    opacity: 0;             /* 투명도 0 */
                    pointer-events: none;   /* 클릭 이벤트 비활성화 */
                    transition: max-height ${BubbleConfig.MENU_TRANSITION_DURATION}s ease-out, opacity ${BubbleConfig.MENU_TRANSITION_DURATION}s ease-out; /* 나타나고 사라지는 애니메이션 */
                }

                /* AI 사이트 옵션 메뉴가 보일 때의 스타일 */
                #aiSiteOptions.visible {
                    max-height: 500px; /* 메뉴 내용이 모두 보이도록 충분히 큰 값 설정 */
                    opacity: 1;        /* 완전히 불투명하게 */
                    pointer-events: auto; /* 클릭 이벤트 활성화 */
                    transition: max-height ${BubbleConfig.MENU_TRANSITION_DURATION}s ease-out, opacity ${BubbleConfig.MENU_TRANSITION_DURATION}s ease-out; /* 나타나고 사라지는 애니메이션 */
                }

                /* 각 AI 사이트 옵션 항목 스타일 */
                .ai-option {
                    display: flex;        /* 내부 아이콘과 텍스트 정렬 */
                    align-items: center;
                    padding: ${BubbleConfig.OPTION_ITEM_PADDING_VERTICAL}px ${BubbleConfig.OPTION_ITEM_PADDING_HORIZONTAL}px; /* 패딩 */
                    cursor: pointer;      /* 클릭 가능함을 나타내는 커서 */
                    border-bottom: 1px solid #f0f0f0; /* 하단 구분선 */
                    transition: background-color 0.2s; /* 호버 시 배경색 변경 애니메이션 */
                    width: ${BubbleConfig.OPTION_MENU_WIDTH}px; /* 메뉴 항목의 고정 너비 */
                }

                /* 마지막 메뉴 항목의 하단 구분선 제거 */
                .ai-option:last-child {
                    border-bottom: none;
                }

                /* 메뉴 항목 호버 시 배경색 변경 */
                .ai-option:hover {
                    background-color: #f5f5f5;
                }

                /* 메뉴 항목 내부 아이콘 스타일 */
                .ai-option .option-icon {
                    width: ${BubbleConfig.OPTION_ICON_SIZE}px;
                    height: ${BubbleConfig.OPTION_ICON_SIZE}px;
                    margin-right: ${BubbleConfig.OPTION_ITEM_ICON_MARGIN_RIGHT}px; /* 아이콘과 텍스트 사이 간격 */
                    border-radius: 2px;    /* 살짝 둥근 모서리 */
                    vertical-align: middle;
                    user-select: none;
                    -webkit-user-drag: none;
                    pointer-events: none;
                }

                /* 메뉴 항목 내부 텍스트 (AI 이름) 스타일 */
                .ai-option span {
                    font-family: 'Inter', sans-serif; /* Inter 폰트 사용 (폴백: sans-serif) */
                    font-size: 14px; /* 폰트 크기 */
                    color: #333;     /* 어두운 회색 폰트 색상 */
                    font-weight: normal; /* 보통 굵기 */
                }
            `);
        }

        /**
         * @private
         * 모든 이벤트 리스너를 설정합니다.
         * 드래그, 호버, 클릭 관련 이벤트를 등록합니다.
         */
        _setupEventListeners() {
            this._setupDrag();  // 버블 드래그 기능 설정
            this._setupHover(); // AI 목록 표시/숨김 호버 기능 설정
            this._setupClick(); // AI 사이트 옵션 클릭 기능 설정
        }

        /**
         * @private
         * 버블 드래그 기능을 설정합니다.
         * 마우스 다운, 이동, 업 이벤트를 사용하여 버블을 드래그 가능하게 합니다.
         */
        _setupDrag() {
            this.bubbleContainer.addEventListener('mousedown', (e) => {
                // AI 옵션 메뉴 자체를 드래그하는 것은 방지합니다.
                // 만약 클릭된 요소가 '.ai-option' 클래스를 포함한다면 드래그를 시작하지 않습니다.
                if (e.target.closest('.ai-option')) {
                    return;
                }

                this.isDragging = true; // 드래그 시작 플래그 설정
                this.bubbleContainer.classList.add('grabbing'); // 드래그 중임을 나타내는 클래스 추가

                // 마우스 포인터와 버블 컨테이너의 좌상단 모서리 간의 오프셋을 계산합니다.
                // 이는 드래그 시작 시 마우스 위치와 버블 위치의 차이를 기억하여 자연스러운 드래그를 가능하게 합니다.
                this.offsetX = e.clientX - this.bubbleContainer.getBoundingClientRect().left;
                this.offsetY = e.clientY - this.bubbleContainer.getBoundingClientRect().top;
            });

            document.addEventListener('mousemove', (e) => {
                // 드래그 중이 아니면 함수를 종료합니다.
                if (!this.isDragging) return;

                // 새로운 버블 위치 계산
                let newLeft = e.clientX - this.offsetX;
                let newTop = e.clientY - this.offsetY;

                // 화면 경계를 벗어나지 않도록 버블 위치를 제한합니다.
                const maxX = window.innerWidth - this.bubbleContainer.offsetWidth;  // 최대 X 좌표
                const maxY = window.innerHeight - this.bubbleContainer.offsetHeight; // 최대 Y 좌표

                newLeft = Math.max(0, Math.min(newLeft, maxX)); // X 좌표를 0과 maxX 사이로 제한
                newTop = Math.max(0, Math.min(newTop, maxY));   // Y 좌표를 0과 maxY 사이로 제한

                // 계산된 위치를 버블 컨테이너에 적용합니다.
                this.bubbleContainer.style.left = `${newLeft}px`;
                this.bubbleContainer.style.top = `${newTop}px`;
                // left/top을 사용할 때는 기존 right/bottom 속성을 초기화하여 충돌을 방지합니다.
                this.bubbleContainer.style.right = 'auto';
                this.bubbleContainer.style.bottom = 'auto';
            });

            document.addEventListener('mouseup', () => {
                this.isDragging = false; // 드래그 종료 플래그 설정
                this.bubbleContainer.classList.remove('grabbing'); // 드래그 중임을 나타내는 클래스 제거
            });
        }

        /**
         * @private
         * AI 목록 표시/숨김 호버 기능을 설정합니다.
         * 마우스가 버블 위에 있을 때 메뉴를 표시하고, 벗어났을 때 지연 후 숨깁니다.
         */
        _setupHover() {
            this.bubbleContainer.addEventListener('mouseenter', () => {
                clearTimeout(this.hideTimeout); // 숨김 타이머가 설정되어 있다면 취소합니다.
                this.siteOptions.classList.add('visible'); // AI 목록 메뉴를 표시합니다.
            });

            this.bubbleContainer.addEventListener('mouseleave', () => {
                // 마우스가 벗어난 후 일정 지연 시간(MENU_HIDE_DELAY) 후에 메뉴를 숨깁니다.
                // 이 지연 시간 동안 마우스가 다시 들어오면 숨김이 취소됩니다.
                this.hideTimeout = setTimeout(() => {
                    this.siteOptions.classList.remove('visible'); // AI 목록 메뉴를 숨깁니다.
                }, BubbleConfig.MENU_HIDE_DELAY); // BubbleConfig에서 지연 시간 가져옴
            });
        }

        /**
         * @private
         * AI 사이트 옵션 클릭 기능을 설정합니다.
         * AI 목록에서 특정 AI 사이트를 클릭하면 새 팝업 창으로 해당 사이트를 엽니다.
         */
        _setupClick() {
            this.siteOptions.addEventListener('click', (event) => {
                // 클릭된 요소가 '.ai-option' 클래스를 가진 가장 가까운 부모 요소를 찾습니다.
                const option = event.target.closest('.ai-option');
                if (option) {
                    let url = option.dataset.url; // 클릭된 옵션의 'data-url' 속성에서 URL을 가져옵니다.
                    if (url) {
                        // 새 팝업 창임을 나타내는 쿼리 파라미터 'bubble_popup=true'를 URL에 추가합니다.
                        // 이 파라미터는 팝업 창에서 버블이 나타나지 않도록 하는 데 사용됩니다.
                        url += (url.includes('?') ? '&' : '?') + 'bubble_popup=true';

                        const windowName = 'AIFloatingWindow'; // 팝업 창의 이름 설정 (동일한 이름으로 열면 기존 창 재활용)
                        // 팝업 창의 특징(너비, 높이, 메뉴바, 툴바 등)을 BubbleConfig에서 가져와 설정합니다.
                        const features = `width=${BubbleConfig.POPUP_WINDOW_WIDTH},height=${BubbleConfig.POPUP_WINDOW_HEIGHT},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`;

                        window.open(url, windowName, features); // 새 팝업 창을 엽니다.
                    }
                }
            });
        }
    }

    // 스크립트 실행 시 AI 플로팅 버블 인스턴스를 생성하여 모든 기능을 시작합니다.
    new AIFloatingBubble();
})();