// ==UserScript== // @name Dropbox Share Link Batch Save & Download // @name:en Dropbox Share Link Batch Save & Download // @name:zh-CN Dropbox 分享链接批量保存下载 // @name:zh-TW Dropbox 分享連結批次儲存下載 // @name:ja Dropbox 共有リンクの一括保存・ダウンロード // @name:ko Dropbox 공유 링크 일괄 저장·다운로드 // @name:de Dropbox Freigabelink – Mehrfach speichern und herunterladen // @name:fr Dropbox – Enregistrer et télécharger en lot depuis un lien de partage // @name:es Dropbox – Guardar y descargar varios archivos desde un enlace compartido // @namespace https://github.com/gaoyilun // @version 1.0.0 // @description Adds a checkbox to every file on Dropbox share-link pages, in both list and grid view. Select any files, then either save them one by one with "Copy to Dropbox" on the same page (the new-tab event is the success signal and is intercepted), or send them straight to the browser downloader. Shift-click and Ctrl/Cmd+A work too. The UI blends into Dropbox's own native layout. // @description:zh-CN 在 Dropbox 分享链接页为文件添加多选框,列表视图与网格视图都支持;选中后可在同一页面依次点击每个文件的“复制到 Dropbox”按钮(以“新标签页打开”作为成功事件并拦截弹窗),也可直接交给浏览器批量下载。UI 融入原生布局:保存按钮排在操作栏“复制到 Dropbox”右侧,下载按钮排在“下载”右侧;全选复选框在列表视图下置于列头,网格视图下置于操作栏最前面。 // @author 伦Ellen // @icon https://cfl.dropboxstatic.com/static/metaserver/static/images/favicon.ico // @tag dropbox // @tag multi-select // @tag batch-operations // @tag productivity // @tag cloud-storage // @tag 网盘 // @tag 多选 // @tag 批量操作 // @tag 效率工具 // @match https://*.dropbox.com/scl/fo/* // @match https://*.dropbox.com/scl/fi/* // @match https://*.dropbox.com/sh/* // @run-at document-idle // @grant none // ==/UserScript== // 目标元素全部按 Dropbox 前端自身的 data-testid 定位(语言无关), // 因此脚本功能适用于 Dropbox 的所有语言;本脚本注入的界面文案另按页面语言本地化。 (function () { 'use strict'; // 列表视图 const LIST = { item: '[data-testid="ROW_TEST_ID"]', // 文件行 link: 'a[data-testid="sl-list-column--name"]', // 文件名链接 hoverCell: '[data-testid="sl-list-column--hover-actions"]', // 悬停操作区 copyBtn: '[data-testid="list-item-hover-copy-button"]', // 行内“复制到 Dropbox” overflowBtn: '[data-testid="list-item-overflow-open-button"]', // 溢出菜单按钮 overflowCopy: '[data-testid="list-item-overflow-copy-button"]', // 菜单中的“复制” headerName: '[data-testid="sl-list-header--name"]', // “名称”列头 }; // 网格视图(卡片流) const GRID = { body: '[data-testid="sl-grid-body"]', // 卡片列表容器 item: '[data-testid="sl-grid-body"] > li', // 单个卡片 link: 'a[data-testid="grid-link"]', // 卡片文件名链接 media: '[data-testid="sl-card-media"]', // 卡片预览图区(复选框放这里) overflowBtn: '[data-testid="grid-item-overflow-open-button"]', // 卡片溢出菜单按钮 overflowCopy: '[data-testid="grid-item-overflow-copy-button"]', // 菜单中的“复制” menuCopy: '[data-testid="grid-v2-tile-copy-menu-item"]', // 新版卡片菜单里的“复制” }; // 操作栏(两种视图共用,克隆样式来源) const BAR = { copy: '[data-testid="action-bar-copy-to-dropbox-button"]', download: '[data-testid="action-bar-download-button"]', inner: '_folderActionBarInnerContainer', // 每个原生按钮各自的包裹层,间距来源 }; const CONFIG = { revealTimeoutMs: 2000, // 悬停后等待操作按钮渲染 menuTimeoutMs: 2000, // 等待溢出菜单出现 successTimeoutMs: 6000, // 等待“成功”弹窗信号的兜底时间 downloadGapMs: 300, // 多文件下载的间隔(太快会被浏览器一次性拦截) }; // Ctrl/Cmd+A:只有这些类型的 input 才有“文本可全选”,其余(复选框/单选/按钮/文件…)不拦 const TEXT_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date', 'datetime-local', 'month', 'week', 'time']; // 只在本脚本注入的元素上生效的样式(原生按钮一律克隆,不自己画皮) const STYLE = { // 复选框本体(尺寸 / 光标);具体摆放位置由下面两个视图各自的规则覆盖 checkbox: 'width:16px;height:16px;margin:0 8px 0 2px;vertical-align:middle;cursor:pointer;', // 列表视图:单元格是 display:table-cell,而链接虽然是行内元素、内部却是块级 div, // 行内复选框会被“块级盒子打断行内内容”的规则挤成单独一行贴到左上角。 // 所以改成绝对定位:垂直居中贴在单元格左侧(left 由单元格左内边距算出)。 listCheckbox: (left) => 'position:absolute;left:' + left + 'px;top:calc(50% - 8px);' + 'margin:0;z-index:20;cursor:pointer;', // 网格视图:浮在预览图内部左上角,预留左/上边距;z-index 要压过 hover 时的遮罩层 gridCheckbox: 'position:absolute;left:10px;top:10px;margin:0;z-index:20;cursor:pointer;', toast: 'position:fixed;right:14px;bottom:14px;z-index:999999;background:#1e2024;color:#fff;' + 'padding:8px 14px;border-radius:6px;font:13px sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.3);max-width:60vw;', fallbackBtn: (bottom) => 'position:fixed;right:14px;bottom:' + bottom + 'px;z-index:999999;background:#19c37d;' + 'color:#fff;border:none;border-radius:4px;padding:8px 14px;cursor:pointer;font:14px/1.2 sans-serif;', rowHighlight: '2px solid #007ee5', }; // 列表复选框占用的左侧空间:复选框 16px + 与内容之间的 8px 间隔 + 2px 对齐余量 const LIST_RESERVE = 26; // ---------- 本地化:本脚本注入的界面文案 ---------- const MESSAGES = { en: { saveSelected: (n) => `Save Selected (${n})`, noSelection: 'Please select at least one file first.', start: (n) => `Starting sequential copy (${n} file${n === 1 ? '' : 's'})…`, progress: (i, n, name) => `(${i}/${n}) Copying: ${name}`, done: (ok, pending, fail) => `Done: ${ok} succeeded, ${pending} unconfirmed, ${fail} failed. Please verify in Dropbox later.`, unknownName: '(unknown)', interceptOpen: 'Intercepted success popup via window.open (no new tab):', interceptLink: 'Intercepted success popup via target=_blank (no new tab):', noCopyButton: 'Copy button not found, skipped:', itemOk: 'Success (popup intercepted):', itemUnconfirmed: 'Clicked but no success popup captured (probably saved):', downloadSelected: (n) => `Download Selected (${n})`, downloadStart: (n) => `Starting download of ${n} file${n === 1 ? '' : 's'}…`, downloadProgress: (i, n, name) => `(${i}/${n}) Downloading: ${name}`, downloadDone: (n) => `Done: started ${n} download(s). If your browser asks to allow multiple downloads, please allow.`, selectAll: 'Select all', }, zh_Hans: { saveSelected: (n) => `保存选中 (${n})`, noSelection: '请先勾选文件', start: (n) => `开始逐个触发复制(共 ${n} 个)…`, progress: (i, n, name) => `(${i}/${n}) 正在复制:${name}`, done: (ok, pending, fail) => `完成:成功 ${ok} 个,未确认 ${pending} 个,失败 ${fail} 个。请稍后在 Dropbox 中核对。`, unknownName: '(未知)', interceptOpen: '已拦截成功弹窗 window.open(未新建标签页):', interceptLink: '已拦截成功弹窗 target=_blank(未新建标签页):', noCopyButton: '未找到复制按钮,跳过:', itemOk: '成功(已拦截弹窗):', itemUnconfirmed: '已点击但未捕获成功弹窗(可能已保存):', downloadSelected: (n) => `下载选中 (${n})`, downloadStart: (n) => `开始下载 ${n} 个文件…`, downloadProgress: (i, n, name) => `(${i}/${n}) 正在下载:${name}`, downloadDone: (n) => `完成:已发起 ${n} 个下载。若浏览器询问是否允许多文件下载,请选择允许。`, selectAll: '全选', }, zh_Hant: { saveSelected: (n) => `儲存選取 (${n})`, noSelection: '請先勾選檔案', start: (n) => `開始逐一觸發複製(共 ${n} 個)…`, progress: (i, n, name) => `(${i}/${n}) 正在複製:${name}`, done: (ok, pending, fail) => `完成:成功 ${ok} 個,未確認 ${pending} 個,失敗 ${fail} 個。請稍後在 Dropbox 中核對。`, unknownName: '(未知)', interceptOpen: '已攔截成功彈窗 window.open(未新增分頁):', interceptLink: '已攔截成功彈窗 target=_blank(未新增分頁):', noCopyButton: '找不到複製按鈕,已跳過:', itemOk: '成功(已攔截彈窗):', itemUnconfirmed: '已點擊但未擷取到成功彈窗(可能已儲存):', downloadSelected: (n) => `下載選取 (${n})`, downloadStart: (n) => `開始下載 ${n} 個檔案…`, downloadProgress: (i, n, name) => `(${i}/${n}) 正在下載:${name}`, downloadDone: (n) => `完成:已發起 ${n} 個下載。若瀏覽器詢問是否允許多檔案下載,請選擇允許。`, selectAll: '全選', }, ja: { saveSelected: (n) => `選択を保存 (${n})`, noSelection: '先にファイルを選択してください。', start: (n) => `一括コピーを開始します(${n} 件)…`, progress: (i, n, name) => `(${i}/${n}) コピー中:${name}`, done: (ok, pending, fail) => `完了:成功 ${ok} 件、未確認 ${pending} 件、失敗 ${fail} 件。後ほど Dropbox でご確認ください。`, unknownName: '(不明)', interceptOpen: '成功ポップアップを window.open で傍受(新しいタブは開きません):', interceptLink: '成功ポップアップを target=_blank で傍受(新しいタブは開きません):', noCopyButton: 'コピーボタンが見つからず、スキップしました:', itemOk: '成功(ポップアップを傍受):', itemUnconfirmed: 'クリックしましたが成功ポップアップを取得できませんでした(保存済みの可能性あり):', downloadSelected: (n) => `選択をダウンロード (${n})`, downloadStart: (n) => `${n} 件のダウンロードを開始します…`, downloadProgress: (i, n, name) => `(${i}/${n}) ダウンロード中:${name}`, downloadDone: (n) => `完了:${n} 件のダウンロードを開始しました。ブラウザに複数ファイルのダウンロード許可を求められた場合は許可してください。`, selectAll: 'すべて選択', }, ko: { saveSelected: (n) => `선택 항목 저장 (${n})`, noSelection: '먼저 파일을 하나 이상 선택하세요.', start: (n) => `순차 복사를 시작합니다(${n}개)…`, progress: (i, n, name) => `(${i}/${n}) 복사 중: ${name}`, done: (ok, pending, fail) => `완료: 성공 ${ok}개, 미확인 ${pending}개, 실패 ${fail}개. 나중에 Dropbox에서 확인하세요.`, unknownName: '(알 수 없음)', interceptOpen: '성공 팝업을 window.open에서 가로챔(새 탭 없음):', interceptLink: '성공 팝업을 target=_blank에서 가로챔(새 탭 없음):', noCopyButton: '복사 버튼을 찾지 못해 건너뜀:', itemOk: '성공(팝업 가로챔):', itemUnconfirmed: '클릭했지만 성공 팝업을 감지하지 못함(저장되었을 수 있음):', downloadSelected: (n) => `선택 항목 다운로드 (${n})`, downloadStart: (n) => `${n}개 파일 다운로드를 시작합니다…`, downloadProgress: (i, n, name) => `(${i}/${n}) 다운로드 중: ${name}`, downloadDone: (n) => `완료: ${n}개 다운로드를 시작했습니다. 브라우저에서 여러 파일 다운로드 허용을 물으면 허용하세요.`, selectAll: '전체 선택', }, de: { saveSelected: (n) => `Auswahl speichern (${n})`, noSelection: 'Bitte zuerst mindestens eine Datei auswählen.', start: (n) => `Starte sequenzielles Kopieren (${n} Datei(en))…`, progress: (i, n, name) => `(${i}/${n}) Kopiere: ${name}`, done: (ok, pending, fail) => `Fertig: ${ok} erfolgreich, ${pending} unbestätigt, ${fail} fehlgeschlagen. Bitte später in Dropbox prüfen.`, unknownName: '(unbekannt)', interceptOpen: 'Erfolgs-Popup über window.open abgefangen (kein neuer Tab):', interceptLink: 'Erfolgs-Popup über target=_blank abgefangen (kein neuer Tab):', noCopyButton: 'Kopier-Button nicht gefunden, übersprungen:', itemOk: 'Erfolgreich (Popup abgefangen):', itemUnconfirmed: 'Geklickt, aber kein Erfolgs-Popup erfasst (vermutlich gespeichert):', downloadSelected: (n) => `Auswahl herunterladen (${n})`, downloadStart: (n) => `Starte Download von ${n} Datei(en)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Lädt herunter: ${name}`, downloadDone: (n) => `Fertig: ${n} Download(s) gestartet. Falls der Browser nach der Erlaubnis für mehrere Downloads fragt, bitte erlauben.`, selectAll: 'Alle auswählen', }, fr: { saveSelected: (n) => `Enregistrer la sélection (${n})`, noSelection: "Veuillez d'abord sélectionner au moins un fichier.", start: (n) => `Démarrage de la copie séquentielle (${n} fichier(s))…`, progress: (i, n, name) => `(${i}/${n}) Copie : ${name}`, done: (ok, pending, fail) => `Terminé : ${ok} réussie(s), ${pending} non confirmée(s), ${fail} échec(s). Veuillez vérifier dans Dropbox plus tard.`, unknownName: '(inconnu)', interceptOpen: 'Popup de succès interceptée via window.open (aucun nouvel onglet) :', interceptLink: 'Popup de succès interceptée via target=_blank (aucun nouvel onglet) :', noCopyButton: 'Bouton de copie introuvable, ignoré :', itemOk: 'Réussi (popup interceptée) :', itemUnconfirmed: 'Clic effectué mais aucun popup de succès détecté (probablement enregistré) :', downloadSelected: (n) => `Télécharger la sélection (${n})`, downloadStart: (n) => `Démarrage du téléchargement de ${n} fichier(s)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Téléchargement : ${name}`, downloadDone: (n) => `Terminé : ${n} téléchargement(s) lancé(s). Si le navigateur demande l’autorisation de télécharger plusieurs fichiers, veuillez l’autoriser.`, selectAll: 'Tout sélectionner', }, es: { saveSelected: (n) => `Guardar selección (${n})`, noSelection: 'Primero selecciona al menos un archivo.', start: (n) => `Iniciando copia secuencial (${n} archivo(s))…`, progress: (i, n, name) => `(${i}/${n}) Copiando: ${name}`, done: (ok, pending, fail) => `Listo: ${ok} correcto(s), ${pending} sin confirmar, ${fail} fallido(s). Compruébalo más tarde en Dropbox.`, unknownName: '(desconocido)', interceptOpen: 'Ventana emergente de éxito interceptada mediante window.open (sin abrir pestaña):', interceptLink: 'Ventana emergente de éxito interceptada mediante target=_blank (sin abrir pestaña):', noCopyButton: 'No se encontró el botón de copiar, se omite:', itemOk: 'Correcto (ventana emergente interceptada):', itemUnconfirmed: 'Se hizo clic pero no se detectó la ventana de éxito (probablemente guardado):', downloadSelected: (n) => `Descargar selección (${n})`, downloadStart: (n) => `Iniciando la descarga de ${n} archivo(s)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Descargando: ${name}`, downloadDone: (n) => `Listo: se iniciaron ${n} descarga(s). Si el navegador pregunta si permite descargar varios archivos, permítelo.`, selectAll: 'Seleccionar todo', }, pt_BR: { saveSelected: (n) => `Salvar seleção (${n})`, noSelection: 'Selecione pelo menos um arquivo primeiro.', start: (n) => `Iniciando cópia sequencial (${n} arquivo(s))…`, progress: (i, n, name) => `(${i}/${n}) Copiando: ${name}`, done: (ok, pending, fail) => `Concluído: ${ok} com sucesso, ${pending} não confirmado(s), ${fail} com falha. Verifique no Dropbox mais tarde.`, unknownName: '(desconhecido)', interceptOpen: 'Pop-up de sucesso interceptado via window.open (sem abrir nova aba):', interceptLink: 'Pop-up de sucesso interceptado via target=_blank (sem abrir nova aba):', noCopyButton: 'Botão de copiar não encontrado, ignorado:', itemOk: 'Sucesso (pop-up interceptado):', itemUnconfirmed: 'Clicado, mas nenhum pop-up de sucesso detectado (provavelmente salvo):', downloadSelected: (n) => `Baixar seleção (${n})`, downloadStart: (n) => `Iniciando o download de ${n} arquivo(s)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Baixando: ${name}`, downloadDone: (n) => `Concluído: ${n} download(s) iniciado(s). Se o navegador perguntar se permite baixar vários arquivos, permita.`, selectAll: 'Selecionar tudo', }, it: { saveSelected: (n) => `Salva selezione (${n})`, noSelection: 'Seleziona prima almeno un file.', start: (n) => `Avvio copia sequenziale (${n} file)…`, progress: (i, n, name) => `(${i}/${n}) Copia in corso: ${name}`, done: (ok, pending, fail) => `Fatto: ${ok} riusciti, ${pending} non confermati, ${fail} non riusciti. Verifica più tardi su Dropbox.`, unknownName: '(sconosciuto)', interceptOpen: 'Popup di successo intercettato tramite window.open (nessuna nuova scheda):', interceptLink: 'Popup di successo intercettato tramite target=_blank (nessuna nuova scheda):', noCopyButton: 'Pulsante di copia non trovato, ignorato:', itemOk: 'Riuscito (popup intercettato):', itemUnconfirmed: 'Clic effettuato ma nessun popup di successo rilevato (probabilmente salvato):', downloadSelected: (n) => `Scarica selezione (${n})`, downloadStart: (n) => `Avvio del download di ${n} file…`, downloadProgress: (i, n, name) => `(${i}/${n}) Download in corso: ${name}`, downloadDone: (n) => `Fatto: ${n} download avviato(i). Se il browser chiede l’autorizzazione per più download, concedila.`, selectAll: 'Seleziona tutto', }, nl: { saveSelected: (n) => `Selectie opslaan (${n})`, noSelection: 'Selecteer eerst minimaal één bestand.', start: (n) => `Sequentiële kopie starten (${n} bestand(en))…`, progress: (i, n, name) => `(${i}/${n}) Kopiëren: ${name}`, done: (ok, pending, fail) => `Klaar: ${ok} geslaagd, ${pending} onbevestigd, ${fail} mislukt. Controleer dit later in Dropbox.`, unknownName: '(onbekend)', interceptOpen: 'Succes-pop-up onderschept via window.open (geen nieuw tabblad):', interceptLink: 'Succes-pop-up onderschept via target=_blank (geen nieuw tabblad):', noCopyButton: 'Kopieerknop niet gevonden, overgeslagen:', itemOk: 'Geslaagd (pop-up onderschept):', itemUnconfirmed: 'Geklikt maar geen succes-pop-up vastgelegd (waarschijnlijk opgeslagen):', downloadSelected: (n) => `Selectie downloaden (${n})`, downloadStart: (n) => `Download van ${n} bestand(en) starten…`, downloadProgress: (i, n, name) => `(${i}/${n}) Downloaden: ${name}`, downloadDone: (n) => `Klaar: ${n} download(s) gestart. Als de browser vraagt om meerdere downloads toe te staan, sta dit dan toe.`, selectAll: 'Alles selecteren', }, pl: { saveSelected: (n) => `Zapisz zaznaczone (${n})`, noSelection: 'Najpierw zaznacz co najmniej jeden plik.', start: (n) => `Rozpoczynam sekwencyjne kopiowanie (${n} plik(i))…`, progress: (i, n, name) => `(${i}/${n}) Kopiowanie: ${name}`, done: (ok, pending, fail) => `Gotowe: ${ok} udane, ${pending} niepotwierdzone, ${fail} nieudane. Sprawdź później w Dropbox.`, unknownName: '(nieznany)', interceptOpen: 'Przechwycono wyskakujące okienko sukcesu przez window.open (brak nowej karty):', interceptLink: 'Przechwycono wyskakujące okienko sukcesu przez target=_blank (brak nowej karty):', noCopyButton: 'Nie znaleziono przycisku kopiowania, pominięto:', itemOk: 'Sukces (przechwycono wyskakujące okienko):', itemUnconfirmed: 'Kliknięto, ale nie przechwycono okienka sukcesu (prawdopodobnie zapisano):', downloadSelected: (n) => `Pobierz zaznaczone (${n})`, downloadStart: (n) => `Rozpoczynam pobieranie ${n} plik(i)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Pobieranie: ${name}`, downloadDone: (n) => `Gotowe: rozpoczęto ${n} pobieranie(a). Jeśli przeglądarka zapyta o zgodę na pobranie wielu plików, wyraź zgodę.`, selectAll: 'Zaznacz wszystko', }, ru: { saveSelected: (n) => `Сохранить выбранное (${n})`, noSelection: 'Сначала выберите хотя бы один файл.', start: (n) => `Начинаю последовательное копирование (${n} файл(ов))…`, progress: (i, n, name) => `(${i}/${n}) Копирование: ${name}`, done: (ok, pending, fail) => `Готово: успешно ${ok}, не подтверждено ${pending}, с ошибками ${fail}. Проверьте позже в Dropbox.`, unknownName: '(неизвестно)', interceptOpen: 'Перехвачено всплывающее окно успеха через window.open (новая вкладка не открывается):', interceptLink: 'Перехвачено всплывающее окно успеха через target=_blank (новая вкладка не открывается):', noCopyButton: 'Кнопка копирования не найдена, пропуск:', itemOk: 'Успешно (всплывающее окно перехвачено):', itemUnconfirmed: 'Нажатие выполнено, но всплывающее окно не получено (возможно, уже сохранено):', downloadSelected: (n) => `Скачать выбранное (${n})`, downloadStart: (n) => `Начинаю загрузку ${n} файл(ов)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Загрузка: ${name}`, downloadDone: (n) => `Готово: начато загрузок: ${n}. Если браузер запросит разрешение на загрузку нескольких файлов, разрешите.`, selectAll: 'Выбрать все', }, uk: { saveSelected: (n) => `Зберегти вибране (${n})`, noSelection: 'Спочатку виберіть хоча б один файл.', start: (n) => `Починаю послідовне копіювання (${n} файл(ів))…`, progress: (i, n, name) => `(${i}/${n}) Копіювання: ${name}`, done: (ok, pending, fail) => `Готово: успішно ${ok}, не підтверджено ${pending}, з помилками ${fail}. Перевірте пізніше в Dropbox.`, unknownName: '(невідомо)', interceptOpen: 'Перехоплено спливаюче вікно успіху через window.open (нову вкладку не відкриваємо):', interceptLink: 'Перехоплено спливаюче вікно успіху через target=_blank (нову вкладку не відкриваємо):', noCopyButton: 'Кнопку копіювання не знайдено, пропуск:', itemOk: 'Успішно (спливаюче вікно перехоплено):', itemUnconfirmed: 'Натиснуто, але спливаюче вікно не отримано (можливо, уже збережено):', downloadSelected: (n) => `Завантажити вибране (${n})`, downloadStart: (n) => `Починаю завантаження ${n} файл(ів)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Завантаження: ${name}`, downloadDone: (n) => `Готово: розпочато завантажень: ${n}. Якщо браузер запитає дозвіл на завантаження кількох файлів, дозвольте.`, selectAll: 'Вибрати все', }, sv: { saveSelected: (n) => `Spara valda (${n})`, noSelection: 'Markera först minst en fil.', start: (n) => `Startar sekventiell kopiering (${n} fil(er))…`, progress: (i, n, name) => `(${i}/${n}) Kopierar: ${name}`, done: (ok, pending, fail) => `Klart: ${ok} lyckades, ${pending} obekräftade, ${fail} misslyckades. Kontrollera senare i Dropbox.`, unknownName: '(okänd)', interceptOpen: 'Lyckat popup-fönster fångat via window.open (ingen ny flik):', interceptLink: 'Lyckat popup-fönster fångat via target=_blank (ingen ny flik):', noCopyButton: 'Kopieringsknappen hittades inte, hoppar över:', itemOk: 'Lyckades (popup fångad):', itemUnconfirmed: 'Klickade men inget lyckat popup-fönster fångades (troligen sparad):', downloadSelected: (n) => `Ladda ner valda (${n})`, downloadStart: (n) => `Startar nedladdning av ${n} fil(er)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Laddar ner: ${name}`, downloadDone: (n) => `Klart: ${n} nedladdning(ar) startade. Om webbläsaren frågar om tillstånd för flera nedladdningar, tillåt det.`, selectAll: 'Markera alla', }, da: { saveSelected: (n) => `Gem valgte (${n})`, noSelection: 'Markér først mindst én fil.', start: (n) => `Starter sekventiel kopiering (${n} fil(er))…`, progress: (i, n, name) => `(${i}/${n}) Kopierer: ${name}`, done: (ok, pending, fail) => `Færdig: ${ok} lykkedes, ${pending} ubekræftede, ${fail} mislykkedes. Kontroller senare i Dropbox.`, unknownName: '(ukendt)', interceptOpen: 'Succes-popup fanget via window.open (ingen ny fane):', interceptLink: 'Succes-popup fanget via target=_blank (ingen ny fane):', noCopyButton: 'Kopiknap ikke fundet, springes over:', itemOk: 'Lykkede (popup fanget):', itemUnconfirmed: 'Klikket, men ingen succes-popup registreret (sandsynligvis gemt):', downloadSelected: (n) => `Download valgte (${n})`, downloadStart: (n) => `Starter download af ${n} fil(er)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Downloader: ${name}`, downloadDone: (n) => `Færdig: ${n} download(s) startet. Hvis browseren spørger om tilladelse til flere downloads, så tillad det.`, selectAll: 'Markér alle', }, nb: { saveSelected: (n) => `Lagre valgte (${n})`, noSelection: 'Merk minst én fil først.', start: (n) => `Starter sekvensiell kopiering (${n} fil(er))…`, progress: (i, n, name) => `(${i}/${n}) Kopierer: ${name}`, done: (ok, pending, fail) => `Ferdig: ${ok} vellykket, ${pending} ubekreftet, ${fail} mislyktes. Sjekk i Dropbox senere.`, unknownName: '(ukjent)', interceptOpen: 'Suksess-popup fanget via window.open (ingen ny fane):', interceptLink: 'Suksess-popup fanget via target=_blank (ingen ny fane):', noCopyButton: 'Kopiknappen ikke funnet, hopper over:', itemOk: 'Vellykket (popup fanget):', itemUnconfirmed: 'Klikket, men ingen suksess-popup fanget (sannsynligvis lagret):', downloadSelected: (n) => `Last ned valgte (${n})`, downloadStart: (n) => `Starter nedlasting av ${n} fil(er)…`, downloadProgress: (i, n, name) => `(${i}/${n}) Laster ned: ${name}`, downloadDone: (n) => `Ferdig: ${n} nedlasting(er) startet. Hvis nettleseren spør om tillatelse til flere nedlastinger, tillat det.`, selectAll: 'Merk alle', }, id: { saveSelected: (n) => `Simpan Pilihan (${n})`, noSelection: 'Pilih minimal satu berkas terlebih dahulu.', start: (n) => `Memulai penyalinan berurutan (${n} berkas)…`, progress: (i, n, name) => `(${i}/${n}) Menyalin: ${name}`, done: (ok, pending, fail) => `Selesai: ${ok} berhasil, ${pending} belum dikonfirmasi, ${fail} gagal. Silakan periksa di Dropbox nanti.`, unknownName: '(tidak diketahui)', interceptOpen: 'Popup sukses dicegat melalui window.open (tanpa tab baru):', interceptLink: 'Popup sukses dicegat melalui target=_blank (tanpa tab baru):', noCopyButton: 'Tombol salin tidak ditemukan, dilewati:', itemOk: 'Berhasil (popup dicegat):', itemUnconfirmed: 'Diklik tetapi popup sukses tidak tertangkap (mungkin sudah disimpan):', downloadSelected: (n) => `Unduh Pilihan (${n})`, downloadStart: (n) => `Memulai pengunduhan ${n} berkas…`, downloadProgress: (i, n, name) => `(${i}/${n}) Mengunduh: ${name}`, downloadDone: (n) => `Selesai: ${n} pengunduhan dimulai. Jika browser meminta izin untuk mengunduh beberapa berkas, izinkan.`, selectAll: 'Pilih semua', }, ms: { saveSelected: (n) => `Simpan Pilihan (${n})`, noSelection: 'Sila pilih sekurang-kurangnya satu fail dahulu.', start: (n) => `Memulakan salinan berurutan (${n} fail)…`, progress: (i, n, name) => `(${i}/${n}) Menyalin: ${name}`, done: (ok, pending, fail) => `Selesai: ${ok} berjaya, ${pending} belum disahkan, ${fail} gagal. Sila semak dalam Dropbox kemudian.`, unknownName: '(tidak diketahui)', interceptOpen: 'Popup berjaya dipintas melalui window.open (tiada tab baru):', interceptLink: 'Popup berjaya dipintas melalui target=_blank (tiada tab baru):', noCopyButton: 'Butang salin tidak ditemui, dilangkau:', itemOk: 'Berjaya (popup dipintas):', itemUnconfirmed: 'Diklik tetapi popup berjaya tidak dikesan (mungkin sudah disimpan):', downloadSelected: (n) => `Muat Turun Pilihan (${n})`, downloadStart: (n) => `Memulakan muat turun ${n} fail…`, downloadProgress: (i, n, name) => `(${i}/${n}) Memuat turun: ${name}`, downloadDone: (n) => `Selesai: ${n} muat turun dimulakan. Jika pelayar meminta kebenaran untuk memuat turun berbilang fail, benarkan.`, selectAll: 'Pilih semua', }, th: { saveSelected: (n) => `บันทึกที่เลือก (${n})`, noSelection: 'โปรดเลือกไฟล์อย่างน้อยหนึ่งไฟล์ก่อน', start: (n) => `เริ่มคัดลอกตามลำดับ (${n} ไฟล์)…`, progress: (i, n, name) => `(${i}/${n}) กำลังคัดลอก: ${name}`, done: (ok, pending, fail) => `เสร็จสิ้น: สำเร็จ ${ok} ไม่ยืนยัน ${pending} ล้มเหลว ${fail} โปรดตรวจสอบใน Dropbox ภายหลัง`, unknownName: '(ไม่ทราบ)', interceptOpen: 'ดักจับป็อปอัปสำเร็จผ่าน window.open (ไม่เปิดแท็บใหม่):', interceptLink: 'ดักจับป็อปอัปสำเร็จผ่าน target=_blank (ไม่เปิดแท็บใหม่):', noCopyButton: 'ไม่พบปุ่มคัดลอก ข้าม:', itemOk: 'สำเร็จ (ดักจับป็อปอัปแล้ว):', itemUnconfirmed: 'คลิกแล้วแต่ไม่พบป็อปอัปสำเร็จ (อาจบันทึกแล้ว):', downloadSelected: (n) => `ดาวน์โหลดที่เลือก (${n})`, downloadStart: (n) => `เริ่มดาวน์โหลด ${n} ไฟล์…`, downloadProgress: (i, n, name) => `(${i}/${n}) กำลังดาวน์โหลด: ${name}`, downloadDone: (n) => `เสร็จสิ้น: เริ่มดาวน์โหลด ${n} รายการ หากเบราว์เซอร์ถามอนุญาตให้ดาวน์โหลดหลายไฟล์ โปรดอนุญาต`, selectAll: 'เลือกทั้งหมด', }, }; const LOCALE_ALIAS = { en: 'en', ja: 'ja', ko: 'ko', ru: 'ru', uk: 'uk', th: 'th', id: 'id', ms: 'ms', pl: 'pl', de: 'de', fr: 'fr', it: 'it', nl: 'nl', nb: 'nb', no: 'nb', da: 'da', sv: 'sv', es: 'es', pt: 'pt_BR', }; function pickLocale(lang) { const l = String(lang || '').toLowerCase().replace(/_/g, '-'); const base = l.split('-')[0]; if (base === 'zh') return /(hant|tw|hk|mo)/.test(l) ? 'zh_Hant' : 'zh_Hans'; return LOCALE_ALIAS[base] || 'en'; } function detectLocale() { // 页面语言优先(用户在 Dropbox 里实际看到的语言),其次浏览器语言,最后回退英文 let cands = []; try { cands = [].concat( (document.documentElement && document.documentElement.lang) || [], navigator.languages || [], navigator.language || [] ); } catch (e) { cands = []; } for (const raw of cands) { if (!raw) continue; const c = String(raw).toLowerCase().replace(/_/g, '-'); // 未收录的语言不在此处中断,继续看下一个候选(例如页面 lang 未收录时回落到浏览器语言) if (c.indexOf('zh') === 0 || LOCALE_ALIAS[c.split('-')[0]]) return pickLocale(c); } return 'en'; } const T = MESSAGES[detectLocale()] || MESSAGES.en; const log = (...a) => console.log('[DBX Multi-Select]', ...a); // ---------- 条目模型:列表行与网格卡片统一成 { el, link, view } ---------- function currentView() { // 卡片一旦渲染出来就按网格处理,否则按列表;两种视图互斥 const card = document.querySelector(GRID.item); if (card && card.querySelector(GRID.link)) return GRID; return LIST; } function getEntries() { const v = currentView(); return Array.from(document.querySelectorAll(v.item)) .map((el) => ({ el: el, link: el.querySelector(v.link), view: v })) .filter((e) => e.link && e.link.href); // 文件夹等没有文件链接的条目不支持,直接跳过 } function nameOf(entry) { const l = entry && entry.link; return l ? (l.getAttribute('aria-label') || l.textContent || '').trim() : T.unknownName; } // 选中态以文件名链接的 URL 为键 —— 行被 React 重渲染后依然能对上 function keyOfLink(link) { return ((link && link.href) || (link && link.getAttribute('href')) || '').split('#')[0]; } function keyOfEntry(entry) { return keyOfLink(entry.link); } function setRowHighlight(el, on) { el.style.outline = on ? STYLE.rowHighlight : ''; } function makeCheckbox(id) { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'dbx-cb'; if (id) cb.id = id; cb.style.cssText = STYLE.checkbox; // 阻止冒泡:避免勾选动作被行/列头/卡片自己接走(打开预览、触发排序) cb.addEventListener('mousedown', (e) => { e.stopPropagation(); // Shift 连选时浏览器会顺手选中一片文本,挡掉它 if (e.shiftKey) e.preventDefault(); }); cb.addEventListener('click', (e) => e.stopPropagation()); return cb; } const selection = new Map(); // 文件链接 -> 是否选中 let anchorKey = null; // Shift 连选的锚点(最后一次普通点击的文件) function indexOfKey(entries, key) { for (let i = 0; i < entries.length; i++) if (keyOfEntry(entries[i]) === key) return i; return -1; } function setEntryChecked(entry, on) { selection.set(keyOfEntry(entry), on); setRowHighlight(entry.el, on); const box = entry.el.querySelector('.dbx-cb'); if (box) box.checked = on; } function toggleAll(on) { getEntries().forEach((entry) => setEntryChecked(entry, on)); updateCount(); } // 单个文件的复选框:支持 Shift 连选(以最后一次普通点击为锚点) function bindEntryCheckbox(cb, entry) { cb.addEventListener('click', (e) => { e.stopPropagation(); const target = cb.checked; // 浏览器已经翻转过了,这里就是目标状态 const key = keyOfEntry(entry); if (e.shiftKey && anchorKey && anchorKey !== key) { const entries = getEntries(); const a = indexOfKey(entries, anchorKey); const b = indexOfKey(entries, key); if (a >= 0 && b >= 0) { const lo = Math.min(a, b), hi = Math.max(a, b); for (let i = lo; i <= hi; i++) setEntryChecked(entries[i], target); updateCount(); return; // 连选时锚点保持不变,方便继续往同一方向扩展 } } setEntryChecked(entry, target); anchorKey = key; updateCount(); }); } // 记下单元格“原生”的左内边距。本脚本会改写它,若每次都读当前值, // 反复刷新(React 密集重渲染)会把留白一次次越推越宽。 const basePadCache = new WeakMap(); function basePadLeft(cell) { let v = basePadCache.get(cell); if (v === undefined) { v = parseFloat(getComputedStyle(cell).paddingLeft); if (!isFinite(v)) v = 16; basePadCache.set(cell, v); } return v; } function ensureCheckboxes() { getEntries().forEach((entry) => { const key = keyOfEntry(entry); if (entry.view === GRID) { // 网格:复选框浮在预览图内部的左上角 const media = entry.el.querySelector(GRID.media); if (!media || media.querySelector(':scope > .dbx-cb')) return; // 预览图区未必是定位上下文,补上 relative 复选框才钉得住 if (getComputedStyle(media).position === 'static') media.style.position = 'relative'; const cb = makeCheckbox(); cb.style.cssText = STYLE.checkbox + STYLE.gridCheckbox; cb.checked = !!selection.get(key); bindEntryCheckbox(cb, entry); media.appendChild(cb); } else { // 列表:单元格是 display:table-cell,链接本身是行内元素、内部却是块级 div, // 直接插在链接前面会被“块级盒子打断行内内容”挤到单独一行(渲染成贴左上角)。 // 改为绝对定位垂直居中,并把单元格左内边距让出等宽空间(内容整体右移,与表头对齐)。 const cell = entry.link.parentElement; if (!cell || cell.querySelector(':scope > .dbx-cb')) return; if (getComputedStyle(cell).position === 'static') cell.style.position = 'relative'; const base = basePadLeft(cell); const cb = makeCheckbox(); cb.style.cssText = STYLE.checkbox + STYLE.listCheckbox(base + 2); cb.checked = !!selection.get(key); bindEntryCheckbox(cb, entry); cell.style.paddingLeft = (base + LIST_RESERVE) + 'px'; cell.insertBefore(cb, entry.link); } }); } function selectedEntries() { return getEntries().filter((e) => !!selection.get(keyOfEntry(e))); } // ---------- 计数 / 状态提示 ---------- function updateCount() { const sel = selectedEntries().length; const total = getEntries().length; const saveBtn = document.getElementById('dbx-save'); if (saveBtn) { setButtonText(saveBtn, T.saveSelected(sel)); saveBtn.disabled = sel === 0; } const dlBtn = document.getElementById('dbx-download'); if (dlBtn) { setButtonText(dlBtn, T.downloadSelected(sel)); dlBtn.disabled = sel === 0; } const all = document.getElementById('dbx-select-all'); if (all) { all.checked = total > 0 && sel === total; all.indeterminate = sel > 0 && sel < total; } } // 找到按钮内真正承载可见文字的元素(图标等无文字元素会被跳过) function findTextLeaf(el) { const kids = Array.from(el.children || []); if (!kids.length) return (el.textContent || '').trim() ? el : null; for (const k of kids) { const found = findTextLeaf(k); if (found) return found; } return null; } function setButtonText(btn, text) { btn.setAttribute('aria-label', text); const leaf = findTextLeaf(btn); if (leaf) leaf.textContent = text; else btn.textContent = text; } function setStatus(txt) { let t = document.getElementById('dbx-status-toast'); if (!t) { t = document.createElement('div'); t.id = 'dbx-status-toast'; t.style.cssText = STYLE.toast; document.body.appendChild(t); } t.textContent = txt || ''; t.style.display = txt ? 'block' : 'none'; } // ---------- 注入 UI:克隆原生按钮以完全一致地融入页面 ---------- // 每个原生按钮都被各自的 _folderActionBarInnerContainer 包一层, // 按钮之间的间距来自这些「同级容器」,所以我们的按钮也要插在容器这一层。 function closestByClass(el, token) { let p = el; while (p && p !== document.body) { const c = (p.className && p.className.toString()) || ''; if (c.indexOf(token) !== -1) return p; p = p.parentElement; } return null; } function hGap(prev, next) { const a = prev.getBoundingClientRect(); const b = next.getBoundingClientRect(); if (Math.abs(a.top - b.top) > 4) return -1; // 不在同一行,视为不可比 return Math.round(b.left - a.right); } function ensureGap(btn) { // 容器已给出间距就什么都不做;仍贴合时按原生的实际间距补一个外边距 try { const prev = btn.previousElementSibling; if (!prev) return; if (hGap(prev, btn) > 2) return; const parent = btn.parentElement; let native = 0; if (parent) { const kids = Array.from(parent.children).filter((k) => k !== btn); for (let i = 0; i + 1 < kids.length; i++) { const g = hGap(kids[i], kids[i + 1]); if (g > 0) { native = g; break; } } } btn.style.marginLeft = (native > 0 ? native : 8) + 'px'; } catch (e) { btn.style.marginLeft = '8px'; } } // 按优先级取第一个存在的元素(注意:querySelector('A, B') 是文档顺序,不是选择器顺序) function firstOf(sels) { for (let i = 0; i < sels.length; i++) { const el = document.querySelector(sels[i]); if (el) return el; } return null; } // 克隆一个原生操作栏按钮,保证间距 / 内边距 / 图标与官方完全一致 function injectBarButton(id, refSels, onRun, fallbackBottom) { if (document.getElementById(id)) return; const ref = firstOf(refSels); let btn; if (ref) { btn = ref.cloneNode(true); btn.id = id; btn.className = (btn.className || '') + ' dbx-btn'; // 清掉从原生按钮带来的身份与状态,避免和原生按钮混淆 ['data-testid', 'disabled', 'aria-describedby', 'aria-label', 'title'].forEach((a) => btn.removeAttribute(a)); // 紧贴对应的原生按钮(不要跳过别的元素,否则中间会夹进原生按钮) const anchor = closestByClass(ref, BAR.inner) || ref.parentElement; anchor.insertAdjacentElement('afterend', btn); ensureGap(btn); } else { // 兜底:找不到操作栏时放右下角 btn = document.createElement('button'); btn.id = id; btn.className = 'dbx-btn'; btn.style.cssText = STYLE.fallbackBtn(fallbackBottom); document.body.appendChild(btn); } btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // 阻止事件冒泡到页面自身的 React 监听器 onRun(); }); updateCount(); } function injectSaveButton() { // 排在“复制到 Dropbox”右侧;没有该按钮时退到“下载”右侧 injectBarButton('dbx-save', [BAR.copy, BAR.download], saveSelected, 48); } function injectDownloadButton() { // 排在原生“下载”右侧;没有该按钮时退到“复制到 Dropbox”右侧 injectBarButton('dbx-download', [BAR.download, BAR.copy], downloadSelected, 88); } // 宿主必须是「注入时真正挂载到的那个父节点」,判定时才能一一对应, // 否则每次刷新都会误判为“没注入”而反复删除重建 —— 表现出来就是点了没反应。 function selectAllHost() { if (currentView() === LIST) return document.querySelector(LIST.headerName); // 列表:“名称”列头 const ref = firstOf([BAR.download, BAR.copy]); // 网格:顶部操作栏 return ref ? (closestByClass(ref, BAR.inner) || ref.parentElement) : null; } function injectSelectAll() { const host = selectAllHost(); if (!host) return; const existing = document.getElementById('dbx-select-all'); if (existing) { // 已挂在正确宿主上就别动;视图切换导致宿主变了再挪过去 const box = existing.parentElement && existing.parentElement.id === 'dbx-select-all-wrap' ? existing.parentElement : existing; // 列表:挂在列头内;网格:挂在操作栏里(宿主容器的同级) if (box.parentNode === host || box.parentNode === host.parentNode) return; if (box.parentNode) box.parentNode.removeChild(box); } const cb = makeCheckbox('dbx-select-all'); cb.title = T.selectAll; cb.setAttribute('aria-label', T.selectAll); cb.addEventListener('change', () => { getEntries().forEach((entry) => setEntryChecked(entry, cb.checked)); updateCount(); }); if (currentView() === LIST) { host.insertBefore(cb, host.firstChild); // 列表:放进“名称”列头 return; } // 网格:连同一个文字标签一起放进顶部操作栏的最前面(先选择,再执行动作) const wrap = document.createElement('span'); wrap.id = 'dbx-select-all-wrap'; wrap.style.cssText = 'display:inline-flex;align-items:center;gap:6px;margin-right:10px;' + 'font:13px/1.2 sans-serif;white-space:nowrap;'; const label = document.createElement('span'); label.textContent = T.selectAll; wrap.appendChild(cb); wrap.appendChild(label); const row = host.parentNode || host; row.insertBefore(wrap, row.firstChild); } // ---------- 成功弹窗拦截 ---------- let popupResolver = null; let cancelWait = null; let clickGuard = null; let awaitingPopup = false; // 仅在“已点击复制、等待成功”期间拦截 function firePopup(url) { if (popupResolver) { const r = popupResolver; popupResolver = null; r(url); } } function waitForPopup(timeoutMs) { return new Promise((resolve) => { awaitingPopup = true; const t = setTimeout(() => { cancelWait = null; awaitingPopup = false; resolve(null); }, timeoutMs); cancelWait = () => { clearTimeout(t); cancelWait = null; awaitingPopup = false; resolve(null); }; }); } // 复制按钮没点到时,别让“等弹窗”的窗口一直开着,否则会误伤下一个文件 function cancelPopupWait() { if (cancelWait) cancelWait(); } function installInterceptor() { if (!window.__dbxOrigOpen) window.__dbxOrigOpen = window.open.bind(window); window.open = function (url, name, features) { if (!awaitingPopup) return window.__dbxOrigOpen(url, name, features); log(T.interceptOpen, url); firePopup(url); return { focus() {}, close() {}, blur() {}, postMessage() {}, closed: true, location: {} }; }; clickGuard = (e) => { if (!awaitingPopup) return; const a = e.target && e.target.closest ? e.target.closest('a[target="_blank"]') : null; if (!a || !/dropbox\.com\/scl\//i.test(a.href || '')) return; e.preventDefault(); e.stopPropagation(); log(T.interceptLink, a.href); firePopup(a.href); }; document.addEventListener('click', clickGuard, true); } function removeInterceptor() { if (window.__dbxOrigOpen) window.open = window.__dbxOrigOpen; if (clickGuard) { document.removeEventListener('click', clickGuard, true); clickGuard = null; } awaitingPopup = false; popupResolver = null; cancelWait = null; } // ---------- 触发单个文件的复制(全部按 testid 精确定位) ---------- function waitFor(pred, timeoutMs) { return new Promise((resolve) => { const start = Date.now(); const tick = () => { let v = false; try { v = pred(); } catch (e) { v = false; } if (v) return resolve(v); if (Date.now() - start > timeoutMs) return resolve(null); setTimeout(tick, 150); }; tick(); }); } // React 的 onMouseEnter 是由 mouseover/pointerover 委托合成的,两种都发一遍更稳 function fireHover(el) { ['pointerover', 'mouseover', 'mouseenter'].forEach((type) => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); }); } function revealEntry(entry) { fireHover(entry.el); // 列表:hover 操作区;网格:hover 预览图区(遮罩层与溢出菜单都挂在它上面) const target = entry.view === GRID ? entry.el.querySelector(GRID.media) : entry.el.querySelector(LIST.hoverCell); if (target) fireHover(target); } async function clickEntryCopy(entry) { if (entry.view === LIST) { // 首选:行内悬停出现的“复制到 Dropbox”按钮(网格卡片没有这个按钮) const btn = await waitFor(() => entry.el.querySelector(LIST.copyBtn), CONFIG.revealTimeoutMs); if (btn) { btn.click(); return true; } } // 次选:溢出菜单里的复制项 const overflowSel = entry.view === GRID ? GRID.overflowBtn : LIST.overflowBtn; const copySel = entry.view === GRID ? GRID.overflowCopy : LIST.overflowCopy; const overflow = entry.el.querySelector(overflowSel); if (!overflow) return false; overflow.click(); const item = await waitFor( () => document.querySelector(copySel) || (entry.view === GRID ? document.querySelector(GRID.menuCopy) : null), CONFIG.menuTimeoutMs ); if (!item) { closeMenus(); return false; } item.click(); return true; } function closeMenus() { try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); } catch (e) {} } // ---------- 主流程 ---------- async function saveSelected() { const entries = selectedEntries(); if (entries.length === 0) { setStatus(T.noSelection); return; } installInterceptor(); setStatus(T.start(entries.length)); let ok = 0, pending = 0, fail = 0; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const name = nameOf(entry); setStatus(T.progress(i + 1, entries.length, name)); revealEntry(entry); const p = waitForPopup(CONFIG.successTimeoutMs); const clicked = await clickEntryCopy(entry); if (!clicked) { cancelPopupWait(); fail++; log(T.noCopyButton, name); continue; } const result = await p; if (result) { ok++; log(T.itemOk, name); } else { pending++; log(T.itemUnconfirmed, name); } closeMenus(); } removeInterceptor(); setStatus(T.done(ok, pending, fail)); } // ---------- 多选下载:交给浏览器直接下载多个文件 ---------- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } // 分享链接里 dl=0 是预览页,dl=1 才是 Dropbox 官方的“强制下载”参数 function downloadUrlOf(link) { try { const u = new URL(link.href, location.href); u.searchParams.set('dl', '1'); return u.toString(); } catch (e) { const href = link.href || ''; return /[?&]dl=/.test(href) ? href.replace(/([?&]dl=)[^&]*/, '$11') : href + (href.indexOf('?') < 0 ? '?dl=1' : '&dl=1'); } } function triggerDownload(entry) { const a = document.createElement('a'); a.href = downloadUrlOf(entry.link); a.setAttribute('download', nameOf(entry)); // 尽力带上文件名(跨域时浏览器可能忽略) a.rel = 'noopener'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { if (a.parentNode) a.parentNode.removeChild(a); }, 1000); } async function downloadSelected() { const entries = selectedEntries(); if (entries.length === 0) { setStatus(T.noSelection); return; } setStatus(T.downloadStart(entries.length)); let n = 0; for (let i = 0; i < entries.length; i++) { setStatus(T.downloadProgress(i + 1, entries.length, nameOf(entries[i]))); triggerDownload(entries[i]); n++; // 第一个紧跟用户手势同步发出;其余留间隔,否则会被浏览器当成自动下载一次性拦下 if (i > 0) await sleep(CONFIG.downloadGapMs); } setStatus(T.downloadDone(n)); } // ---------- 注入入口 ---------- // 列表由 React 动态渲染(滚动加载、hover 重绘),用 rAF 合并高频变更, // 避免每次 mutation 都把整份列表重扫一遍。 let refreshQueued = false; function refresh() { injectSaveButton(); injectDownloadButton(); injectSelectAll(); ensureCheckboxes(); updateCount(); } function scheduleRefresh() { if (refreshQueued) return; refreshQueued = true; requestAnimationFrame(() => { refreshQueued = false; refresh(); }); } let observer = null; // Ctrl/Cmd + A:全选当前列表(已在全选状态时再按一次则取消) let shortcutsInstalled = false; function installShortcuts() { if (shortcutsInstalled) return; shortcutsInstalled = true; document.addEventListener('keydown', (e) => { if (!(e.ctrlKey || e.metaKey)) return; if (e.code !== 'KeyA' && e.key !== 'a' && e.key !== 'A') return; // 输入态下不抢,用户按的是“全选文本”。 // 注意只能对“可输入文本”的控件让位:复选框/按钮同样是 INPUT 却没有文本可选, // 若一并放行,用户点过任意一个复选框后(焦点落在它身上)Ctrl+A 就会失效。 const t = e.target; if (t && (t.isContentEditable || t.tagName === 'TEXTAREA' || (t.tagName === 'INPUT' && TEXT_INPUT_TYPES.indexOf((t.type || 'text').toLowerCase()) >= 0))) return; const entries = getEntries(); if (!entries.length) return; e.preventDefault(); toggleAll(!entries.every((en) => !!selection.get(keyOfEntry(en)))); }, true); } function initListing() { refresh(); installShortcuts(); if (!observer) { observer = new MutationObserver(scheduleRefresh); observer.observe(document.body, { childList: true, subtree: true }); } } function boot() { let tries = 0; const timer = setInterval(() => { tries++; // 列表和网格都要等到真正渲染出条目为止 if (getEntries().length > 0) { clearInterval(timer); initListing(); } else if (tries > 40) clearInterval(timer); }, 400); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })();