// ==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();
})();