Gemini Canvas Markdown Copy

Gemini Canvas에서 나온 Markdown 문서를 복사하기 쉽게해줌

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

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

})();