// ==UserScript== // @name WPlace Unlimited Favorites⭐ - 無制限保存 & エクスポート機能 // @description WPlace.liveで無制限のお気に入り保存、バックアップ・復元機能付き、Blue Marble完全対応、UI統合型 // @match *://wplace.live/* // @grant GM_setValue // @grant GM_getValue // @icon https://www.google.com/s2/favicons?sz=64&domain=wplace.live // @version 2025-08-21 // @author Defaulter // @license MIT // @namespace https://greasyfork.org/users/1508363 // @downloadURL none // ==/UserScript== class WPlaceExtendedFavorites { constructor() { this.STORAGE_KEY = 'wplace_extended_favorites'; this.init(); } init() { this.observeAndInit(); } observeAndInit() { // ボタン設定 const buttonConfigs = [ { id: 'favorite-btn', selector: '[title="お気に入り"]', containerSelector: 'button[title="Toggle art opacity"]', create: this.createFavoriteButton.bind(this) }, { id: 'save-btn', selector: '[data-wplace-save="true"]', containerSelector: '.hide-scrollbar.flex.max-w-full.gap-1\\.5.overflow-x-auto', create: this.createSaveButton.bind(this) } ]; // 汎用ボタン監視 this.startButtonObserver(buttonConfigs); // モーダル作成 setTimeout(() => this.createModal(), 2000); } // 汎用ボタン監視システム startButtonObserver(configs) { const ensureButtons = () => { configs.forEach(config => { if (!document.querySelector(config.selector)) { const container = document.querySelector(config.containerSelector); if (container) { config.create(container); } } }); }; // DOM変更監視 const observer = new MutationObserver(() => { setTimeout(ensureButtons, 100); }); observer.observe(document.body, { childList: true, subtree: true }); // 初期配置 & 定期チェック setTimeout(ensureButtons, 1000); setInterval(ensureButtons, 5000); } // お気に入りボタン作成 createFavoriteButton(toggleButton) { const container = toggleButton.parentElement; if (!container) return; const button = document.createElement('button'); button.className = 'btn btn-lg sm:btn-xl btn-square shadow-md text-base-content/80 ml-2 z-30'; button.title = 'お気に入り'; button.innerHTML = ` `; button.addEventListener('click', () => this.openModal()); container.appendChild(button); console.log('⭐ Favorite button added'); } // 保存ボタン作成 createSaveButton(container) { const button = document.createElement('button'); button.className = 'btn btn-primary btn-soft'; button.setAttribute('data-wplace-save', 'true'); button.innerHTML = ` 保存 `; button.addEventListener('click', () => this.addFavorite()); container.appendChild(button); console.log('⭐ Save button added'); } // モーダルを作成 createModal() { const modal = document.createElement('dialog'); modal.id = 'favorite-modal'; modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); // イベントリスナー(既存のグリッドクリック) modal.querySelector('#favorites-grid').addEventListener('click', (e) => { const card = e.target.closest('.favorite-card'); const deleteBtn = e.target.closest('.delete-btn'); if (deleteBtn) { const id = parseInt(deleteBtn.dataset.id); this.deleteFavorite(id); } else if (card) { const lat = parseFloat(card.dataset.lat); const lng = parseFloat(card.dataset.lng); const zoom = parseFloat(card.dataset.zoom); this.goTo(lat, lng, zoom); modal.close(); } }); // エクスポート・インポートのイベントリスナー modal.querySelector('#export-btn').addEventListener('click', () => this.exportFavorites()); modal.querySelector('#import-btn').addEventListener('click', () => this.importFavorites()); } // エクスポート機能 async exportFavorites() { try { const favorites = await this.getFavorites(); if (favorites.length === 0) { this.showToast('エクスポートするお気に入りがありません'); return; } const exportData = { version: "1.0", exportDate: new Date().toISOString(), count: favorites.length, favorites: favorites }; const dataStr = JSON.stringify(exportData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(dataBlob); link.download = `wplace-favorites-${new Date().toISOString().split('T')[0]}.json`; link.click(); this.showToast(`${favorites.length}件のお気に入りをエクスポートしました`); } catch (error) { console.error('エクスポートエラー:', error); this.showToast('エクスポートに失敗しました'); } } // インポート機能 importFavorites() { const fileInput = document.getElementById('import-file'); fileInput.click(); fileInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); const importData = JSON.parse(text); // データ形式チェック if (!importData.favorites || !Array.isArray(importData.favorites)) { throw new Error('無効なファイル形式です'); } const currentFavorites = await this.getFavorites(); const importCount = importData.favorites.length; if (!confirm(`${importCount}件のお気に入りをインポートしますか?\n既存のデータは保持されます。`)) { return; } // 重複チェック(座標が同じものは除外) const newFavorites = importData.favorites.filter(importFav => { return !currentFavorites.some(existing => Math.abs(existing.lat - importFav.lat) < 0.001 && Math.abs(existing.lng - importFav.lng) < 0.001 ); }); // IDを新規採番(整数で) newFavorites.forEach((fav, index) => { fav.id = Date.now() + index; }); // マージして保存 const mergedFavorites = [...currentFavorites, ...newFavorites]; await GM.setValue(this.STORAGE_KEY, JSON.stringify(mergedFavorites)); this.renderFavorites(); this.showToast(`${newFavorites.length}件のお気に入りをインポートしました`); } catch (error) { console.error('インポートエラー:', error); this.showToast('インポートに失敗しました: ' + error.message); } // ファイル入力をクリア fileInput.value = ''; }; } // モーダルを開く openModal() { this.renderFavorites(); document.getElementById('favorite-modal').showModal(); } // 現在位置を取得 getCurrentPosition() { try { const locationStr = localStorage.getItem('location'); if (locationStr) { const location = JSON.parse(locationStr); return { lat: location.lat, lng: location.lng, zoom: location.zoom }; } } catch (error) { console.error('位置取得エラー:', error); } return null; } // お気に入りを追加 async addFavorite() { const position = this.getCurrentPosition(); if (!position) { alert('位置情報を取得できませんでした。マップをクリックしてから保存してください。'); return; } const name = prompt('お気に入り名を入力してください:', `地点 (${position.lat.toFixed(3)}, ${position.lng.toFixed(3)})`); if (!name) return; const favorite = { id: Date.now(), name: name, lat: position.lat, lng: position.lng, zoom: position.zoom || 14, date: new Date().toLocaleDateString('ja-JP') }; const favorites = await this.getFavorites(); favorites.push(favorite); await GM.setValue(this.STORAGE_KEY, JSON.stringify(favorites)); // 通知 this.showToast(`"${name}" を保存しました`); } // お気に入り一覧を取得 async getFavorites() { try { const stored = await GM.getValue(this.STORAGE_KEY, '[]'); return JSON.parse(stored); } catch (error) { console.error('お気に入り取得エラー:', error); return []; } } // お気に入り一覧を表示 async renderFavorites() { const favorites = await this.getFavorites(); const grid = document.getElementById('favorites-grid'); const count = document.getElementById('favorites-count'); if (!grid || !count) return; count.textContent = `保存済み: ${favorites.length} 件`; if (favorites.length === 0) { grid.innerHTML = `

お気に入りがありません

下の「保存」ボタンから追加してください

`; return; } // 新しい順にソート favorites.sort((a, b) => b.id - a.id); grid.innerHTML = favorites.map(fav => `

${fav.name}

📍 ${fav.lat.toFixed(3)}, ${fav.lng.toFixed(3)}
📅 ${fav.date}
`).join(''); } // 位置へ移動 goTo(lat, lng, zoom) { const url = new URL(window.location); url.searchParams.set('lat', lat); url.searchParams.set('lng', lng); url.searchParams.set('zoom', zoom); window.location.href = url.toString(); } // お気に入り削除 async deleteFavorite(id) { if (!confirm('このお気に入りを削除しますか?')) return; const favorites = await this.getFavorites(); const filtered = favorites.filter(fav => fav.id !== id); await GM.setValue(this.STORAGE_KEY, JSON.stringify(filtered)); this.renderFavorites(); this.showToast('削除しました'); } // トースト通知 showToast(message) { const toast = document.createElement('div'); toast.className = 'toast toast-top toast-end z-50'; toast.innerHTML = `
${message}
`; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); } } // 初期化 new WPlaceExtendedFavorites();