ChatGPT Clipboard Manager

Modern simple clipboard manager for ChatGPT with persistent storage

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==UserScript==
// @name         ChatGPT Clipboard Manager
// @namespace    http://tampermonkey.net/
// @version      0.1.2
// @description  Modern simple clipboard manager for ChatGPT with persistent storage
// @author       OutlawRGB
// @match        *://chatgpt.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        MAX_ITEMS: 100,
        MAX_FAVORITES: 10,
        TOAST_DURATION: 2000,
        CONFIRM_TIMEOUT: 5000,
        STORAGE_KEY: 'chatgpt-clips',
    };

    const styles = `
        .clip-manager, .clip-toggle, .clip-content, .clip-bottom-actions,
        .clip-header, .clip-title, .clip-title-wrapper, .clip-title-display,
        .clip-close, .clip-toast, .clip-card, .clip-preview,
        .clip-actions, .clip-btn, .clip-save, .clip-save-clipboard,
        .clip-clear-all, .clip-search, .clip-controls, .clip-empty,
        .clip-title-input, .clip-favorite, .clip-edit-icon, .clip-save-icon {
            transition: all 0.2s ease;
        }

        .clip-manager {
            position: fixed;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            width: 600px;
            height: 700px;
            background: #1a1b1e;
            border-radius: 16px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
            z-index: 10000;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            display: flex;
            flex-direction: column;
            opacity: 0;
            visibility: hidden;
            user-select: none;
        }

        .clip-manager.open {
            opacity: 1;
            visibility: visible;
        }

        .clip-toggle {
            position: fixed;
            right: 340px;
            top: 10px;
            background: #2c2d31;
            border: none;
            border-radius: 12px;
            padding: 12px;
            cursor: pointer;
            z-index: 10001;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .clip-toggle.hidden {
            opacity: 0;
            visibility: hidden;
            pointer-events: none;
        }

        .clip-toggle:hover {
            transform: scale(1.05);
            background: #3a3b3f;
        }

        .clip-header {
            padding: 20px;
            color: #fff;
            border-bottom: 1px solid #2c2d31;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .clip-title {
            margin: 0;
            font-size: 18px;
            font-weight: 600;
        }

        .clip-title-display {
            font-size: 16px;
            color: #fff;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
            max-width: 100%;
        }

        .clip-title-wrapper {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 8px;
        }

        .clip-close {
            background: transparent;
            border: none;
            color: #6b7280;
            font-size: 24px;
            cursor: pointer;
            padding: 4px 8px;
            border-radius: 6px;
            line-height: 1;
        }

        .clip-close:hover {
            background: rgba(255, 255, 255, 0.1);
            color: #fff;
        }

        .clip-content {
            flex: 1;
            overflow-y: auto;
            padding: 20px;
            margin-bottom: 70px;
        }

        .clip-content::-webkit-scrollbar {
            width: 6px;
        }

        .clip-content::-webkit-scrollbar-track {
            background: #1a1b1e;
        }

        .clip-content::-webkit-scrollbar-thumb {
            background: #2c2d31;
            border-radius: 3px;
        }

        .clip-content::-webkit-scrollbar-thumb:hover {
            background: #3a3b3f;
        }

        .clip-bottom-actions {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 16px 20px;
            background: #2c2d31;
            border-radius: 0 0 16px 16px;
        }

        .clip-save, .clip-save-clipboard, .clip-clear-all {
            height: 40px;
            padding: 0 24px;
            border-radius: 8px;
            cursor: pointer;
            font-weight: 600;
            font-size: 14px;
            line-height: 40px;
            white-space: nowrap;
            border: none;
        }

        .clip-save {
            background: #98c379;
            color: #1a1b1e;
            box-shadow: 0 2px 8px rgba(152, 195, 121, 0.2);
        }

        .clip-save:hover {
            background: #a9d389;
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(152, 195, 121, 0.3);
        }

        .clip-save:disabled {
            background: #4a4b4f;
            cursor: not-allowed;
        }

        .clip-save-clipboard {
            background: #5a67d8;
            color: white;
            box-shadow: 0 2px 8px rgba(90, 103, 216, 0.2);
        }

        .clip-save-clipboard:hover {
            background: #6875f5;
        }

        .clip-clear-all {
            background: #dc2626;
            color: white;
            box-shadow: 0 2px 8px rgba(220, 38, 38, 0.2);
            padding-right: 20px;
            text-align: center;
        }

        .clip-clear-all:hover {
            background: #ef4444;
        }

        .clip-clear-all.confirm {
            background: #991b1b;
        }

        .clip-card {
            background: #2c2d31;
            border-radius: 12px;
            padding: 16px;
            margin-bottom: 16px;
            color: #fff;
            border: 1px solid #3a3b3f;
        }

        .clip-card:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
            border-color: #4a4b4f;
        }

        .clip-preview {
            font-size: 14px;
            color: #d1d5db;
            margin: 8px 0 12px 0;
            line-height: 1.5;
            max-height: 100px;
            overflow-y: auto;
            white-space: pre-wrap;
            word-break: break-word;
        }

        .clip-actions {
            display: flex;
            gap: 8px;
            justify-content: flex-end;
            border-top: 1px solid #3a3b3f;
            padding-top: 12px;
            margin-top: 8px;
        }

        .clip-btn {
            height: 32px;
            padding: 0 12px;
            background: transparent;
            border: 1px solid #4a4b4f;
            color: #fff;
            border-radius: 6px;
            font-size: 13px;
            line-height: 30px;
        }

        .clip-btn:hover {
            background: #3a3b3f;
            border-color: #5a5b5f;
        }

        .clip-btn.delete {
            color: #ef4444;
            border-color: #ef4444;
        }

        .clip-btn.delete:hover {
            background: rgba(239, 68, 68, 0.1);
        }

        .clip-toast {
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: #2563eb;
            color: white;
            padding: 12px 24px;
            border-radius: 8px;
            z-index: 10003;
            font-size: 14px;
            line-height: 1.4;
        }

        .clip-manager[data-theme="light"] {
            background: #ffffff;
            color: #1a1b1e;
        }

        .clip-search {
            background: #2c2d31;
            color: #fff;
            border: 1px solid #3a3b3f;
            padding: 8px 12px;
            border-radius: 6px;
            margin-bottom: 16px;
            width: 100%;
            flex: 1;
        }

        .clip-favorite {
            color: #ffd700;
            background: transparent;
            border: none;
            cursor: pointer;
            padding: 4px;
            margin-right: auto;
        }

        .clip-controls {
            margin-bottom: 20px;
            padding: 20px;
            display: flex;
            gap: 16px;
            align-items: center;
            flex-wrap: wrap;
            background: #1a1b1e;
            border-radius: 8px;
        }

        .clip-empty {
            text-align: center;
            font-size: 16px;
            color: #d1d5db;
            padding: 20px;
        }

        .clip-title-input {
            background: #2c2d31;
            color: #fff;
            border: 1px solid #3a3b3f;
            padding: 8px 12px;
            border-radius: 6px;
            flex: 1;
        }

        .clip-edit-icon, .clip-save-icon {
            background: transparent;
            border: none;
            color: #6b7280;
            font-size: 16px;
            cursor: pointer;
            padding: 4px 8px;
            border-radius: 6px;
        }

        .clip-edit-icon:hover, .clip-save-icon:hover {
            background: rgba(255, 255, 255, 0.1);
            color: #fff;
        }
    `;

    const archiveStyles = `
        .clip-archived-counter {
            margin-top: 20px;
            padding: 16px;
            background: #2c2d31;
            border-radius: 8px;
            color: #6b7280;
            text-align: center;
            font-size: 14px;
        }

        .archived-line {
            height: 2px;
            background: #3a3b3f;
            margin-bottom: 12px;
            border-radius: 1px;
        }

        .clip-card.favorite {
            border-color: #ffd700;
            background: rgba(255, 215, 0, 0.05);
        }
    `;

    const styleSheet = document.createElement('style');
    styleSheet.textContent = styles + archiveStyles;
    document.head.appendChild(styleSheet);

    const manager = document.createElement('div');
    manager.className = 'clip-manager';
    manager.innerHTML = `
        <div class="clip-header">
            <h2 class="clip-title">Clipboard Manager</h2>
            <button class="clip-close">×</button>
        </div>
        <div class="clip-controls">
            <input type="text" class="clip-search" placeholder="Search clips by title or content...">
        </div>
        <div class="clip-content"></div>
        <div class="clip-bottom-actions">
            <button class="clip-save-clipboard">Save Clipboard</button>
            <button class="clip-save" disabled>Save Selection</button>
            <button class="clip-clear-all">Clear All</button>
        </div>
    `;

    const toggleBtn = document.createElement('button');
    toggleBtn.className = 'clip-toggle';
    toggleBtn.innerHTML = `
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
            <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
            <rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
        </svg>
    `;

    class ClipboardManager {
        constructor() {
            this.items = this.loadItems();
            this.archivedItems = this.loadArchivedItems();
            this.isOpen = false;
            this.clearAllTimeout = null;
            this.searchTerm = '';
            this.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
            this.lastMouseY = 0;
            this.mouseMoveDelta = 0;

            this.init();
        }

        init() {
            document.body.appendChild(manager);
            document.body.appendChild(toggleBtn);
            this.bindEvents();
            this.setupThemeDetection();
            this.renderItems();
            this.updateSaveButton();
        }

        loadItems() {
            try {
                const items = typeof GM_getValue !== 'undefined' ?
                      GM_getValue(CONFIG.STORAGE_KEY) :
                localStorage.getItem(CONFIG.STORAGE_KEY);
                return items ? JSON.parse(items) : [];
            } catch (error) {
                console.error('Error loading items:', error);
                return [];
            }
        }

        loadArchivedItems() {
            try {
                const archived = typeof GM_getValue !== 'undefined' ?
                      GM_getValue(CONFIG.STORAGE_KEY + '_archived') :
                localStorage.getItem(CONFIG.STORAGE_KEY + '_archived');
                return archived ? JSON.parse(archived) : [];
            } catch (error) {
                console.error('Error loading archived items:', error);
                return [];
            }
        }

        saveItems() {
            try {
                const itemsJSON = JSON.stringify(this.items);
                if (typeof GM_setValue !== 'undefined') {
                    GM_setValue(CONFIG.STORAGE_KEY, itemsJSON);
                } else {
                    localStorage.setItem(CONFIG.STORAGE_KEY, itemsJSON);
                }
            } catch (error) {
                console.error('Error saving items:', error);
                this.showToast('Error saving items');
            }
        }

        saveArchivedItems() {
            try {
                const archivedJSON = JSON.stringify(this.archivedItems);
                if (typeof GM_setValue !== 'undefined') {
                    GM_setValue(CONFIG.STORAGE_KEY + '_archived', archivedJSON);
                } else {
                    localStorage.setItem(CONFIG.STORAGE_KEY + '_archived', archivedJSON);
                }
            } catch (error) {
                console.error('Error saving archived items:', error);
            }
        }

        setupThemeDetection() {
            const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
            mediaQuery.addEventListener('change', (e) => {
                this.theme = e.matches ? 'dark' : 'light';
                this.updateTheme();
            });
        }

        updateTheme() {
            manager.dataset.theme = this.theme;
        }

        bindEvents() {
            toggleBtn.addEventListener('click', () => this.toggle());
            manager.querySelector('.clip-close').addEventListener('click', () => this.close());

            document.addEventListener('selectionchange', () => {
                this.updateSaveButton();
            });

            manager.querySelector('.clip-save').addEventListener('click', () => {
                const selection = window.getSelection().toString().trim();
                if (selection) {
                    this.addItem(selection);
                }
            });

            manager.querySelector('.clip-save-clipboard').addEventListener('click', async () => {
                try {
                    const clipboardText = await navigator.clipboard.readText();
                    if (clipboardText.trim()) {
                        this.addItem(clipboardText);
                    } else {
                        this.showToast('Clipboard is empty');
                    }
                } catch (err) {
                    this.showToast('Failed to read clipboard');
                    console.error('Failed to read clipboard:', err);
                }
            });

            manager.querySelector('.clip-clear-all').addEventListener('click', (e) => {
                this.handleClearAll(e.target);
            });

            manager.querySelector('.clip-search').addEventListener('input', (e) => {
                this.searchTerm = e.target.value.toLowerCase();
                this.renderItems();
            });

            document.addEventListener('keydown', (e) => {
                if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') {
                    e.preventDefault();
                    this.toggle();
                    const selection = window.getSelection().toString().trim();
                    if (selection) {
                        this.addItem(selection);
                    }
                }
                if (e.key === 'Escape' && this.isOpen) {
                    this.close();
                }
            });

            const content = manager.querySelector('.clip-content');

            content.addEventListener('click', (e) => {
                if (e.target.classList.contains('clip-card') || 
                    e.target.classList.contains('clip-preview') ||
                    e.target.classList.contains('clip-title-wrapper') ||
                    e.target.classList.contains('clip-info') ||
                    e.target.classList.contains('clip-actions') ||
                    e.target.classList.contains('clip-date')) {
                    return;
                }
            
                const card = e.target.closest('.clip-card');
                if (!card) return;
            
                const id = parseInt(card.dataset.id);
            
                if (e.target.classList.contains('clip-edit-icon') || e.target.classList.contains('clip-save-icon')) {
                    this.toggleEditTitle(id, card);
                } else if (e.target.classList.contains('delete')) {
                    this.removeItem(id);
                } else if (e.target.classList.contains('favorite')) {
                    this.toggleFavorite(id);
                } else if (e.target.classList.contains('move-up')) {
                    e.stopPropagation();
                    this.moveItem(id, -1);
                } else if (e.target.classList.contains('move-down')) {
                    e.stopPropagation();
                    this.moveItem(id, 1);
                } else if (e.target.classList.contains('copy')) {
                    const text = card.querySelector('.clip-preview').textContent;
                    this.copyItem(text);
                }
            });
        }

        moveItem(id, direction) {
            const index = this.items.findIndex(item => item.id === id);
            if (index === -1) return;

            const newIndex = index + direction;
            if (newIndex < 0 || newIndex >= this.items.length) {
                this.showToast('Cannot move item further');
                return;
            }

            const item = this.items[index];
            const targetItem = this.items[newIndex];

            if ((item.isFavorite && !targetItem.isFavorite) || (!item.isFavorite && targetItem.isFavorite)) {
                this.showToast('Cannot move items between favorite and non-favorite sections');
                return;
            }

            this.items.splice(index, 1);
            this.items.splice(newIndex, 0, item);

            this.saveItems();
            this.renderItems();
        }

        addItem(text) {
            const timestamp = Date.now();
            const item = {
                id: timestamp,
                title: `Saved-${timestamp}`,
                text: text.trim(),
                date: new Date().toISOString(),
                isFavorite: false
            };

            const firstNonFavoriteIdx = this.items.findIndex(i => !i.isFavorite);
            this.items.splice(firstNonFavoriteIdx === -1 ? this.items.length : firstNonFavoriteIdx, 0, item);

            if (this.items.length > CONFIG.MAX_ITEMS) {
                const nonFavoriteItems = this.items.filter(item => !item.isFavorite);
                if (nonFavoriteItems.length > 0) {
                    const itemToArchive = nonFavoriteItems[nonFavoriteItems.length - 1];
                    this.archivedItems.unshift(itemToArchive);
                    this.items = this.items.filter(item => item.id !== itemToArchive.id);
                    this.saveArchivedItems();
                }
            }

            this.saveItems();
            this.renderItems();
            this.showToast('Item saved to clipboard manager');
        }

        removeItem(id) {
            const removedItem = this.items.find(item => item.id === id);
            this.items = this.items.filter(item => item.id !== id);

            if (this.archivedItems.length > 0 && this.items.length < CONFIG.MAX_ITEMS) {
                const itemToRestore = this.archivedItems.find(item => !item.isFavorite);
                if (itemToRestore) {
                    this.items.push(itemToRestore);
                    this.archivedItems = this.archivedItems.filter(item => item.id !== itemToRestore.id);
                    this.saveArchivedItems();
                    this.showToast('Restored item from archive');
                }
            }

            this.saveItems();
            this.renderItems();
            this.showToast('Item deleted');
        }

        toggleFavorite(id) {
            const item = this.items.find(item => item.id === id);
            if (!item) return;

            const favoriteCount = this.items.filter(i => i.isFavorite).length;

            if (!item.isFavorite && favoriteCount >= CONFIG.MAX_FAVORITES) {
                this.showToast(`Cannot add more than ${CONFIG.MAX_FAVORITES} favorites`);
                return;
            }

            const currentIndex = this.items.indexOf(item);
            this.items.splice(currentIndex, 1);

            item.isFavorite = !item.isFavorite;

            if (item.isFavorite) {
                const lastFavoriteIdx = this.items.findLastIndex(i => i.isFavorite);
                this.items.splice(lastFavoriteIdx + 1, 0, item);
            } else {
                const firstNonFavoriteIdx = this.items.findIndex(i => !i.isFavorite);
                this.items.splice(firstNonFavoriteIdx === -1 ? this.items.length : firstNonFavoriteIdx, 0, item);
            }

            this.saveItems();
            this.renderItems();
            this.showToast(item.isFavorite ? 'Added to favorites' : 'Removed from favorites');
        }

        async copyItem(text) {
            try {
                await navigator.clipboard.writeText(text);
                this.showToast('Copied to clipboard');
            } catch (err) {
                this.showToast('Failed to copy text');
                console.error('Failed to copy text:', err);
            }
        }

        handleClearAll(button) {
            if (button.classList.contains('confirm')) {
                this.items = [];
                this.archivedItems = [];
                this.saveItems();
                this.saveArchivedItems();
                this.renderItems();
                this.showToast('All items cleared');
                button.textContent = 'Clear All';
                button.classList.remove('confirm');
                if (this.clearAllTimeout) {
                    clearTimeout(this.clearAllTimeout);
                    this.clearAllTimeout = null;
                }
            } else {
                button.textContent = 'Confirm Clear?';
                button.classList.add('confirm');

                if (this.clearAllTimeout) {
                    clearTimeout(this.clearAllTimeout);
                }

                this.clearAllTimeout = setTimeout(() => {
                    button.textContent = 'Clear All';
                    button.classList.remove('confirm');
                    this.clearAllTimeout = null;
                }, CONFIG.CONFIRM_TIMEOUT);
            }
        }

        updateSaveButton() {
            const saveBtn = manager.querySelector('.clip-save');
            if (!saveBtn) return;

            const selection = window.getSelection();
            const hasSelection = selection && selection.toString().trim().length > 0;
            saveBtn.disabled = !hasSelection;
        }

        toggle() {
            this.isOpen = !this.isOpen;
            manager.classList.toggle('open');
            toggleBtn.classList.toggle('hidden');
        }

        close() {
            this.isOpen = false;
            manager.classList.remove('open');
            toggleBtn.classList.remove('hidden');
        }

        showToast(message) {
            const existingToast = document.querySelector('.clip-toast');
            if (existingToast) {
                existingToast.remove();
            }

            const toast = document.createElement('div');
            toast.className = 'clip-toast';
            toast.textContent = message;
            document.body.appendChild(toast);
            setTimeout(() => toast.remove(), CONFIG.TOAST_DURATION);
        }

        toggleEditTitle(id, card) {
            const titleDisplay = card.querySelector('.clip-title-display');
            const editIcon = card.querySelector('.clip-edit-icon');
            const saveIcon = card.querySelector('.clip-save-icon');

            if (!titleDisplay.contentEditable || titleDisplay.contentEditable === 'false') {
                card.classList.add('editing');
                titleDisplay.contentEditable = 'true';
                titleDisplay.focus();

                const range = document.createRange();
                range.selectNodeContents(titleDisplay);
                range.collapse(false);
                const selection = window.getSelection();
                selection.removeAllRanges();
                selection.addRange(range);

                editIcon.style.display = 'none';
                saveIcon.style.display = 'inline-block';

                titleDisplay.addEventListener('input', function() {
                    if (this.textContent.length > 32) {
                        this.textContent = this.textContent.slice(0, 32);
                        const range = document.createRange();
                        range.selectNodeContents(this);
                        range.collapse(false);
                        const selection = window.getSelection();
                        selection.removeAllRanges();
                        selection.addRange(range);
                    }
                });
            } else {
                const newTitle = titleDisplay.textContent.trim();
                if (!newTitle) {
                    this.showToast('Title cannot be blank');
                    return;
                }

                card.classList.remove('editing');
                const item = this.items.find(item => item.id === id);
                if (item) {
                    item.title = newTitle;
                    this.saveItems();
                }

                titleDisplay.contentEditable = 'false';
                editIcon.style.display = 'inline-block';
                saveIcon.style.display = 'none';
            }
        }

        renderItems() {
            const content = manager.querySelector('.clip-content');
            content.innerHTML = '';

            const filteredItems = this.items
            .filter(item => {
                const searchMatch = (
                    item.title.toLowerCase().includes(this.searchTerm) ||
                    item.text.toLowerCase().includes(this.searchTerm)
                );
                return searchMatch;
            })
            .sort((a, b) => b.isFavorite - a.isFavorite);

            if (filteredItems.length === 0) {
                content.innerHTML = `
                    <div class="clip-empty">
                        ${this.searchTerm ? 'No matching items found' : 'No items saved yet'}
                    </div>`;
                return;
            }

            content.innerHTML = filteredItems.map(item => `
                <div class="clip-card ${item.isFavorite ? 'favorite' : ''}" data-id="${item.id}">
                    <div class="clip-title-wrapper">
                        <button class="clip-edit-icon">✎</button>
                        <div class="clip-title-display">${item.title}</div>
                        <button class="clip-save-icon" style="display:none;">💾</button>
                    </div>
                    <div class="clip-preview">${item.text}</div>
                    <div class="clip-info">
                        <span class="clip-date">${new Date(item.date).toLocaleDateString()}</span>
                    </div>
                    <div class="clip-actions">
                        <button class="clip-btn favorite">${item.isFavorite ? '★' : '☆'}</button>
                        <button class="clip-btn copy">Copy</button>
                        <button class="clip-btn delete">Delete</button>
                        <button class="clip-btn move-up">↑</button>
                        <button class="clip-btn move-down">↓</button>
                    </div>
                </div>
            `).join('');

            if (this.archivedItems.length > 0) {
                const archivedCounter = document.createElement('div');
                archivedCounter.className = 'clip-archived-counter';
                archivedCounter.innerHTML = `
                    <div class="archived-line"></div>
                    <span><strong>${this.archivedItems.length}</strong> items in archive</span>
                `;
                content.appendChild(archivedCounter);
            }
        }
    }

    window.clipManager = new ClipboardManager();
})();