Gemini Canvas에서 나온 Markdown 문서를 복사하기 쉽게해줌
// ==UserScript==
// @name Gemini Canvas Markdown Copy
// @namespace https://explainpark101.github.io/html-to-md/gemini-canvas
// @version 1.0.1
// @description Gemini Canvas에서 나온 Markdown 문서를 복사하기 쉽게해줌
// @match https://gemini.google.com/*
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// [User Preference] GM_xmlhttpRequest를 fetch처럼 사용하는 래퍼 함수
const gmFetch = (url, options = {}) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
url: url,
method: options.method || 'GET',
headers: options.headers || {},
data: options.body,
onload: (response) => {
resolve({
ok: response.status >= 200 && response.status < 300,
status: response.status,
text: () => Promise.resolve(response.responseText),
json: () => Promise.resolve(JSON.parse(response.responseText))
});
},
onerror: reject
});
});
};
// 타겟 셀렉터 정의
const TARGET_SELECTOR = ':is(message-content#extended-response-message-content, immersive-editor#extended-response-message-content) .markdown';
// Trusted Types 정책 설정 (보안 정책 우회용)
let trustedPolicy;
if (typeof trustedTypes !== 'undefined' && trustedTypes.createPolicy) {
try {
trustedPolicy = trustedTypes.createPolicy('markdown-copy-policy', {
createHTML: (string) => string
});
} catch (e) {
// 이미 동일한 이름의 정책이 존재하거나 생성 실패 시 폴백
trustedPolicy = { createHTML: (string) => string };
}
} else {
trustedPolicy = { createHTML: (string) => string };
}
// 1. 문자열을 파싱하여 DOM 객체의 body 반환
function parseHTML(htmlString) {
const parser = new DOMParser();
// TrustedHTML 정책을 적용하여 안전한 문자열로 변환 후 파싱
const safeHTML = trustedPolicy.createHTML(htmlString);
const doc = parser.parseFromString(safeHTML, 'text/html');
return doc.body;
}
// 2. 재귀적으로 노드를 탐색하며 마크다운 텍스트 생성
function convertNodeToMarkdown(node) {
let markdown = "";
// 현재 노드의 모든 자식 노드 순회
node.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
// 텍스트 노드인 경우 연속된 공백을 하나로 압축하여 추가
markdown += child.textContent.replace(/\s+/g, ' ');
}
else if (child.nodeType === Node.ELEMENT_NODE) {
// 요소 노드인 경우 태그 종류 확인
const tag = child.tagName.toLowerCase();
// 내부 자식들도 변환하기 위해 재귀 호출
const innerMarkdown = convertNodeToMarkdown(child);
// 태그별 마크다운 기호 매핑
switch (tag) {
case 'h1':
markdown += `\n# ${innerMarkdown}\n`;
break;
case 'p':
markdown += `\n${innerMarkdown}\n`;
break;
case 'strong':
case 'b':
markdown += `**${innerMarkdown}**`;
break;
case 'a':
markdown += `[${innerMarkdown}](${child.getAttribute('href')})`;
break;
default:
// 매핑되지 않은 태그는 내부 텍스트만 유지
markdown += innerMarkdown;
}
}
});
return markdown;
}
// 3. 스타일 정의 (쫀득한 cubic-bezier 애니메이션 추가)
GM_addStyle(`
#custom-floating-copy-btn {
position: fixed;
z-index: 999999;
background-color: #007bff;
color: white;
border: none;
border-radius: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
font-size: 14px;
font-weight: bold;
cursor: pointer;
user-select: none;
touch-action: none;
display: none; /* 기본적으로 숨김 처리 */
opacity: 0;
transform: scale(0.5);
pointer-events: none; /* 숨겨져 있거나 사라지는 중일 때 이벤트 차단 */
/* 나타나고 사라질 때의 쫀득한 타이밍 함수 적용 */
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden; /* 자식 요소의 둥근 모서리 넘침 방지 */
}
#custom-floating-copy-btn.visible {
opacity: 1;
transform: scale(1);
pointer-events: auto; /* 완전히 보일 때만 이벤트 허용 */
}
#custom-floating-copy-btn.visible:active {
transform: scale(0.95);
transition: transform 0.1s; /* 클릭 시에는 빠른 반응 */
}
.custom-btn-half {
padding: 10px 16px;
background-color: transparent;
color: white;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: bold;
outline: none;
}
.custom-btn-half:first-child {
border-right: 1px solid rgba(255,255,255,0.3);
}
.custom-btn-half:hover {
background-color: rgba(255,255,255,0.1);
}
`);
// 4. 버튼 요소 생성
const btn = document.createElement('div');
btn.id = 'custom-floating-copy-btn';
const copyBtn = document.createElement('button');
copyBtn.className = 'custom-btn-half';
copyBtn.innerText = 'Copy HTML';
copyBtn.dataset.action = 'copy';
const toMdBtn = document.createElement('button');
toMdBtn.className = 'custom-btn-half';
toMdBtn.innerText = 'to Markdown';
toMdBtn.dataset.action = 'tomd';
btn.appendChild(copyBtn);
btn.appendChild(toMdBtn);
// 5. 로컬 스토리지에서 초기 위치 불러오기 (기본값 설정)
const savedX = localStorage.getItem('floatingBtnX') || '5vw';
const savedY = localStorage.getItem('floatingBtnY') || '90vh';
btn.style.left = savedX;
btn.style.top = savedY;
document.body.appendChild(btn);
// 6. 드래그 및 클릭 로직
let isDragging = false;
let isLongPress = false;
let longPressTimer;
let startX, startY;
let initialLeft, initialTop;
let isButtonActive = false; // 버튼 활성화 상태
let clickTarget = null; // 클릭한 내부 버튼 추적
btn.addEventListener('pointerdown', (e) => {
if (!isButtonActive) return; // 활성화 상태가 아니면 무시
isDragging = false;
isLongPress = false;
startX = e.clientX;
startY = e.clientY;
clickTarget = e.target.closest('.custom-btn-half');
const rect = btn.getBoundingClientRect();
initialLeft = e.clientX - rect.left;
initialTop = e.clientY - rect.top;
// 300ms 동안 누르고 있으면 드래그 모드 진입
longPressTimer = setTimeout(() => {
if (!isButtonActive) return;
isLongPress = true;
btn.style.opacity = '0.8';
btn.style.cursor = 'grabbing';
}, 300);
btn.setPointerCapture(e.pointerId);
});
btn.addEventListener('pointermove', (e) => {
if (!isButtonActive) return;
if (!isLongPress) {
// 움직임이 감지되면 클릭(혹은 짧은 터치)으로 간주하여 롱프레스 취소
if (Math.abs(e.clientX - startX) > 5 || Math.abs(e.clientY - startY) > 5) {
clearTimeout(longPressTimer);
}
return;
}
isDragging = true;
let newX = e.clientX - initialLeft;
let newY = e.clientY - initialTop;
// px을 vw, vh로 변환
let vw = (newX / window.innerWidth) * 100;
let vh = (newY / window.innerHeight) * 100;
btn.style.left = `${vw}vw`;
btn.style.top = `${vh}vh`;
});
btn.addEventListener('pointerup', (e) => {
if (!isButtonActive) return;
clearTimeout(longPressTimer);
btn.releasePointerCapture(e.pointerId);
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
if (isDragging) {
// 드래그가 끝났을 때 위치 저장
localStorage.setItem('floatingBtnX', btn.style.left);
localStorage.setItem('floatingBtnY', btn.style.top);
} else if (!isLongPress && clickTarget) {
// 드래그하지 않고 짧게 눌렀을 때 분기 처리
if (clickTarget.dataset.action === 'copy') {
copyContent();
} else if (clickTarget.dataset.action === 'tomd') {
const target = document.querySelector(TARGET_SELECTOR);
if (target) {
// 새 창 열기
const newWin = window.open('https://explainpark101.github.io/html-to-md', '_blank');
if (newWin) {
// 새 창이 로드된 후 메시지를 받을 수 있도록 대기 (1000ms)
setTimeout(() => {
newWin.postMessage(
{ type: 'html-to-md', html: target.innerHTML },
'https://explainpark101.github.io' // 보안을 위해 특정 origin 지정
);
}, 1000);
}
} else {
showFeedback('Not Found', clickTarget);
}
}
}
isDragging = false;
isLongPress = false;
clickTarget = null;
});
// 7. 복사 함수 (마크다운 변환 적용)
function copyContent() {
const target = document.querySelector(TARGET_SELECTOR);
if (target) {
const domBody = parseHTML(target.innerHTML);
const markdownResult = convertNodeToMarkdown(domBody).trim();
GM_setClipboard(markdownResult, 'text');
showFeedback('Copied!', copyBtn);
} else {
showFeedback('Not Found', copyBtn);
}
}
// 8. 시각적 피드백
function showFeedback(msg, btnElement = copyBtn) {
const originalText = btnElement.innerText;
btnElement.innerText = msg;
setTimeout(() => {
btnElement.innerText = originalText;
}, 1500);
}
// 9. Observer를 통한 동적 표시 및 애니메이션 로직
let hideTimeout;
function toggleButtonVisibility() {
const targetExists = document.querySelector(TARGET_SELECTOR) !== null;
if (targetExists && !isButtonActive) {
// 나타날 때
isButtonActive = true;
clearTimeout(hideTimeout);
btn.style.display = 'flex'; // display 먼저 적용 (block -> flex로 변경)
// reflow 강제 발생 후 클래스 추가 (애니메이션 트리거)
void btn.offsetWidth;
btn.classList.add('visible');
} else if (!targetExists && isButtonActive) {
// 사라질 때
isButtonActive = false;
btn.classList.remove('visible'); // 투명해지고 작아지는 애니메이션 시작
// 애니메이션(0.4s)이 끝난 후 완전히 display none 처리
hideTimeout = setTimeout(() => {
if (!isButtonActive) {
btn.style.display = 'none';
}
}, 400);
}
}
// 초기 상태 확인
toggleButtonVisibility();
// DOM 변화 감지 Observer 설정
const observer = new MutationObserver(() => {
toggleButtonVisibility();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();