diff --git a/.gitignore b/.gitignore index f7102e1..51872b7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ test-screenshot-*.png debug-screenshot.png screenshot*.png test-results/ +artifacts/ # Local planning artifacts task_plan.md diff --git a/_locales/de/messages.json b/_locales/de/messages.json index aec50c0..6ff4c97 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -827,5 +827,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Gefällt dir Panelize? Bewerte es im Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Bewerten" + }, + "ratingPromptLater": { + "message": "Später" + }, + "ratingPromptNever": { + "message": "Nicht mehr fragen" + }, + "shareButtonText": { + "message": "Teilen" + }, + "shareButtonTitle": { + "message": "Teile einen Schnappschuss dieses Vergleichs. Das Bild bleibt auf deinem Gerät." + }, + "shareDialogTitle": { + "message": "Vergleich teilen" + }, + "shareDialogClose": { + "message": "Schließen" + }, + "sharePrivacyWarning": { + "message": "Prüfe das Bild vor dem Teilen: Es kann Chatverläufe in Seitenleisten, Kontonamen oder Avatare zeigen." + }, + "shareCopyButton": { + "message": "In die Zwischenablage kopieren" + }, + "shareDownloadButton": { + "message": "PNG herunterladen" + }, + "shareCopied": { + "message": "Bild in die Zwischenablage kopiert" + }, + "shareCopyFailed": { + "message": "Das Bild konnte nicht kopiert werden. Nutze stattdessen PNG herunterladen." + }, + "shareCaptureCancelled": { + "message": "Schnappschuss abgebrochen" + }, + "shareCaptureWrongSurface": { + "message": "Teile diesen Panelize-Tab, um einen Schnappschuss zu erstellen" + }, + "shareCaptureTabHidden": { + "message": "Aufnahme abgebrochen: Lass diesen Panelize-Tab im Vordergrund, bis sie fertig ist" + }, + "shareCaptureFailed": { + "message": "Der Vergleich konnte nicht aufgenommen werden" + }, + "shareCaptureLayoutChanged": { + "message": "Die Größe des Fensters oder der Panels hat sich ständig geändert, daher konnte der Vergleich nicht aufgenommen werden. Bitte erneut versuchen." + }, + "shareModeLabel": { + "message": "Bildausschnitt" + }, + "shareModeCurrent": { + "message": "Aktuelle Ansicht" + }, + "shareModeFull": { + "message": "Vollständige Antworten" + }, + "shareTitleLabel": { + "message": "Titel" + }, + "shareTitlePlaceholder": { + "message": "Überschrift hinzufügen (optional)" + }, + "shareSnapshotTagline": { + "message": "KI im direkten Vergleich" + }, + "shareSnapshotPromptLabel": { + "message": "Prompt" + }, + "shareSnapshotCaption": { + "message": "Kostenlose Chrome-Erweiterung" + }, + "shareSnapshotSubline": { + "message": "Gleicher Prompt · $1 · im direkten Vergleich" + }, + "shareTruncatedNote": { + "message": "Zu lang für ein Bild, nur der Anfang wird gezeigt: $1" + }, + "shareInterruptedNote": { + "message": "Konnte nicht bis zum Ende aufgenommen werden, nur der Anfang wird gezeigt: $1" + }, + "shareCurrentViewOnly": { + "message": "Nur aktuelle Ansicht: $1" + }, + "panelResizeHandle": { + "message": "Ziehen, um die Panelbreite zu ändern. Doppelklick zum Zurücksetzen." + }, + "shareOutOfViewNote": { + "message": "Nicht vollständig sichtbar, ausgelassen: $1 — seitlich scrollen und erneut teilen, um sie einzuschließen" + }, + "shareShortcutHint": { + "message": "Tipp: Drücke $1 auf dieser Seite, um die Bestätigung von Chrome zu überspringen" + }, + "shareToolbarHint": { + "message": "Tipp: Klicke auf dieser Seite auf das Panelize-Symbol in der Symbolleiste, um die Bestätigung von Chrome zu überspringen" + }, + "shareProgressPreparing": { + "message": "Schnappschuss wird vorbereitet…" + }, + "shareProgressStep": { + "message": "Bildschirm $1 von $2 wird aufgenommen…" + }, + "shareStreamStatus": { + "message": "Dieser Tab wird für Schnappschüsse geteilt" + }, + "shareStreamStop": { + "message": "Beenden" + }, + "shareProgressHoldPointer": { + "message": "Bitte die Maus kurz nicht bewegen…" + }, + "shareProgressCancel": { + "message": "Abbrechen" + }, + "labelShareWatermark": { + "message": "Wasserzeichen auf geteilten Bildern anzeigen" + }, + "descShareWatermark": { + "message": "Zeigt den Namen Panelize, panelize.dev und das Datum auf Schnappschüssen über die Teilen-Schaltfläche. Überschrift und Rahmen bleiben immer erhalten." + }, + "msgShareWatermarkEnabled": { + "message": "Wasserzeichen auf geteilten Bildern aktiviert" + }, + "msgShareWatermarkDisabled": { + "message": "Wasserzeichen auf geteilten Bildern deaktiviert" } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f11a19f..6347972 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -844,5 +844,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Enjoying Panelize? Rate it on the Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Rate" + }, + "ratingPromptLater": { + "message": "Later" + }, + "ratingPromptNever": { + "message": "Don't ask again" + }, + "shareButtonText": { + "message": "Share" + }, + "shareButtonTitle": { + "message": "Share a snapshot of this comparison. The image stays on your device." + }, + "shareDialogTitle": { + "message": "Share comparison" + }, + "shareDialogClose": { + "message": "Close" + }, + "sharePrivacyWarning": { + "message": "Check the image before you share it: it may show chat history in sidebars, account names or avatars." + }, + "shareCopyButton": { + "message": "Copy to clipboard" + }, + "shareDownloadButton": { + "message": "Download PNG" + }, + "shareCopied": { + "message": "Image copied to clipboard" + }, + "shareCopyFailed": { + "message": "Couldn't copy the image. Use Download PNG instead." + }, + "shareCaptureCancelled": { + "message": "Snapshot cancelled" + }, + "shareCaptureWrongSurface": { + "message": "To take a snapshot, share this Panelize tab" + }, + "shareCaptureTabHidden": { + "message": "Snapshot stopped: keep this Panelize tab in front until it is done" + }, + "shareCaptureFailed": { + "message": "Couldn't capture the comparison" + }, + "shareCaptureLayoutChanged": { + "message": "The window or panels kept changing size, so the comparison couldn't be captured. Try again." + }, + "shareModeLabel": { + "message": "Snapshot area" + }, + "shareModeCurrent": { + "message": "Current view" + }, + "shareModeFull": { + "message": "Full answers" + }, + "shareTitleLabel": { + "message": "Title" + }, + "shareTitlePlaceholder": { + "message": "Add a headline (optional)" + }, + "shareSnapshotTagline": { + "message": "Compare AI side by side" + }, + "shareSnapshotPromptLabel": { + "message": "Prompt" + }, + "shareSnapshotCaption": { + "message": "Free Chrome extension" + }, + "shareSnapshotSubline": { + "message": "Same prompt · $1 · side by side" + }, + "shareTruncatedNote": { + "message": "Too long for one image, only the start is shown: $1" + }, + "shareInterruptedNote": { + "message": "Could not be captured to the end, only the start is shown: $1" + }, + "shareCurrentViewOnly": { + "message": "Current view only: $1" + }, + "panelResizeHandle": { + "message": "Drag to resize panels. Double-click to reset." + }, + "shareOutOfViewNote": { + "message": "Not fully on screen, left out: $1 — scroll sideways and share again to include them" + }, + "shareShortcutHint": { + "message": "Tip: press $1 on this page to skip Chrome's confirmation" + }, + "shareToolbarHint": { + "message": "Tip: click the Panelize toolbar icon on this page to skip Chrome's confirmation" + }, + "shareProgressPreparing": { + "message": "Preparing snapshot…" + }, + "shareProgressStep": { + "message": "Capturing screen $1 of $2…" + }, + "shareStreamStatus": { + "message": "Sharing this tab for snapshots" + }, + "shareStreamStop": { + "message": "Stop" + }, + "shareProgressHoldPointer": { + "message": "Keep the mouse still for a moment…" + }, + "shareProgressCancel": { + "message": "Cancel" + }, + "labelShareWatermark": { + "message": "Show watermark on shared images" + }, + "descShareWatermark": { + "message": "Show the Panelize name, panelize.dev and the date on snapshots taken with the Share button. The headline and frame stay either way." + }, + "msgShareWatermarkEnabled": { + "message": "Watermark on shared images enabled" + }, + "msgShareWatermarkDisabled": { + "message": "Watermark on shared images disabled" } } diff --git a/_locales/es/messages.json b/_locales/es/messages.json index e858b20..98c1b94 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -827,5 +827,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "¿Te gusta Panelize? Valóralo en Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Valorar" + }, + "ratingPromptLater": { + "message": "Más tarde" + }, + "ratingPromptNever": { + "message": "No volver a preguntar" + }, + "shareButtonText": { + "message": "Compartir" + }, + "shareButtonTitle": { + "message": "Comparte una captura de esta comparación. La imagen se queda en tu dispositivo." + }, + "shareDialogTitle": { + "message": "Compartir comparación" + }, + "shareDialogClose": { + "message": "Cerrar" + }, + "sharePrivacyWarning": { + "message": "Revisa la imagen antes de compartirla: puede mostrar historial de chats en barras laterales, nombres de cuenta o avatares." + }, + "shareCopyButton": { + "message": "Copiar al portapapeles" + }, + "shareDownloadButton": { + "message": "Descargar PNG" + }, + "shareCopied": { + "message": "Imagen copiada al portapapeles" + }, + "shareCopyFailed": { + "message": "No se pudo copiar la imagen. Usa Descargar PNG." + }, + "shareCaptureCancelled": { + "message": "Captura cancelada" + }, + "shareCaptureWrongSurface": { + "message": "Para hacer la captura, comparte esta pestaña de Panelize" + }, + "shareCaptureTabHidden": { + "message": "Captura detenida: mantén esta pestaña de Panelize al frente hasta que termine" + }, + "shareCaptureFailed": { + "message": "No se pudo capturar la comparación" + }, + "shareCaptureLayoutChanged": { + "message": "El tamaño de la ventana o de los paneles no dejaba de cambiar, así que no se pudo capturar la comparación. Inténtalo de nuevo." + }, + "shareModeLabel": { + "message": "Área de la captura" + }, + "shareModeCurrent": { + "message": "Vista actual" + }, + "shareModeFull": { + "message": "Respuestas completas" + }, + "shareTitleLabel": { + "message": "Título" + }, + "shareTitlePlaceholder": { + "message": "Añade un titular (opcional)" + }, + "shareSnapshotTagline": { + "message": "Compara IA en paralelo" + }, + "shareSnapshotPromptLabel": { + "message": "Prompt" + }, + "shareSnapshotCaption": { + "message": "Extensión gratuita para Chrome" + }, + "shareSnapshotSubline": { + "message": "Mismo prompt · $1 · en paralelo" + }, + "shareTruncatedNote": { + "message": "Demasiado largo para una imagen, solo se muestra el principio: $1" + }, + "shareInterruptedNote": { + "message": "No se pudo capturar hasta el final, solo se muestra el principio: $1" + }, + "shareCurrentViewOnly": { + "message": "Solo vista actual: $1" + }, + "panelResizeHandle": { + "message": "Arrastra para cambiar el ancho de los paneles. Doble clic para restablecer." + }, + "shareOutOfViewNote": { + "message": "No se ven enteros, omitidos: $1 — desplázate a los lados y vuelve a compartir para incluirlos" + }, + "shareShortcutHint": { + "message": "Consejo: pulsa $1 en esta página para omitir la confirmación de Chrome" + }, + "shareToolbarHint": { + "message": "Consejo: haz clic en el icono de Panelize de la barra de herramientas en esta página para omitir la confirmación de Chrome" + }, + "shareProgressPreparing": { + "message": "Preparando la captura…" + }, + "shareProgressStep": { + "message": "Capturando pantalla $1 de $2…" + }, + "shareStreamStatus": { + "message": "Compartiendo esta pestaña para las capturas" + }, + "shareStreamStop": { + "message": "Detener" + }, + "shareProgressHoldPointer": { + "message": "No muevas el ratón un momento…" + }, + "shareProgressCancel": { + "message": "Cancelar" + }, + "labelShareWatermark": { + "message": "Mostrar marca de agua en las imágenes compartidas" + }, + "descShareWatermark": { + "message": "Muestra el nombre Panelize, panelize.dev y la fecha en las capturas hechas con el botón Compartir. El titular y el marco se mantienen siempre." + }, + "msgShareWatermarkEnabled": { + "message": "Marca de agua en imágenes compartidas activada" + }, + "msgShareWatermarkDisabled": { + "message": "Marca de agua en imágenes compartidas desactivada" } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 651f1d4..707035b 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -827,5 +827,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Vous aimez Panelize ? Notez-le sur le Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Noter" + }, + "ratingPromptLater": { + "message": "Plus tard" + }, + "ratingPromptNever": { + "message": "Ne plus demander" + }, + "shareButtonText": { + "message": "Partager" + }, + "shareButtonTitle": { + "message": "Partagez une capture de cette comparaison. L'image reste sur votre appareil." + }, + "shareDialogTitle": { + "message": "Partager la comparaison" + }, + "shareDialogClose": { + "message": "Fermer" + }, + "sharePrivacyWarning": { + "message": "Vérifiez l'image avant de la partager : elle peut montrer l'historique des discussions dans les barres latérales, des noms de compte ou des avatars." + }, + "shareCopyButton": { + "message": "Copier dans le presse-papiers" + }, + "shareDownloadButton": { + "message": "Télécharger le PNG" + }, + "shareCopied": { + "message": "Image copiée dans le presse-papiers" + }, + "shareCopyFailed": { + "message": "Impossible de copier l'image. Utilisez plutôt Télécharger le PNG." + }, + "shareCaptureCancelled": { + "message": "Capture annulée" + }, + "shareCaptureWrongSurface": { + "message": "Pour faire la capture, partagez cet onglet Panelize" + }, + "shareCaptureTabHidden": { + "message": "Capture interrompue : gardez cet onglet Panelize au premier plan jusqu'à la fin" + }, + "shareCaptureFailed": { + "message": "Impossible de capturer la comparaison" + }, + "shareCaptureLayoutChanged": { + "message": "La taille de la fenêtre ou des panneaux n'arrêtait pas de changer, la comparaison n'a donc pas pu être capturée. Réessayez." + }, + "shareModeLabel": { + "message": "Zone de la capture" + }, + "shareModeCurrent": { + "message": "Vue actuelle" + }, + "shareModeFull": { + "message": "Réponses complètes" + }, + "shareTitleLabel": { + "message": "Titre" + }, + "shareTitlePlaceholder": { + "message": "Ajouter un titre (facultatif)" + }, + "shareSnapshotTagline": { + "message": "Comparez les IA côte à côte" + }, + "shareSnapshotPromptLabel": { + "message": "Prompt" + }, + "shareSnapshotCaption": { + "message": "Extension Chrome gratuite" + }, + "shareSnapshotSubline": { + "message": "Même prompt · $1 · côte à côte" + }, + "shareTruncatedNote": { + "message": "Trop long pour une seule image, seul le début est affiché : $1" + }, + "shareInterruptedNote": { + "message": "Capture impossible jusqu'à la fin, seul le début est affiché : $1" + }, + "shareCurrentViewOnly": { + "message": "Vue actuelle uniquement : $1" + }, + "panelResizeHandle": { + "message": "Faites glisser pour redimensionner les panneaux. Double-cliquez pour réinitialiser." + }, + "shareOutOfViewNote": { + "message": "Pas entièrement visibles, exclus : $1 — faites défiler latéralement et partagez à nouveau pour les inclure" + }, + "shareShortcutHint": { + "message": "Astuce : appuyez sur $1 sur cette page pour éviter la confirmation de Chrome" + }, + "shareToolbarHint": { + "message": "Astuce : cliquez sur l'icône Panelize de la barre d'outils sur cette page pour éviter la confirmation de Chrome" + }, + "shareProgressPreparing": { + "message": "Préparation de la capture…" + }, + "shareProgressStep": { + "message": "Capture de l'écran $1 sur $2…" + }, + "shareStreamStatus": { + "message": "Cet onglet est partagé pour les captures" + }, + "shareStreamStop": { + "message": "Arrêter" + }, + "shareProgressHoldPointer": { + "message": "Ne bougez pas la souris un instant…" + }, + "shareProgressCancel": { + "message": "Annuler" + }, + "labelShareWatermark": { + "message": "Afficher un filigrane sur les images partagées" + }, + "descShareWatermark": { + "message": "Affiche le nom Panelize, panelize.dev et la date sur les captures prises avec le bouton Partager. Le titre et le cadre restent dans tous les cas." + }, + "msgShareWatermarkEnabled": { + "message": "Filigrane des images partagées activé" + }, + "msgShareWatermarkDisabled": { + "message": "Filigrane des images partagées désactivé" } } diff --git a/_locales/it/messages.json b/_locales/it/messages.json index bf0442d..2cafcf0 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -827,5 +827,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Ti piace Panelize? Valutalo sul Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Valuta" + }, + "ratingPromptLater": { + "message": "Più tardi" + }, + "ratingPromptNever": { + "message": "Non chiedere più" + }, + "shareButtonText": { + "message": "Condividi" + }, + "shareButtonTitle": { + "message": "Condividi un'istantanea di questo confronto. L'immagine resta sul tuo dispositivo." + }, + "shareDialogTitle": { + "message": "Condividi confronto" + }, + "shareDialogClose": { + "message": "Chiudi" + }, + "sharePrivacyWarning": { + "message": "Controlla l'immagine prima di condividerla: potrebbe mostrare la cronologia delle chat nelle barre laterali, nomi account o avatar." + }, + "shareCopyButton": { + "message": "Copia negli appunti" + }, + "shareDownloadButton": { + "message": "Scarica PNG" + }, + "shareCopied": { + "message": "Immagine copiata negli appunti" + }, + "shareCopyFailed": { + "message": "Impossibile copiare l'immagine. Usa Scarica PNG." + }, + "shareCaptureCancelled": { + "message": "Istantanea annullata" + }, + "shareCaptureWrongSurface": { + "message": "Per creare l'istantanea, condividi questa scheda di Panelize" + }, + "shareCaptureTabHidden": { + "message": "Acquisizione interrotta: tieni questa scheda di Panelize in primo piano fino al termine" + }, + "shareCaptureFailed": { + "message": "Impossibile acquisire il confronto" + }, + "shareCaptureLayoutChanged": { + "message": "Le dimensioni della finestra o dei pannelli continuavano a cambiare, quindi non è stato possibile acquisire il confronto. Riprova." + }, + "shareModeLabel": { + "message": "Area dell'istantanea" + }, + "shareModeCurrent": { + "message": "Vista attuale" + }, + "shareModeFull": { + "message": "Risposte complete" + }, + "shareTitleLabel": { + "message": "Titolo" + }, + "shareTitlePlaceholder": { + "message": "Aggiungi un titolo (facoltativo)" + }, + "shareSnapshotTagline": { + "message": "Confronta le IA fianco a fianco" + }, + "shareSnapshotPromptLabel": { + "message": "Prompt" + }, + "shareSnapshotCaption": { + "message": "Estensione gratuita per Chrome" + }, + "shareSnapshotSubline": { + "message": "Stesso prompt · $1 · fianco a fianco" + }, + "shareTruncatedNote": { + "message": "Troppo lungo per un'unica immagine, è mostrato solo l'inizio: $1" + }, + "shareInterruptedNote": { + "message": "Non è stato possibile acquisire fino alla fine, è mostrato solo l'inizio: $1" + }, + "shareCurrentViewOnly": { + "message": "Solo vista attuale: $1" + }, + "panelResizeHandle": { + "message": "Trascina per ridimensionare i pannelli. Doppio clic per ripristinare." + }, + "shareOutOfViewNote": { + "message": "Non del tutto visibili, esclusi: $1 — scorri lateralmente e condividi di nuovo per includerli" + }, + "shareShortcutHint": { + "message": "Suggerimento: premi $1 in questa pagina per saltare la conferma di Chrome" + }, + "shareToolbarHint": { + "message": "Suggerimento: fai clic sull'icona di Panelize nella barra degli strumenti in questa pagina per saltare la conferma di Chrome" + }, + "shareProgressPreparing": { + "message": "Preparazione dell'istantanea…" + }, + "shareProgressStep": { + "message": "Acquisizione della schermata $1 di $2…" + }, + "shareStreamStatus": { + "message": "Questa scheda è condivisa per le istantanee" + }, + "shareStreamStop": { + "message": "Interrompi" + }, + "shareProgressHoldPointer": { + "message": "Non muovere il mouse per un attimo…" + }, + "shareProgressCancel": { + "message": "Annulla" + }, + "labelShareWatermark": { + "message": "Mostra filigrana sulle immagini condivise" + }, + "descShareWatermark": { + "message": "Mostra il nome Panelize, panelize.dev e la data sulle istantanee create con il pulsante Condividi. Titolo e cornice restano in ogni caso." + }, + "msgShareWatermarkEnabled": { + "message": "Filigrana sulle immagini condivise attivata" + }, + "msgShareWatermarkDisabled": { + "message": "Filigrana sulle immagini condivise disattivata" } } diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index f5d2526..059f5cd 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -827,5 +827,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Panelize を気に入っていただけましたか?Chrome ウェブストアで評価をお願いします" + }, + "ratingPromptRate": { + "message": "評価する" + }, + "ratingPromptLater": { + "message": "後で" + }, + "ratingPromptNever": { + "message": "今後表示しない" + }, + "shareButtonText": { + "message": "共有" + }, + "shareButtonTitle": { + "message": "この比較のスナップショットを共有します。画像はお使いのデバイスにのみ保存されます。" + }, + "shareDialogTitle": { + "message": "比較を共有" + }, + "shareDialogClose": { + "message": "閉じる" + }, + "sharePrivacyWarning": { + "message": "共有する前に画像を確認してください。サイドバーのチャット履歴、アカウント名、アバターが写っている場合があります。" + }, + "shareCopyButton": { + "message": "クリップボードにコピー" + }, + "shareDownloadButton": { + "message": "PNG をダウンロード" + }, + "shareCopied": { + "message": "画像をクリップボードにコピーしました" + }, + "shareCopyFailed": { + "message": "画像をコピーできませんでした。代わりに「PNG をダウンロード」を使用してください。" + }, + "shareCaptureCancelled": { + "message": "スナップショットをキャンセルしました" + }, + "shareCaptureWrongSurface": { + "message": "スナップショットを撮るには、この Panelize タブを共有してください" + }, + "shareCaptureTabHidden": { + "message": "スナップショットを中止しました:完了するまでこの Panelize タブを表示したままにしてください" + }, + "shareCaptureFailed": { + "message": "比較画面をキャプチャできませんでした" + }, + "shareCaptureLayoutChanged": { + "message": "ウィンドウやパネルのサイズが変わり続けたため、比較画面をキャプチャできませんでした。もう一度お試しください" + }, + "shareModeLabel": { + "message": "撮影範囲" + }, + "shareModeCurrent": { + "message": "現在の表示" + }, + "shareModeFull": { + "message": "回答全体" + }, + "shareTitleLabel": { + "message": "タイトル" + }, + "shareTitlePlaceholder": { + "message": "見出しを追加(任意)" + }, + "shareSnapshotTagline": { + "message": "AI を並べて比較" + }, + "shareSnapshotPromptLabel": { + "message": "プロンプト" + }, + "shareSnapshotCaption": { + "message": "無料の Chrome 拡張機能" + }, + "shareSnapshotSubline": { + "message": "同じプロンプト · $1 · 並べて比較" + }, + "shareTruncatedNote": { + "message": "回答が長すぎるため、画像には冒頭のみ表示:$1" + }, + "shareInterruptedNote": { + "message": "最後までキャプチャできなかったため、冒頭のみ表示:$1" + }, + "shareCurrentViewOnly": { + "message": "現在の表示のみ:$1" + }, + "panelResizeHandle": { + "message": "ドラッグでパネル幅を調整、ダブルクリックでリセット" + }, + "shareOutOfViewNote": { + "message": "全体が表示されていないため除外:$1 — 横にスクロールしてもう一度共有すると含められます" + }, + "shareShortcutHint": { + "message": "ヒント:このページで $1 を押すと Chrome の確認を省略できます" + }, + "shareToolbarHint": { + "message": "ヒント:このページでツールバーの Panelize アイコンをクリックすると Chrome の確認を省略できます" + }, + "shareProgressPreparing": { + "message": "スナップショットを準備しています…" + }, + "shareProgressStep": { + "message": "$2 画面中 $1 画面目を撮影しています…" + }, + "shareStreamStatus": { + "message": "スナップショット用にこのタブを共有中" + }, + "shareStreamStop": { + "message": "停止" + }, + "shareProgressHoldPointer": { + "message": "少しの間マウスを動かさないでください…" + }, + "shareProgressCancel": { + "message": "キャンセル" + }, + "labelShareWatermark": { + "message": "共有画像に透かしを表示" + }, + "descShareWatermark": { + "message": "「共有」ボタンで作成したスナップショットに Panelize の名前、panelize.dev、日付を表示します。見出しと枠は常に表示されます。" + }, + "msgShareWatermarkEnabled": { + "message": "共有画像の透かしを有効にしました" + }, + "msgShareWatermarkDisabled": { + "message": "共有画像の透かしを無効にしました" } } diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 9347a2a..7b64769 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -827,5 +827,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Panelize가 마음에 드시나요? Chrome 웹 스토어에서 평가해 주세요" + }, + "ratingPromptRate": { + "message": "평가하기" + }, + "ratingPromptLater": { + "message": "나중에" + }, + "ratingPromptNever": { + "message": "다시 묻지 않기" + }, + "shareButtonText": { + "message": "공유" + }, + "shareButtonTitle": { + "message": "이 비교의 스냅샷을 공유합니다. 이미지는 기기에만 저장됩니다." + }, + "shareDialogTitle": { + "message": "비교 공유" + }, + "shareDialogClose": { + "message": "닫기" + }, + "sharePrivacyWarning": { + "message": "공유하기 전에 이미지를 확인하세요. 사이드바의 채팅 기록, 계정 이름 또는 아바타가 포함될 수 있습니다." + }, + "shareCopyButton": { + "message": "클립보드에 복사" + }, + "shareDownloadButton": { + "message": "PNG 다운로드" + }, + "shareCopied": { + "message": "이미지를 클립보드에 복사했습니다" + }, + "shareCopyFailed": { + "message": "이미지를 복사할 수 없습니다. 대신 PNG 다운로드를 사용하세요." + }, + "shareCaptureCancelled": { + "message": "스냅샷이 취소되었습니다" + }, + "shareCaptureWrongSurface": { + "message": "스냅샷을 찍으려면 이 Panelize 탭을 공유하세요" + }, + "shareCaptureTabHidden": { + "message": "스냅샷이 중단됨: 완료될 때까지 이 Panelize 탭을 앞에 두세요" + }, + "shareCaptureFailed": { + "message": "비교 화면을 캡처할 수 없습니다" + }, + "shareCaptureLayoutChanged": { + "message": "창이나 패널 크기가 계속 바뀌어 비교 화면을 캡처할 수 없습니다. 다시 시도해 주세요" + }, + "shareModeLabel": { + "message": "캡처 범위" + }, + "shareModeCurrent": { + "message": "현재 화면" + }, + "shareModeFull": { + "message": "전체 답변" + }, + "shareTitleLabel": { + "message": "제목" + }, + "shareTitlePlaceholder": { + "message": "헤드라인 추가(선택 사항)" + }, + "shareSnapshotTagline": { + "message": "AI를 나란히 비교" + }, + "shareSnapshotPromptLabel": { + "message": "프롬프트" + }, + "shareSnapshotCaption": { + "message": "무료 Chrome 확장 프로그램" + }, + "shareSnapshotSubline": { + "message": "같은 프롬프트 · $1 · 나란히 비교" + }, + "shareTruncatedNote": { + "message": "답변이 너무 길어 이미지에 앞부분만 표시됨: $1" + }, + "shareInterruptedNote": { + "message": "끝까지 캡처하지 못해 앞부분만 표시됨: $1" + }, + "shareCurrentViewOnly": { + "message": "현재 화면만: $1" + }, + "panelResizeHandle": { + "message": "드래그하여 패널 너비 조정, 더블클릭하여 초기화" + }, + "shareOutOfViewNote": { + "message": "화면에 다 보이지 않아 제외됨: $1 — 좌우로 스크롤한 뒤 다시 공유하면 포함됩니다" + }, + "shareShortcutHint": { + "message": "팁: 이 페이지에서 $1 키를 누르면 Chrome 확인을 건너뛸 수 있습니다" + }, + "shareToolbarHint": { + "message": "팁: 이 페이지에서 도구 모음의 Panelize 아이콘을 클릭하면 Chrome 확인을 건너뛸 수 있습니다" + }, + "shareProgressPreparing": { + "message": "스냅샷 준비 중…" + }, + "shareProgressStep": { + "message": "$2개 화면 중 $1번째 캡처 중…" + }, + "shareStreamStatus": { + "message": "스냅샷용으로 이 탭을 공유하는 중" + }, + "shareStreamStop": { + "message": "중지" + }, + "shareProgressHoldPointer": { + "message": "잠시 마우스를 움직이지 마세요…" + }, + "shareProgressCancel": { + "message": "취소" + }, + "labelShareWatermark": { + "message": "공유 이미지에 워터마크 표시" + }, + "descShareWatermark": { + "message": "공유 버튼으로 만든 스냅샷에 Panelize 이름, panelize.dev, 날짜를 표시합니다. 헤드라인과 프레임은 항상 유지됩니다." + }, + "msgShareWatermarkEnabled": { + "message": "공유 이미지 워터마크가 켜졌습니다" + }, + "msgShareWatermarkDisabled": { + "message": "공유 이미지 워터마크가 꺼졌습니다" } } diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 02e0feb..26749e6 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -827,5 +827,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Нравится Panelize? Оцените его в Интернет-магазине Chrome" + }, + "ratingPromptRate": { + "message": "Оценить" + }, + "ratingPromptLater": { + "message": "Позже" + }, + "ratingPromptNever": { + "message": "Больше не спрашивать" + }, + "shareButtonText": { + "message": "Поделиться" + }, + "shareButtonTitle": { + "message": "Поделитесь снимком этого сравнения. Изображение остаётся на вашем устройстве." + }, + "shareDialogTitle": { + "message": "Поделиться сравнением" + }, + "shareDialogClose": { + "message": "Закрыть" + }, + "sharePrivacyWarning": { + "message": "Проверьте изображение перед отправкой: на нём может быть история чатов в боковых панелях, имена аккаунтов или аватары." + }, + "shareCopyButton": { + "message": "Копировать в буфер обмена" + }, + "shareDownloadButton": { + "message": "Скачать PNG" + }, + "shareCopied": { + "message": "Изображение скопировано в буфер обмена" + }, + "shareCopyFailed": { + "message": "Не удалось скопировать изображение. Используйте «Скачать PNG»." + }, + "shareCaptureCancelled": { + "message": "Снимок отменён" + }, + "shareCaptureWrongSurface": { + "message": "Чтобы сделать снимок, выберите эту вкладку Panelize" + }, + "shareCaptureTabHidden": { + "message": "Снимок прерван: не уходите с этой вкладки Panelize, пока он не будет готов" + }, + "shareCaptureFailed": { + "message": "Не удалось сделать снимок сравнения" + }, + "shareCaptureLayoutChanged": { + "message": "Размер окна или панелей всё время менялся, поэтому снимок сравнения не удался. Попробуйте ещё раз." + }, + "shareModeLabel": { + "message": "Область снимка" + }, + "shareModeCurrent": { + "message": "Текущий вид" + }, + "shareModeFull": { + "message": "Полные ответы" + }, + "shareTitleLabel": { + "message": "Заголовок" + }, + "shareTitlePlaceholder": { + "message": "Добавьте заголовок (необязательно)" + }, + "shareSnapshotTagline": { + "message": "Сравнивайте ИИ бок о бок" + }, + "shareSnapshotPromptLabel": { + "message": "Промпт" + }, + "shareSnapshotCaption": { + "message": "Бесплатное расширение для Chrome" + }, + "shareSnapshotSubline": { + "message": "Один промпт · $1 · бок о бок" + }, + "shareTruncatedNote": { + "message": "Слишком длинный ответ, на изображении показано только начало: $1" + }, + "shareInterruptedNote": { + "message": "Не удалось захватить до конца, показано только начало: $1" + }, + "shareCurrentViewOnly": { + "message": "Только текущий вид: $1" + }, + "panelResizeHandle": { + "message": "Перетащите, чтобы изменить ширину панелей. Двойной щелчок — сброс." + }, + "shareOutOfViewNote": { + "message": "Не видны целиком, пропущены: $1 — прокрутите вбок и поделитесь снова, чтобы их добавить" + }, + "shareShortcutHint": { + "message": "Совет: нажмите $1 на этой странице, чтобы пропустить подтверждение Chrome" + }, + "shareToolbarHint": { + "message": "Совет: нажмите на значок Panelize на панели инструментов на этой странице, чтобы пропустить подтверждение Chrome" + }, + "shareProgressPreparing": { + "message": "Подготовка снимка…" + }, + "shareProgressStep": { + "message": "Снимаем экран $1 из $2…" + }, + "shareStreamStatus": { + "message": "Эта вкладка транслируется для снимков" + }, + "shareStreamStop": { + "message": "Остановить" + }, + "shareProgressHoldPointer": { + "message": "Не двигайте мышь несколько секунд…" + }, + "shareProgressCancel": { + "message": "Отмена" + }, + "labelShareWatermark": { + "message": "Показывать водяной знак на изображениях" + }, + "descShareWatermark": { + "message": "Показывать название Panelize, panelize.dev и дату на снимках, созданных кнопкой «Поделиться». Заголовок и рамка остаются всегда." + }, + "msgShareWatermarkEnabled": { + "message": "Водяной знак на изображениях включён" + }, + "msgShareWatermarkDisabled": { + "message": "Водяной знак на изображениях выключен" } } diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index b6801d4..3e8e08a 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -823,5 +823,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "喜欢 Panelize 吗?欢迎在 Chrome 应用商店给它评个分" + }, + "ratingPromptRate": { + "message": "去评分" + }, + "ratingPromptLater": { + "message": "以后再说" + }, + "ratingPromptNever": { + "message": "不再询问" + }, + "shareButtonText": { + "message": "分享" + }, + "shareButtonTitle": { + "message": "分享当前对比的截图,图片只保存在你的设备上。" + }, + "shareDialogTitle": { + "message": "分享对比截图" + }, + "shareDialogClose": { + "message": "关闭" + }, + "sharePrivacyWarning": { + "message": "分享前请检查图片:其中可能包含侧边栏的聊天记录、账号名称或头像。" + }, + "shareCopyButton": { + "message": "复制到剪贴板" + }, + "shareDownloadButton": { + "message": "下载 PNG" + }, + "shareCopied": { + "message": "图片已复制到剪贴板" + }, + "shareCopyFailed": { + "message": "无法复制图片,请改用“下载 PNG”。" + }, + "shareCaptureCancelled": { + "message": "已取消截图" + }, + "shareCaptureWrongSurface": { + "message": "要截图,请选择共享当前 Panelize 标签页" + }, + "shareCaptureTabHidden": { + "message": "截图已停止:完成前请保持在此 Panelize 标签页" + }, + "shareCaptureFailed": { + "message": "无法截取对比画面" + }, + "shareCaptureLayoutChanged": { + "message": "窗口或面板大小一直在变,无法截取对比画面,请再试一次" + }, + "shareModeLabel": { + "message": "截图范围" + }, + "shareModeCurrent": { + "message": "当前视图" + }, + "shareModeFull": { + "message": "完整回答" + }, + "shareTitleLabel": { + "message": "标题" + }, + "shareTitlePlaceholder": { + "message": "添加标题(可选)" + }, + "shareSnapshotTagline": { + "message": "并排对比 AI" + }, + "shareSnapshotPromptLabel": { + "message": "提示词" + }, + "shareSnapshotCaption": { + "message": "免费 Chrome 扩展" + }, + "shareSnapshotSubline": { + "message": "同一提示词 · $1 · 并排对比" + }, + "shareTruncatedNote": { + "message": "回答太长,图片中只显示了开头部分:$1" + }, + "shareInterruptedNote": { + "message": "页面未能截到结尾,只显示了开头部分:$1" + }, + "shareCurrentViewOnly": { + "message": "仅当前视图:$1" + }, + "panelResizeHandle": { + "message": "拖动可调整面板宽度,双击恢复默认" + }, + "shareOutOfViewNote": { + "message": "未完整显示,未截入:$1 — 左右滚动后重新截图即可包含" + }, + "shareShortcutHint": { + "message": "提示:在此页面按 $1 即可跳过 Chrome 的确认" + }, + "shareToolbarHint": { + "message": "提示:在此页面点击工具栏中的 Panelize 图标即可跳过 Chrome 的确认" + }, + "shareProgressPreparing": { + "message": "正在准备截图…" + }, + "shareProgressStep": { + "message": "正在截取第 $1 屏,共 $2 屏…" + }, + "shareStreamStatus": { + "message": "正在共享此标签页,用于截图" + }, + "shareStreamStop": { + "message": "停止" + }, + "shareProgressHoldPointer": { + "message": "请保持鼠标不动片刻…" + }, + "shareProgressCancel": { + "message": "取消" + }, + "labelShareWatermark": { + "message": "在分享图片上显示水印" + }, + "descShareWatermark": { + "message": "在“分享”按钮生成的截图上显示 Panelize 名称、panelize.dev 和日期。标题和外框始终保留。" + }, + "msgShareWatermarkEnabled": { + "message": "已开启分享图片水印" + }, + "msgShareWatermarkDisabled": { + "message": "已关闭分享图片水印" } } diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index 39e3004..211d9f8 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -823,5 +823,137 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "喜歡 Panelize 嗎?歡迎到 Chrome 線上應用程式商店為它評分" + }, + "ratingPromptRate": { + "message": "去評分" + }, + "ratingPromptLater": { + "message": "稍後再說" + }, + "ratingPromptNever": { + "message": "不要再詢問" + }, + "shareButtonText": { + "message": "分享" + }, + "shareButtonTitle": { + "message": "分享目前比較的截圖,圖片只會保存在你的裝置上。" + }, + "shareDialogTitle": { + "message": "分享比較截圖" + }, + "shareDialogClose": { + "message": "關閉" + }, + "sharePrivacyWarning": { + "message": "分享前請檢查圖片:其中可能包含側邊欄的聊天記錄、帳號名稱或頭像。" + }, + "shareCopyButton": { + "message": "複製到剪貼簿" + }, + "shareDownloadButton": { + "message": "下載 PNG" + }, + "shareCopied": { + "message": "圖片已複製到剪貼簿" + }, + "shareCopyFailed": { + "message": "無法複製圖片,請改用「下載 PNG」。" + }, + "shareCaptureCancelled": { + "message": "已取消截圖" + }, + "shareCaptureWrongSurface": { + "message": "要截圖,請選擇分享目前的 Panelize 分頁" + }, + "shareCaptureTabHidden": { + "message": "截圖已停止:完成前請保持在此 Panelize 分頁" + }, + "shareCaptureFailed": { + "message": "無法擷取比較畫面" + }, + "shareCaptureLayoutChanged": { + "message": "視窗或面板大小一直在變,無法擷取比較畫面,請再試一次" + }, + "shareModeLabel": { + "message": "截圖範圍" + }, + "shareModeCurrent": { + "message": "目前檢視" + }, + "shareModeFull": { + "message": "完整回答" + }, + "shareTitleLabel": { + "message": "標題" + }, + "shareTitlePlaceholder": { + "message": "新增標題(選填)" + }, + "shareSnapshotTagline": { + "message": "並排比較 AI" + }, + "shareSnapshotPromptLabel": { + "message": "提示詞" + }, + "shareSnapshotCaption": { + "message": "免費 Chrome 擴充功能" + }, + "shareSnapshotSubline": { + "message": "同一提示詞 · $1 · 並排比較" + }, + "shareTruncatedNote": { + "message": "回答太長,圖片中只顯示了開頭部分:$1" + }, + "shareInterruptedNote": { + "message": "頁面未能截到結尾,只顯示了開頭部分:$1" + }, + "shareCurrentViewOnly": { + "message": "僅目前檢視:$1" + }, + "panelResizeHandle": { + "message": "拖曳可調整面板寬度,按兩下恢復預設" + }, + "shareOutOfViewNote": { + "message": "未完整顯示,未截入:$1 — 左右捲動後重新截圖即可包含" + }, + "shareShortcutHint": { + "message": "提示:在此頁面按 $1 即可略過 Chrome 的確認" + }, + "shareToolbarHint": { + "message": "提示:在此頁面點擊工具列中的 Panelize 圖示即可略過 Chrome 的確認" + }, + "shareProgressPreparing": { + "message": "正在準備截圖…" + }, + "shareProgressStep": { + "message": "正在擷取第 $1 個畫面,共 $2 個…" + }, + "shareStreamStatus": { + "message": "正在分享此分頁,用於截圖" + }, + "shareStreamStop": { + "message": "停止" + }, + "shareProgressHoldPointer": { + "message": "請讓滑鼠保持不動片刻…" + }, + "shareProgressCancel": { + "message": "取消" + }, + "labelShareWatermark": { + "message": "在分享圖片上顯示浮水印" + }, + "descShareWatermark": { + "message": "在「分享」按鈕產生的截圖上顯示 Panelize 名稱、panelize.dev 和日期。標題和外框一律保留。" + }, + "msgShareWatermarkEnabled": { + "message": "已開啟分享圖片浮水印" + }, + "msgShareWatermarkDisabled": { + "message": "已關閉分享圖片浮水印" } } diff --git a/background/service-worker.js b/background/service-worker.js index 821b599..fb5c403 100644 --- a/background/service-worker.js +++ b/background/service-worker.js @@ -1,6 +1,7 @@ import { notifyMessage } from '../modules/messaging.js'; import { t, initializeLanguage } from '../modules/i18n.js'; import { migrateEnabledProvidersOnUpdate } from '../modules/provider-defaults.js'; +import { SHARE_SNAPSHOT_ACTION, isMultiPanelTab } from '../modules/share-trigger.js'; import { removeDeprecatedClaudeModelMode } from '../modules/settings.js'; import { reconcileOptionalProviderAccess @@ -308,6 +309,24 @@ function dispatchToMultiPanel(action, payload) { }, 500); } +/** + * On the Multi-Panel page itself, invoking the extension shares a snapshot + * of it: this gesture is what grants activeTab for a silent capture. + * @returns {Promise} Whether the page was asked to share. + */ +async function requestShareSnapshotOnMultiPanel(tab) { + if (!(await isMultiPanelTab(tab))) { + return false; + } + notifyMessage({ + action: SHARE_SNAPSHOT_ACTION, + payload: { tabId: tab.id } + }).catch(() => { + // The page may still be loading; the user can invoke it again + }); + return true; +} + // Context menu click handler - opens Multi-Panel and sends message chrome.contextMenus.onClicked.addListener(async (info, tab) => { try { @@ -315,6 +334,10 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { return; } + if (await requestShareSnapshotOnMultiPanel(tab)) { + return; + } + // 先打开 Multi-Panel 窗口 await openMultiPanel(); @@ -333,6 +356,10 @@ chrome.action.onClicked.addListener(async (tab) => { return; } + if (await requestShareSnapshotOnMultiPanel(tab)) { + return; + } + await openMultiPanel(); }); diff --git a/content-scripts/text-injection-all-providers.js b/content-scripts/text-injection-all-providers.js index a3ba01c..c3cf099 100644 --- a/content-scripts/text-injection-all-providers.js +++ b/content-scripts/text-injection-all-providers.js @@ -15,6 +15,78 @@ const PANELIZE_TEMP_CHAT_ENABLED = 'PANELIZE_TEMP_CHAT_ENABLED'; const PANELIZE_PROVIDER_LOCATION = 'PANELIZE_PROVIDER_LOCATION'; const PANELIZE_RESTORE_USER_FOCUS = 'PANELIZE_RESTORE_USER_FOCUS'; + const PANELIZE_SNAPSHOT_PREPARE = 'PANELIZE_SNAPSHOT_PREPARE'; + const PANELIZE_SNAPSHOT_SCROLL = 'PANELIZE_SNAPSHOT_SCROLL'; + const PANELIZE_SNAPSHOT_RESTORE = 'PANELIZE_SNAPSHOT_RESTORE'; + const PANELIZE_SNAPSHOT_STATE = 'PANELIZE_SNAPSHOT_STATE'; + const MULTI_PANEL_SNAPSHOT_CONTEXT = 'multi-panel-snapshot'; + const PANELIZE_PAGE_WIDTH = 'PANELIZE_PAGE_WIDTH'; + const PAGE_WIDTH_REPORT_THROTTLE_MS = 500; + // The app shell is near the top of the tree; deeper nodes are content. + const PAGE_WIDTH_MAX_DEPTH = 12; + const PAGE_WIDTH_MAX_ELEMENTS = 1000; + const PAGE_WIDTH_SCROLLING_OVERFLOW = /^(auto|scroll)$/; + // Normalized prompt prefix that identifies the user's message. + const SNAPSHOT_MATCH_KEY_LENGTH = 40; + const SNAPSHOT_MIN_MATCH_KEY_LENGTH = 2; + const SNAPSHOT_MAX_MATCH_CANDIDATES = 50; + const SNAPSHOT_ALIGN_MARGIN_PX = 16; + const SNAPSHOT_PAINT_SETTLE_MS = 100; + const SNAPSHOT_PAINT_TIMEOUT_MS = 400; + // A conversation list fills a good part of the frame; smaller scrolling + // boxes are code blocks or collapsed messages. + const SNAPSHOT_MIN_CONTAINER_HEIGHT_RATIO = 0.3; + // Pinned bars cover at most this share of the container at each edge. + const SNAPSHOT_MAX_EDGE_RATIO = 0.4; + const SNAPSHOT_OVERLAY_MIN_WIDTH_RATIO = 0.5; + const SNAPSHOT_EDGE_TOLERANCE_PX = 24; + // Floating buttons and fades hidden while stitching are at most this share + // of the visible conversation high; taller layers are backgrounds. + const SNAPSHOT_MAX_FLOATING_HEIGHT_RATIO = 0.5; + const SNAPSHOT_HIDDEN_MASK_PROPERTIES = ['mask-image', '-webkit-mask-image']; + // Ancestors with these cut off what lies outside them. + const SNAPSHOT_CLIPPING_OVERFLOW = /^(hidden|clip|auto|scroll|overlay)$/; + const SNAPSHOT_SKIPPED_TEXT_PARENTS = [ + 'script', 'style', 'noscript', 'template', 'textarea', 'input', 'select', + '[contenteditable=""]', '[contenteditable="true"]', '[aria-hidden="true"]' + ].join(', '); + // Chat history lists and links repeat prompts as titles; they are not the + // conversation. + const SNAPSHOT_NAVIGATION_PARENTS = 'nav, aside, [role="navigation"], [role="complementary"], a[href]'; + // User messages by role attribute or known bubble, for when the prompt is + // not on the page (e.g. typed into the provider directly). + const SNAPSHOT_USER_TURN_SELECTORS = [ + '[data-message-author-role="user"]', // ChatGPT + '[data-testid="user-message"]', // Claude + 'user-query', // Gemini + '[class*="items-end"] .message-bubble', // Grok + '[data-role="user"]', + '[data-author="user"]', + '[data-message-role="user"]', + '[data-sender="user"]', + '[data-content="user-message"]', // Copilot + '[data-testid="send_message"]', // Doubao + '.chat-content-item-user', // Kimi + '.chat-user-message' // Qwen + ].join(', '); + // How far an aligned message may be from where it was put. + const SNAPSHOT_ALIGN_TOLERANCE_PX = 2; + // Paints to wait for a virtual list to grow back to the position it had. + const SNAPSHOT_RESTORE_ATTEMPTS = 10; + // Inline properties set to take a sticky box out of sticking, keeping its + // place in the flow. + const SNAPSHOT_UNSTICK_PROPERTIES = ['position', 'top', 'right', 'bottom', 'left']; + // Marks pseudo-elements to hide (e.g. "before after") and the document + // whose pointer is hidden; styled by SNAPSHOT_STYLE_RULES. + const SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE = 'data-panelize-snapshot-hide'; + const SNAPSHOT_HIDE_CURSOR_ATTRIBUTE = 'data-panelize-snapshot-cursor'; + const SNAPSHOT_STYLE_RULES = ` + [${SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE}~="before"]::before, + [${SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE}~="after"]::after { visibility: hidden !important; } + [${SNAPSHOT_HIDE_CURSOR_ATTRIBUTE}], [${SNAPSHOT_HIDE_CURSOR_ATTRIBUTE}] *, + [${SNAPSHOT_HIDE_CURSOR_ATTRIBUTE}] *::before, [${SNAPSHOT_HIDE_CURSOR_ATTRIBUTE}] *::after { cursor: none !important; } + `; + const SNAPSHOT_STYLE_ELEMENT_ID = 'panelize-snapshot-style'; const USER_FOCUS_RESTORE_WINDOW_MS = 2000; const CHATGPT_STOP_BUTTON_SELECTOR = 'button[data-testid="stop-button"]'; const CHATGPT_SEND_TRACKING_IDLE_DELAY_MS = 800; @@ -43,6 +115,10 @@ let lastUserFocus = null; const pendingKimiImageUploads = new Map(); const pendingProviderImageUploads = new Map(); + // Scroll container and positions of the snapshot being taken. + let snapshotSession = null; + // Constructed sheet with SNAPSHOT_STYLE_RULES, adopted while a snapshot runs. + let snapshotStyleSheet = null; // Provider-specific selectors. ChatGPT's composer lookup lives in // chatgpt-composer.js, shared with the other ChatGPT content scripts. @@ -628,6 +704,117 @@ reportIfChanged(); } + function isMultiPanelFrame() { + if (window.parent === window || window.parent !== window.top) { + return false; + } + const embedder = window.location.ancestorOrigins?.[0]; + return embedder === undefined || /^(chrome|moz)-extension:/.test(embedder); + } + + /** + * Boxes filling the viewport hold the app itself, even when they scroll or + * float; so do those filling most of it beside a side rail, e.g. Qwen's + * content box next to its rail of icons. + */ + function isPageShellBox(rect, viewportWidth, viewportHeight) { + return rect.right >= viewportWidth - 1 && rect.width >= viewportWidth / 2 + && rect.height >= viewportHeight / 2; + } + + /** + * Width the page's layout needs, in CSS pixels: the furthest an element + * held open by a pixel min-width reaches from where it starts. Only the app + * shell is scanned: floating layers (menus, popovers) and content of + * scrolling boxes (chat lists, code blocks, tables) are left out. It does + * not depend on the viewport width, so a page laid out at the width it + * needs keeps asking for it. Returns 0 when nothing is held open. + */ + function measurePageNeededWidth() { + const viewportWidth = document.documentElement.clientWidth || window.innerWidth; + const viewportHeight = document.documentElement.clientHeight || window.innerHeight; + if (!document.body || !(viewportWidth > 0)) { + return 0; + } + let needed = 0; + let visited = 0; + const queue = Array.from(document.body.children, (element) => ({ element, depth: 1 })); + for (let index = 0; index < queue.length && visited < PAGE_WIDTH_MAX_ELEMENTS; index += 1) { + const { element, depth } = queue[index]; + visited += 1; + const style = window.getComputedStyle(element); + if (style.display === 'none') { + continue; + } + const rect = element.getBoundingClientRect(); + const shell = isPageShellBox(rect, viewportWidth, viewportHeight); + if ((style.position === 'absolute' || style.position === 'fixed') && !shell) { + continue; + } + const minWidth = style.minWidth.endsWith('px') ? parseFloat(style.minWidth) : 0; + const startsOnScreen = rect.left >= -1 && rect.left < viewportWidth - 1; + if (minWidth > 0 && rect.height > 0 && startsOnScreen && style.visibility !== 'hidden') { + needed = Math.max(needed, Math.ceil(rect.left + window.scrollX + minWidth)); + } + const scrolls = PAGE_WIDTH_SCROLLING_OVERFLOW.test(style.overflowX) + || PAGE_WIDTH_SCROLLING_OVERFLOW.test(style.overflowY); + if (depth < PAGE_WIDTH_MAX_DEPTH && (shell || !scrolls)) { + for (const child of element.children) { + queue.push({ element: child, depth: depth + 1 }); + } + } + } + return needed; + } + + /** + * Tells the multi-panel host how wide this page needs to be, so a panel + * narrower than that can scale the page down to fit instead of cutting it. + */ + function setupPageWidthReporting() { + if (!isMultiPanelFrame() || typeof ResizeObserver !== 'function') { + return; + } + let lastReported = null; + let timer = null; + + const report = () => { + timer = null; + // Snapshots move content around; measure once they are done. + if (snapshotSession) { + schedule(); + return; + } + const provider = detectProvider(); + const neededWidth = measurePageNeededWidth(); + if (!provider || neededWidth === lastReported) { + return; + } + lastReported = neededWidth; + window.parent.postMessage({ + type: PANELIZE_PAGE_WIDTH, + provider, + neededWidth, + context: MULTI_PANEL_PROVIDER_STATUS_CONTEXT + }, '*'); + }; + + function schedule() { + if (timer === null) { + timer = setTimeout(report, PAGE_WIDTH_REPORT_THROTTLE_MS); + } + } + + new ResizeObserver(schedule).observe(document.documentElement); + new MutationObserver(schedule).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style'] + }); + schedule(); + } + function rememberUserFocus() { const entry = { element: null, at: Date.now() }; lastUserFocus = entry; @@ -3231,6 +3418,10 @@ return; } + if (handleSnapshotMessage(event)) { + return; + } + if (event.data.type === PANELIZE_RESTORE_USER_FOCUS && event.data.context === 'multi-panel') { restoreUserFocus(); return; @@ -3463,7 +3654,1022 @@ } } + // ===== Share snapshot: align and scroll the conversation for a capture ===== + // The multi-panel page captures the whole tab, so all it needs from a + // provider frame is where the conversation scrolls and which part of it is + // covered by the provider's own header and composer. Only geometry is sent + // back; no page text leaves the frame. + + function normalizeSnapshotMatchText(text) { + return String(text || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[\s​-‍⁠*_`]+/g, ''); + } + + function getSnapshotMatchKey(prompt) { + return Array.from(normalizeSnapshotMatchText(prompt)).slice(0, SNAPSHOT_MATCH_KEY_LENGTH).join(''); + } + + function getSnapshotViewport() { + return { + width: window.innerWidth || document.documentElement?.clientWidth || 0, + height: window.innerHeight || document.documentElement?.clientHeight || 0 + }; + } + + function isSnapshotDocumentScroller(element) { + return !element || element === document.scrollingElement + || element === document.documentElement || element === document.body; + } + + function isSnapshotScrollable(element) { + if (isSnapshotDocumentScroller(element)) { + const scroller = document.scrollingElement || document.documentElement; + return Boolean(scroller) && scroller.scrollHeight > scroller.clientHeight + 1; + } + const overflowY = window.getComputedStyle(element).overflowY; + return /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight + 1; + } + + function isSnapshotElementRendered(element) { + if (typeof element.checkVisibility === 'function') { + return element.checkVisibility(); + } + return true; + } + + function getSnapshotContainerRect(container) { + const viewport = getSnapshotViewport(); + if (isSnapshotDocumentScroller(container)) { + return { left: 0, top: 0, right: viewport.width, bottom: viewport.height }; + } + // Client box: excludes borders and scrollbars. + const rect = container.getBoundingClientRect(); + const left = rect.left + container.clientLeft; + const top = rect.top + container.clientTop; + const box = { + left: Math.max(0, left), + top: Math.max(0, top), + right: Math.min(viewport.width, left + container.clientWidth), + bottom: Math.min(viewport.height, top + container.clientHeight) + }; + // Only what clipping ancestors show, e.g. not the part of a box scrolled + // sideways that is hidden under the side rail beside it. + for (let node = container.parentElement; node && !isSnapshotDocumentScroller(node); node = node.parentElement) { + const style = window.getComputedStyle(node); + if (!SNAPSHOT_CLIPPING_OVERFLOW.test(style.overflowX) && !SNAPSHOT_CLIPPING_OVERFLOW.test(style.overflowY)) { + continue; + } + const nodeRect = node.getBoundingClientRect(); + const nodeLeft = nodeRect.left + node.clientLeft; + const nodeTop = nodeRect.top + node.clientTop; + box.left = Math.max(box.left, nodeLeft); + box.top = Math.max(box.top, nodeTop); + box.right = Math.min(box.right, nodeLeft + node.clientWidth); + box.bottom = Math.min(box.bottom, nodeTop + node.clientHeight); + } + return box; + } + + function isLargeSnapshotContainer(element) { + const viewport = getSnapshotViewport(); + if (isSnapshotDocumentScroller(element)) { + return true; + } + const rect = getSnapshotContainerRect(element); + return rect.bottom - rect.top >= viewport.height * SNAPSHOT_MIN_CONTAINER_HEIGHT_RATIO; + } + + /** + * The scrolling ancestor that moves the element: the main conversation + * when it holds the element, otherwise the large ancestor that scrolls the + * furthest, so that a wrapper scrolling a few pixels is not taken for the + * conversation. + * @param {Element} element + * @param {Element} main - findMainSnapshotScrollContainer(). + */ + function findSnapshotScrollContainer(element, main) { + const ancestors = []; + for (let node = element; node && !isSnapshotDocumentScroller(node); node = node.parentElement) { + if (isSnapshotScrollable(node) && isLargeSnapshotContainer(node)) { + ancestors.push(node); + } + } + if (isSnapshotScrollable(null)) { + ancestors.push(document.scrollingElement || document.documentElement); + } + if (ancestors.includes(main)) { + return main; + } + return ancestors.reduce((best, node) => ( + !best || getSnapshotMaxScrollTop(node) > getSnapshotMaxScrollTop(best) ? node : best + ), null); + } + + /** Largest visible scrolling element; the document when nothing else scrolls. */ + function findMainSnapshotScrollContainer() { + let best = null; + let bestArea = 0; + for (const element of document.body?.querySelectorAll('*') || []) { + if (!isSnapshotScrollable(element) || !isLargeSnapshotContainer(element)) { + continue; + } + const rect = getSnapshotContainerRect(element); + const area = Math.max(0, rect.right - rect.left) * Math.max(0, rect.bottom - rect.top); + if (area > bestArea) { + best = element; + bestArea = area; + } + } + return best || document.scrollingElement || document.documentElement; + } + + /** Whether the element can be the start of a turn in the conversation. */ + function isSnapshotConversationElement(element) { + if (!isSnapshotElementRendered(element) || element.closest(SNAPSHOT_NAVIGATION_PARENTS)) { + return false; + } + // Fixed boxes (dialogs, toasts) do not scroll with the conversation. + for (let node = element; node && node !== document.body; node = node.parentElement) { + if (window.getComputedStyle(node).position === 'fixed') { + return false; + } + } + return true; + } + + function collectSnapshotTextIndex() { + const segments = []; + let text = ''; + if (!document.body) { + return { segments, text }; + } + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent || parent.closest(SNAPSHOT_SKIPPED_TEXT_PARENTS)) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const normalized = normalizeSnapshotMatchText(node.nodeValue); + if (normalized) { + segments.push({ node, start: text.length }); + text += normalized; + } + } + return { segments, text }; + } + + function findSnapshotSegment(segments, offset) { + let low = 0; + let high = segments.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (segments[middle].start <= offset) { + low = middle; + } else { + high = middle - 1; + } + } + return segments[low]; + } + + /** + * Rendered messages that start like the prompt, best first: a match in a + * known user message beats any other, e.g. an answer heading repeating the + * question; then a match at the start of a text node (a message bubble) + * beats one in the middle of a paragraph, e.g. an answer quoting it; then + * a match in the main conversation beats one elsewhere; then the latest wins. + * @returns {{element: Element, container: Element}[]} + */ + function findSnapshotPromptMatches(prompt, main) { + const key = getSnapshotMatchKey(prompt); + if (Array.from(key).length < SNAPSHOT_MIN_MATCH_KEY_LENGTH) { + return []; + } + const { segments, text } = collectSnapshotTextIndex(); + const candidates = []; + let offset = text.lastIndexOf(key); + for (let checked = 0; offset !== -1 && checked < SNAPSHOT_MAX_MATCH_CANDIDATES; checked += 1) { + const segment = findSnapshotSegment(segments, offset); + const element = segment.node.parentElement; + if (element && isSnapshotConversationElement(element)) { + const container = findSnapshotScrollContainer(element, main); + if (container) { + const rank = (element.closest(SNAPSHOT_USER_TURN_SELECTORS) ? 0 : 4) + + (segment.start === offset ? 0 : 2) + (container === main ? 0 : 1); + candidates.push({ element, container, rank }); + } + } + offset = offset > 0 ? text.lastIndexOf(key, offset - 1) : -1; + } + // Array sort is stable, so the latest stays first within a rank. + return candidates.sort((a, b) => a.rank - b.rank); + } + + /** The latest user message found by role attributes or known bubbles. */ + function findSnapshotUserTurn(main) { + const elements = Array.from(document.querySelectorAll(SNAPSHOT_USER_TURN_SELECTORS)); + for (let index = elements.length - 1; index >= 0; index -= 1) { + const element = elements[index]; + if (!isSnapshotConversationElement(element)) { + continue; + } + const container = findSnapshotScrollContainer(element, main); + if (container) { + return { element, container }; + } + } + return null; + } + + function getSnapshotScrollTop(container) { + return isSnapshotDocumentScroller(container) ? window.scrollY : container.scrollTop; + } + + function getSnapshotMaxScrollTop(container) { + const scroller = isSnapshotDocumentScroller(container) + ? document.scrollingElement || document.documentElement + : container; + return Math.max(0, scroller.scrollHeight - scroller.clientHeight); + } + + function clampSnapshotScrollTop(container, top) { + return Math.max(0, Math.min(getSnapshotMaxScrollTop(container), Math.round(top))); + } + + function scrollSnapshotContainer(container, top) { + const target = clampSnapshotScrollTop(container, top); + // "instant" overrides a smooth scroll-behavior set by the page. + if (isSnapshotDocumentScroller(container)) { + window.scrollTo({ top: target, behavior: 'instant' }); + } else if (typeof container.scrollTo === 'function') { + container.scrollTo({ top: target, behavior: 'instant' }); + } else { + container.scrollTop = target; + } + } + + function isSnapshotScrolledContent(element, container) { + // Absolute elements scroll with the content when their containing block + // is the scroll container or inside it. + const containingBlock = element.offsetParent; + if (!containingBlock) { + return false; + } + if (isSnapshotDocumentScroller(container)) { + return true; + } + return containingBlock === container || container.contains(containingBlock); + } + + /** + * Visible part of the container minus bars pinned over its top or bottom: + * sticky or fixed headers, the composer and its fade. Narrow floating + * buttons are ignored. + */ + function getSnapshotClip(container, provider) { + const rect = getSnapshotContainerRect(container); + const clip = { ...rect }; + const height = rect.bottom - rect.top; + const width = rect.right - rect.left; + if (height <= 0 || width <= 0) { + return clip; + } + const maxEdge = height * SNAPSHOT_MAX_EDGE_RATIO; + const scope = isSnapshotDocumentScroller(container) ? document.body : container; + const candidates = Array.from(scope?.querySelectorAll('*') || []); + if (!isSnapshotDocumentScroller(container)) { + // Fixed bars elsewhere in the page can still cover the container. + candidates.push(...Array.from(document.body.querySelectorAll('*')).filter((element) => ( + !container.contains(element) && !element.contains(container) + ))); + } + + const pinBar = (barRect) => { + if (barRect.width < width * SNAPSHOT_OVERLAY_MIN_WIDTH_RATIO || barRect.height <= 0) { + return; + } + if (barRect.bottom <= rect.top || barRect.top >= rect.bottom + || barRect.right <= rect.left || barRect.left >= rect.right) { + return; + } + if (barRect.top <= rect.top + SNAPSHOT_EDGE_TOLERANCE_PX && barRect.bottom - rect.top <= maxEdge) { + clip.top = Math.max(clip.top, barRect.bottom); + } else if (barRect.bottom >= rect.bottom - SNAPSHOT_EDGE_TOLERANCE_PX && rect.bottom - barRect.top <= maxEdge) { + clip.bottom = Math.min(clip.bottom, barRect.top); + } + }; + + // Absolute bars outside the container that sit over it, so that their + // own layers count too, e.g. a header's fading backdrop. + const overlayingBars = new Set(); + const isInOverlayingBar = (element) => { + for (let node = element.parentElement; node; node = node.parentElement) { + if (overlayingBars.has(node)) { + return true; + } + } + return false; + }; + + for (const element of candidates) { + const style = window.getComputedStyle(element); + const position = style.position; + if (position !== 'sticky' && position !== 'fixed' && position !== 'absolute') { + continue; + } + if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') { + continue; + } + if (position === 'absolute' && container.contains(element) && isSnapshotScrolledContent(element, container)) { + continue; + } + if (position === 'absolute' && !container.contains(element)) { + // Absolute siblings only count when they sit over the container. + // Layers without pointer events are not hit, so they count through + // the bar they belong to. + const sample = document.elementFromPoint?.( + Math.round((rect.left + rect.right) / 2), + Math.round(element.getBoundingClientRect().top + 1) + ); + if (sample && element.contains(sample)) { + overlayingBars.add(element); + } else if (!isInOverlayingBar(element)) { + continue; + } + } + pinBar(element.getBoundingClientRect()); + } + + // The composer, also when it is a plain block laid over the conversation. + const input = findSnapshotComposerInput(provider); + if (input) { + let box = null; + for (let node = input; node && node !== container && !node.contains(container); node = node.parentElement) { + const nodeRect = node.getBoundingClientRect(); + if (nodeRect.height > maxEdge || nodeRect.width > width + 2) { + break; + } + box = nodeRect; + } + if (box && box.top < clip.bottom && box.bottom > rect.top + height - maxEdge + && box.right > rect.left && box.left < rect.right) { + clip.bottom = Math.min(clip.bottom, box.top); + } + } + + if (clip.bottom - clip.top < height * (1 - SNAPSHOT_MAX_EDGE_RATIO * 2)) { + // Something went wrong (e.g. a full-height overlay); keep the container. + return { ...rect }; + } + return { + left: Math.round(clip.left), + top: Math.round(clip.top), + right: Math.round(clip.right), + bottom: Math.round(clip.bottom) + }; + } + + function findSnapshotComposerInput(provider) { + if (provider === 'google') { + return findGoogleInput(GOOGLE_PROVIDER_MODE_AI); + } + return provider ? findProviderInput(provider) : null; + } + + /** + * The outermost box that keeps the element put while the container + * scrolls: an ancestor inside the container that is fixed, stuck to an + * edge, or absolute against a box outside the scrolled content. For an + * element outside the container, its outermost ancestor that does not + * hold the container. + * @returns {Element|null} Null when the element scrolls with the content. + */ + function getSnapshotPinningNode(element, container) { + const outside = !isSnapshotDocumentScroller(container) && !container.contains(element); + const containerRect = getSnapshotContainerRect(container); + let pinning = null; + for (let node = element; node && node !== container && node !== document.body && !node.contains(container); + node = node.parentElement) { + if (outside) { + pinning = node; + continue; + } + const position = window.getComputedStyle(node).position; + if (position === 'fixed') { + pinning = node; + } else if (position === 'sticky') { + // Sticky boxes in the middle of the content (e.g. code block + // headers) scroll like the rest until they reach an edge. + const rect = node.getBoundingClientRect(); + if (Math.abs(rect.top - containerRect.top) <= SNAPSHOT_EDGE_TOLERANCE_PX + || Math.abs(rect.bottom - containerRect.bottom) <= SNAPSHOT_EDGE_TOLERANCE_PX) { + pinning = node; + } + } else if (position === 'absolute' && !isSnapshotDocumentScroller(container) + && !isSnapshotScrolledContent(node, container)) { + pinning = node; + } + } + return pinning; + } + + function isSnapshotPinned(element, container) { + return Boolean(getSnapshotPinningNode(element, container)); + } + + function overlapsSnapshotClip(rect, clip) { + return rect.width > 0 && rect.height > 0 + && rect.bottom > clip.top && rect.top < clip.bottom && rect.right > clip.left && rect.left < clip.right; + } + + function hasSnapshotReadableText(element) { + // Icon fonts use private-use characters, which are not letters. + return /[\p{L}\p{N}]/u.test(element.textContent || ''); + } + + function saveSnapshotInlineStyle(element, property) { + return { + element, + property, + value: element.style.getPropertyValue(property), + priority: element.style.getPropertyPriority(property) + }; + } + + function restoreSnapshotInlineStyles(savedStyles) { + // Newest first, so a property saved twice ends up as it started. + for (const { element, property, value, priority } of [...savedStyles].reverse()) { + if (value) { + element.style.setProperty(property, value, priority); + } else { + element.style.removeProperty(property); + } + } + } + + /** + * Adds SNAPSHOT_STYLE_RULES to the page. A constructed sheet is not + * subject to the page's style-src policy; a style element is the fallback. + */ + function installSnapshotStyles() { + try { + if (typeof CSSStyleSheet === 'function' && Array.isArray(document.adoptedStyleSheets)) { + if (!snapshotStyleSheet) { + snapshotStyleSheet = new CSSStyleSheet(); + snapshotStyleSheet.replaceSync(SNAPSHOT_STYLE_RULES); + } + if (!document.adoptedStyleSheets.includes(snapshotStyleSheet)) { + document.adoptedStyleSheets = [...document.adoptedStyleSheets, snapshotStyleSheet]; + } + return; + } + } catch (error) { + console.warn('[Snapshot] Constructed stylesheet unavailable, using a style element:', error); + } + if (!document.getElementById(SNAPSHOT_STYLE_ELEMENT_ID)) { + const style = document.createElement('style'); + style.id = SNAPSHOT_STYLE_ELEMENT_ID; + style.textContent = SNAPSHOT_STYLE_RULES; + (document.head || document.documentElement).appendChild(style); + } + } + + function removeSnapshotStyles() { + if (snapshotStyleSheet && Array.isArray(document.adoptedStyleSheets)) { + document.adoptedStyleSheets = document.adoptedStyleSheets.filter((sheet) => sheet !== snapshotStyleSheet); + } + document.getElementById(SNAPSHOT_STYLE_ELEMENT_ID)?.remove(); + } + + /** + * Whether a pseudo-element is a fade or shadow laid over the page, e.g. + * Gemini's gradient drawn by the composer's ::before over the bottom of + * the conversation. + */ + function isSnapshotFadePseudo(element, pseudo) { + const style = window.getComputedStyle(element, `::${pseudo}`); + if (!style?.content || style.content === 'none' || style.content === 'normal' || style.display === 'none') { + return false; + } + if (!/^(absolute|fixed|sticky)$/.test(style.position)) { + return false; + } + return (style.backgroundImage || '').includes('gradient') + || (Boolean(style.boxShadow) && style.boxShadow !== 'none'); + } + + /** Hides the element's fading pseudo-elements. @returns {boolean} Whether anything new was hidden. */ + function hideSnapshotFadePseudos(session, element) { + const marked = (element.getAttribute(SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE) || '').split(/\s+/).filter(Boolean); + const missing = ['before', 'after'].filter((pseudo) => !marked.includes(pseudo) && isSnapshotFadePseudo(element, pseudo)); + if (missing.length === 0) { + return false; + } + if (!session.hiddenPseudos.has(element)) { + session.hiddenPseudos.set(element, element.getAttribute(SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE)); + } + installSnapshotStyles(); + element.setAttribute(SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE, [...marked, ...missing].join(' ')); + return true; + } + + /** + * Takes sticky boxes in the middle of the conversation out of sticking, + * e.g. section headings and table headers, which would otherwise be pinned + * over every stitched screen. Boxes at the very start or end of the + * conversation are page chrome (a header, the composer dock) that the clip + * leaves out, so they keep sticking. An un-stuck box keeps its place in + * the flow, so nothing else moves and the scroll height stays the same. + * Runs after every scroll, because pages render parts as they show up. + * @returns {boolean} Whether anything new was un-stuck. + */ + function unstickSnapshotContent(session) { + const { container } = session; + const isDocument = isSnapshotDocumentScroller(container); + const scope = isDocument ? document.body : container; + const scroller = isDocument ? document.scrollingElement || document.documentElement : container; + // Top of the scrolled content, in viewport pixels. + const contentTop = getSnapshotContainerRect(container).top - getSnapshotScrollTop(container); + const input = findSnapshotComposerInput(session.provider); + let changed = false; + for (const element of scope?.querySelectorAll('*') || []) { + if (session.unstuckElements.has(element) || session.keptStickyElements.has(element) || !element.style) { + continue; + } + const style = window.getComputedStyle(element); + if (style.position !== 'sticky') { + continue; + } + if (input && element.contains(input)) { + session.keptStickyElements.add(element); + continue; + } + const saved = SNAPSHOT_UNSTICK_PROPERTIES.map((property) => saveSnapshotInlineStyle(element, property)); + element.style.setProperty('position', 'relative', 'important'); + SNAPSHOT_UNSTICK_PROPERTIES.slice(1).forEach((property) => element.style.setProperty(property, 'auto', 'important')); + // Where it sits in the flow, now that it no longer sticks. + const rect = element.getBoundingClientRect(); + const top = rect.top - contentTop; + if (top <= SNAPSHOT_EDGE_TOLERANCE_PX || top + rect.height >= scroller.scrollHeight - SNAPSHOT_EDGE_TOLERANCE_PX) { + restoreSnapshotInlineStyles(saved); + session.keptStickyElements.add(element); + continue; + } + session.savedStyles.push(...saved); + session.unstuckElements.add(element); + // Shadows and fades only made sense while it was stuck over the text. + if (style.boxShadow && style.boxShadow !== 'none') { + session.savedStyles.push(saveSnapshotInlineStyle(element, 'box-shadow')); + element.style.setProperty('box-shadow', 'none', 'important'); + } + hideSnapshotFadePseudos(session, element); + changed = true; + } + return changed; + } + + /** + * Hides what would repeat on every stitched screen or clutter a current + * view: buttons and fades pinned over the visible conversation (e.g. + * "Scroll to bottom"), shadows of bars around it, fades drawn by + * pseudo-elements of the composer or of the conversation's boxes, notices + * stacked on a composer laid over it (e.g. "You've used 90% of your + * limit"), overlay scrollbars, and masks that fade the edges of the + * conversation. While stitching, pinned text goes too (e.g. a heading + * kept on screen); a current view keeps it. The composer itself stays and + * layout is kept. Runs after every scroll, because some buttons only show + * up once the conversation is no longer at the bottom. + * @returns {boolean} Whether anything new was hidden. + */ + function hideSnapshotFloatingElements(session) { + const { container, clip } = session; + const clipHeight = clip.bottom - clip.top; + if (!container || clipHeight <= 0 || clip.right <= clip.left) { + return false; + } + const hidden = session.hiddenElements; + let changed = false; + const hide = (element) => { + hidden.add(element); + session.savedStyles.push(saveSnapshotInlineStyle(element, 'visibility')); + element.style.setProperty('visibility', 'hidden', 'important'); + changed = true; + }; + + // Overlay scrollbars show up after every scroll; they take no room, so + // hiding them does not move the content. + const scroller = isSnapshotDocumentScroller(container) + ? document.scrollingElement || document.documentElement + : container; + if (!session.hidesScrollbar) { + const style = window.getComputedStyle(scroller); + const borders = (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0); + if (scroller.offsetWidth > 0 && scroller.offsetWidth - scroller.clientWidth <= borders) { + session.hidesScrollbar = true; + session.savedStyles.push(saveSnapshotInlineStyle(scroller, 'scrollbar-width')); + scroller.style.setProperty('scrollbar-width', 'none', 'important'); + changed = true; + } + } + + // Boxes whose pseudo-elements may be fades over the conversation: the + // conversation and its ancestors, the composer and its ancestors, and + // whatever is pinned or was un-stuck. + const input = findSnapshotComposerInput(session.provider); + const fadeHosts = new Set(session.unstuckElements); + for (let node = container; node && node.style; node = node.parentElement) { + fadeHosts.add(node); + if (session.unmaskedElements.has(node)) { + continue; + } + const style = window.getComputedStyle(node); + const masked = SNAPSHOT_HIDDEN_MASK_PROPERTIES.some((property) => { + const value = style.getPropertyValue(property); + return value && value !== 'none'; + }); + if (masked) { + session.unmaskedElements.add(node); + SNAPSHOT_HIDDEN_MASK_PROPERTIES.forEach((property) => { + session.savedStyles.push(saveSnapshotInlineStyle(node, property)); + node.style.setProperty(property, 'none', 'important'); + }); + changed = true; + } + } + for (let node = input?.parentElement; node && node !== document.body; node = node.parentElement) { + fadeHosts.add(node); + } + + // Shadows cast by bars next to the clip, e.g. under a header, fall on + // the edge of every screen. + const containerRect = getSnapshotContainerRect(container); + const nearClip = { + left: containerRect.left, + right: containerRect.right, + top: clip.top - SNAPSHOT_EDGE_TOLERANCE_PX, + bottom: clip.bottom + SNAPSHOT_EDGE_TOLERANCE_PX + }; + for (const element of document.body?.querySelectorAll('*') || []) { + if (hidden.has(element) || element === container || element.contains(container) + || (session.matchElement && element.contains(session.matchElement))) { + continue; + } + const style = window.getComputedStyle(element); + const position = style.position; + if (position !== 'sticky' && position !== 'fixed' && position !== 'absolute') { + continue; + } + if (style.display === 'none' || !isSnapshotPinned(element, container)) { + continue; + } + fadeHosts.add(element); + const rect = element.getBoundingClientRect(); + if (style.boxShadow && style.boxShadow !== 'none' && !session.quietedElements.has(element) + && overlapsSnapshotClip(rect, nearClip)) { + session.quietedElements.add(element); + session.savedStyles.push(saveSnapshotInlineStyle(element, 'box-shadow')); + element.style.setProperty('box-shadow', 'none', 'important'); + changed = true; + } + if (rect.height > clipHeight * SNAPSHOT_MAX_FLOATING_HEIGHT_RATIO || !overlapsSnapshotClip(rect, clip)) { + continue; + } + if (input && element.contains(input)) { + continue; + } + const isButton = element.matches('button, [role="button"]'); + if (!session.stitch && !isButton && hasSnapshotReadableText(element)) { + continue; + } + hide(element); + } + + for (const element of fadeHosts) { + if (element.style && hideSnapshotFadePseudos(session, element)) { + changed = true; + } + } + + // The clip ends at the input box, so whatever else the pinned composer + // shows over the conversation sits next to the input's ancestors. + const dock = input && getSnapshotPinningNode(input, container); + if (dock) { + for (let node = input; node !== dock; node = node.parentElement) { + for (const sibling of node.parentElement.children) { + if (sibling !== node && !hidden.has(sibling) && sibling.style + && !(session.matchElement && sibling.contains(session.matchElement)) + && window.getComputedStyle(sibling).display !== 'none' + && overlapsSnapshotClip(sibling.getBoundingClientRect(), clip)) { + hide(sibling); + } + } + } + } + return changed; + } + + /** Puts back every style, mark and pointer a snapshot changed; scroll positions stay. */ + function restoreSnapshotPage(session) { + if (!session) { + return; + } + restoreSnapshotInlineStyles(session.savedStyles); + session.savedStyles = []; + session.hiddenPseudos.forEach((value, element) => { + if (value === null) { + element.removeAttribute(SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE); + } else { + element.setAttribute(SNAPSHOT_HIDE_PSEUDO_ATTRIBUTE, value); + } + }); + session.hiddenPseudos.clear(); + session.hidesScrollbar = false; + session.hiddenElements.clear(); + session.unmaskedElements.clear(); + session.unstuckElements.clear(); + session.keptStickyElements.clear(); + session.quietedElements.clear(); + document.documentElement.removeAttribute(SNAPSHOT_HIDE_CURSOR_ATTRIBUTE); + removeSnapshotStyles(); + } + + /** Un-sticks and hides what should not be captured, then waits for the result to show. */ + async function settleSnapshotScreen(session) { + await waitForSnapshotPaint(); + if (!session.hideFloating) { + return; + } + // A second pass for what only appeared after the first one. + for (let pass = 0; pass < 2; pass += 1) { + const unstuck = session.stitch && unstickSnapshotContent(session); + const hidden = hideSnapshotFloatingElements(session); + if (!unstuck && !hidden) { + break; + } + await waitForSnapshotPaint(); + } + } + + function waitForSnapshotPaint() { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (!settled) { + settled = true; + resolve(); + } + }; + // Frames can be throttled when hidden; the timeout keeps replies coming. + setTimeout(finish, SNAPSHOT_PAINT_TIMEOUT_MS); + if (typeof requestAnimationFrame !== 'function') { + setTimeout(finish, SNAPSHOT_PAINT_SETTLE_MS); + return; + } + requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(finish, SNAPSHOT_PAINT_SETTLE_MS))); + }); + } + + function postSnapshotState(requestId, session, extra = {}) { + const container = session?.container || null; + window.parent.postMessage({ + type: PANELIZE_SNAPSHOT_STATE, + context: MULTI_PANEL_SNAPSHOT_CONTEXT, + requestId, + sessionId: session?.id || null, + provider: detectProvider(), + matched: Boolean(session?.matched), + // 'prompt', 'user-turn' or 'top'; null when the panel was left as it was. + alignedBy: session?.alignedBy || null, + scrollTop: container ? getSnapshotScrollTop(container) : 0, + maxScrollTop: container ? getSnapshotMaxScrollTop(container) : 0, + originalScrollTop: container ? session.scrolled.get(container)?.top ?? null : null, + clip: session?.clip || null, + viewport: getSnapshotViewport(), + ...extra + }, '*'); + } + + /** Captures this container: remembers where it was, un-sticks its content and measures its clip. */ + function useSnapshotContainer(session, container) { + if (session.container === container) { + return; + } + session.container = container; + if (!session.scrolled.has(container)) { + const top = getSnapshotScrollTop(container); + const maxScrollTop = getSnapshotMaxScrollTop(container); + session.scrolled.set(container, { + top, + maxScrollTop, + atEnd: top >= maxScrollTop - SNAPSHOT_ALIGN_TOLERANCE_PX + }); + } + session.keptStickyElements.clear(); + if (session.stitch) { + // Before measuring, so a heading stuck at the top is not taken for a header. + unstickSnapshotContent(session); + } + session.clip = getSnapshotClip(container, session.provider); + } + + /** + * Scrolls the element just under the provider's header, not behind it, + * and checks that it got there: the container scrolled as asked and the + * element moved with it. Otherwise the container is put back. + * @returns {boolean} Whether the element is aligned. + */ + function alignSnapshotElement(session, { element, container }, alignedBy) { + useSnapshotContainer(session, container); + const before = getSnapshotScrollTop(container); + const elementTop = element.getBoundingClientRect().top; + const target = clampSnapshotScrollTop(container, before + elementTop - session.clip.top - SNAPSHOT_ALIGN_MARGIN_PX); + scrollSnapshotContainer(container, target); + const after = getSnapshotScrollTop(container); + const moved = elementTop - element.getBoundingClientRect().top; + if (Math.abs(after - target) > SNAPSHOT_ALIGN_TOLERANCE_PX + || Math.abs(moved - (after - before)) > SNAPSHOT_ALIGN_TOLERANCE_PX) { + scrollSnapshotContainer(container, before); + return false; + } + session.alignedBy = alignedBy; + session.alignTarget = target; + session.matched = alignedBy === 'prompt'; + session.matchElement = element; + return true; + } + + async function handleSnapshotPrepare(data) { + // A capture that never restored, e.g. when the page went away. + restoreSnapshotPage(snapshotSession); + snapshotSession = null; + const stitch = Boolean(data.stitch); + const session = { + id: data.sessionId, + provider: detectProvider(), + stitch, + hideFloating: stitch || Boolean(data.hideFloating), + container: null, + clip: null, + matched: false, + alignedBy: null, + alignTarget: null, + matchElement: null, + // Scroll position of every container touched, to put them back. + scrolled: new Map(), + hiddenElements: new Set(), + unmaskedElements: new Set(), + unstuckElements: new Set(), + keptStickyElements: new Set(), + quietedElements: new Set(), + hiddenPseudos: new Map(), + hidesScrollbar: false, + savedStyles: [] + }; + snapshotSession = session; + if (data.hideCursor) { + // Tab sharing can draw the pointer into the frames. + installSnapshotStyles(); + document.documentElement.setAttribute(SNAPSHOT_HIDE_CURSOR_ATTRIBUTE, ''); + } + + const main = findMainSnapshotScrollContainer(); + useSnapshotContainer(session, main); + if (data.align && typeof data.prompt === 'string') { + for (const match of findSnapshotPromptMatches(data.prompt, main)) { + if (alignSnapshotElement(session, match, 'prompt')) { + break; + } + } + } + if (!session.alignedBy && stitch) { + const turn = findSnapshotUserTurn(main); + if (turn) { + alignSnapshotElement(session, turn, 'user-turn'); + } + } + if (!session.alignedBy && stitch) { + // Never stitch from wherever the panel happened to be scrolled. + useSnapshotContainer(session, main); + scrollSnapshotContainer(main, 0); + session.alignedBy = 'top'; + session.alignTarget = 0; + } + + if (session.alignedBy || session.hideFloating) { + await settleSnapshotScreen(session); + } + const isOffTarget = () => session.alignTarget !== null + && Math.abs(getSnapshotScrollTop(session.container) - session.alignTarget) > SNAPSHOT_ALIGN_TOLERANCE_PX; + if (isOffTarget()) { + // The page scrolled itself meanwhile (e.g. back to the bottom). + scrollSnapshotContainer(session.container, session.alignTarget); + await settleSnapshotScreen(session); + } + if (isOffTarget()) { + // It keeps doing so: a stitch from here would miss the start, so the + // panel is shown as it is; a current view just is not aligned. + session.alignedBy = null; + session.alignTarget = null; + session.matched = false; + postSnapshotState(data.requestId, session, stitch ? { error: 'unaligned' } : {}); + return; + } + postSnapshotState(data.requestId, session); + } + + async function handleSnapshotScroll(data) { + const session = snapshotSession; + if (!session || session.id !== data.sessionId || typeof data.scrollTop !== 'number') { + postSnapshotState(data.requestId, null, { error: 'no-session' }); + return; + } + scrollSnapshotContainer(session.container, data.scrollTop); + await settleSnapshotScreen(session); + postSnapshotState(data.requestId, session); + } + + async function handleSnapshotRestore(data) { + const session = snapshotSession; + if (!session || session.id !== data.sessionId) { + postSnapshotState(data.requestId, null, { error: 'no-session' }); + return; + } + snapshotSession = null; + restoreSnapshotPage(session); + const positions = Array.from(session.scrolled).reverse(); + let { scrolled, heldBack } = restoreSnapshotScrollPositions(positions, false); + // A virtual list (DeepSeek) renders the rows near its old position only + // once scrolled there, so its range can be short at first and hold it + // back: follow the range while it grows back. + for (let attempt = 0; heldBack && !snapshotSession && attempt < SNAPSHOT_RESTORE_ATTEMPTS; attempt += 1) { + await waitForSnapshotPaint(); + scrolled = false; + ({ heldBack } = restoreSnapshotScrollPositions(positions, true)); + } + if (scrolled) { + await waitForSnapshotPaint(); + } + postSnapshotState(data.requestId, session, { restored: true }); + } + + /** + * Scrolls each container back; one that was at the end goes back to the + * end. With `onlyHeldBack`, only containers left at the end of the short + * range seen last time are touched; one that moved off it was scrolled by + * the page meanwhile and is left alone. + * @returns {{scrolled: boolean, heldBack: boolean}} heldBack: a range is + * still shorter than the position needs. + */ + function restoreSnapshotScrollPositions(positions, onlyHeldBack) { + let scrolled = false; + let heldBack = false; + for (const [container, position] of positions) { + const current = getSnapshotScrollTop(container); + if (onlyHeldBack && (position.heldAt === undefined || Math.abs(current - position.heldAt) > 1)) { + continue; + } + const max = getSnapshotMaxScrollTop(container); + const target = position.atEnd ? max : position.top; + if (Math.abs(current - target) > 1) { + scrollSnapshotContainer(container, target); + scrolled = true; + } + const needed = position.atEnd ? position.maxScrollTop : position.top; + const reached = getSnapshotMaxScrollTop(container); + position.heldAt = reached < needed - 1 ? getSnapshotScrollTop(container) : undefined; + heldBack = heldBack || position.heldAt !== undefined; + } + return { scrolled, heldBack }; + } + + /** Returns true when the message belonged to the snapshot protocol. */ + function handleSnapshotMessage(event) { + const type = event.data.type; + if (type !== PANELIZE_SNAPSHOT_PREPARE && type !== PANELIZE_SNAPSHOT_SCROLL && type !== PANELIZE_SNAPSHOT_RESTORE) { + return false; + } + // Only the page embedding this frame may scroll it. + if (event.data.context !== 'multi-panel' || event.source !== window.parent) { + return true; + } + const handler = type === PANELIZE_SNAPSHOT_PREPARE + ? handleSnapshotPrepare + : type === PANELIZE_SNAPSHOT_SCROLL ? handleSnapshotScroll : handleSnapshotRestore; + handler(event.data).catch((error) => { + console.warn('[Snapshot] Could not prepare the conversation:', error); + postSnapshotState(event.data.requestId, null, { error: 'failed' }); + }); + return true; + } + // Listen for messages from the multi-panel host setupProviderLocationReporting(); + setupPageWidthReporting(); window.addEventListener('message', handleTextInjection); })(); diff --git a/modules/i18n.js b/modules/i18n.js index 24d6c6a..ead4e73 100644 --- a/modules/i18n.js +++ b/modules/i18n.js @@ -182,6 +182,14 @@ export function getCurrentLanguage() { return currentLocale || 'en'; } +/** + * Locale of the loaded translations, which follows the language setting. + * @returns {string} Locale code (e.g., 'en', 'zh_CN') + */ +export function getTranslationLocale() { + return currentLocale || 'en'; +} + /** * Initialize i18n for a page * Call this on DOMContentLoaded diff --git a/modules/panel-fit.js b/modules/panel-fit.js new file mode 100644 index 0000000..f56eeec --- /dev/null +++ b/modules/panel-fit.js @@ -0,0 +1,80 @@ +/** + * Fit-to-width for provider pages that are wider than their panel. + * + * The provider frame reports the width its layout needs (PANELIZE_PAGE_WIDTH). + * When that is wider than the panel, the iframe is given that width and scaled + * down to the panel, so the page lays out at a real, wider viewport (media + * queries, vw/vh and fixed elements keep working) and nothing is cut off. + */ + +/** Pages are never shrunk below this; wider overflow stays cut. */ +export const PANEL_FIT_MIN_SCALE = 0.4; +/** Overflow up to this many pixels is left alone. */ +export const PANEL_FIT_TOLERANCE_PX = 4; +/** + * A page that needs more room again this soon after being given less is + * flipping between layouts; it keeps the wider width until the panel resizes. + */ +export const PANEL_FIT_BOUNCE_MS = 1500; + +/** + * @param {number} available - Panel content width in CSS pixels. + * @param {number} needed - Width the page reports it needs; 0 when unknown. + * @returns {number} Width to lay the page out at. + */ +export function getPanelPageWidth(available, needed) { + if (!(available > 0) || !(needed > available + PANEL_FIT_TOLERANCE_PX)) { + return available; + } + return Math.min(Math.ceil(needed), Math.floor(available / PANEL_FIT_MIN_SCALE)); +} + +/** @returns {{available: number, pageWidth: number, shrunkAt: number, locked: boolean}} */ +export function createPanelFitState() { + return { available: 0, pageWidth: 0, shrunkAt: -Infinity, locked: false }; +} + +/** + * @param {ReturnType} state + * @param {{available: number, needed: number, now: number}} input + * @returns {ReturnType} + */ +export function updatePanelFitState(state, { available, needed, now }) { + const next = { ...state }; + if (available !== state.available) { + Object.assign(next, createPanelFitState(), { available, pageWidth: state.pageWidth }); + } + const target = getPanelPageWidth(available, needed); + if (target < next.pageWidth && available === state.available) { + if (next.locked) { + return next; + } + next.shrunkAt = now; + } else if (target > next.pageWidth && now - next.shrunkAt < PANEL_FIT_BOUNCE_MS) { + next.locked = true; + } + next.pageWidth = target; + return next; +} + +/** @returns {number} Scale the page is drawn at, 1 when it fits. */ +export function getPanelFitScale(state) { + if (!(state.available > 0) || !(state.pageWidth > state.available)) { + return 1; + } + return state.available / state.pageWidth; +} + +/** + * @param {HTMLIFrameElement} iframe + * @param {number} scale + */ +export function applyPanelFitScale(iframe, scale) { + if (scale < 1) { + iframe.style.setProperty('--panel-fit-scale', String(scale)); + iframe.classList.add('panel-fitted'); + } else { + iframe.style.removeProperty('--panel-fit-scale'); + iframe.classList.remove('panel-fitted'); + } +} diff --git a/modules/panel-resize.js b/modules/panel-resize.js new file mode 100644 index 0000000..6baeae3 --- /dev/null +++ b/modules/panel-resize.js @@ -0,0 +1,226 @@ +/** + * Drag handles between panel columns. + * + * Column widths are stored per layout as weights: each column becomes + * minmax(minWidth * weight, weight fr). When the window fits the columns they + * share it in proportion; when it does not (1xN layouts scroll sideways) each + * column is minWidth * weight wide. + */ + +export const PANEL_COLUMN_WEIGHTS_KEY = 'panelColumnWeights'; +/** Columns are not dragged narrower than this (or than they already are). */ +export const PANEL_RESIZE_MIN_WIDTH = 200; +/** Arrow keys move a focused handle this far. */ +export const PANEL_RESIZE_KEY_STEP = 16; +/** Width of the grab area, centered on the gap between two columns. */ +export const PANEL_RESIZE_HANDLE_WIDTH = 10; + +const round = (value, digits) => Number(value.toFixed(digits)); +const sum = (values) => values.reduce((total, value) => total + value, 0); + +/** + * @param {number[]} widths - Column widths in pixels. + * @param {number} available - Grid width left for columns (no padding or gaps). + * @param {number} minWidth - Layout minimum column width; 0 for none. + * @returns {number[]} + */ +export function getColumnWeights(widths, available, minWidth) { + const base = Math.max(minWidth, available / widths.length); + return widths.map((width) => round(width / base, 4)); +} + +/** + * @param {number[]} weights + * @param {number} minWidth + * @returns {string} grid-template-columns value. + */ +export function getColumnTemplate(weights, minWidth) { + return weights + .map((weight) => `minmax(${round(minWidth * weight, 2)}px, ${weight}fr)`) + .join(' '); +} + +/** + * Moves the edge between column `index` and the next one by `delta` pixels. + * Columns that fit the window trade width with their neighbor; columns that + * already scroll sideways grow or shrink alone, but not below fitting again. + * @param {number[]} widths + * @param {number} index + * @param {number} delta + * @param {{scrolls: boolean, available: number}} options + * @returns {number[]} + */ +export function resizeColumns(widths, index, delta, { scrolls, available }) { + const next = widths.slice(); + if (scrolls) { + const floor = Math.min(PANEL_RESIZE_MIN_WIDTH, widths[index]); + const fitFloor = available - (sum(widths) - widths[index]); + next[index] = Math.max(floor, fitFloor, widths[index] + delta); + return next; + } + const pair = widths[index] + widths[index + 1]; + const floor = Math.min(PANEL_RESIZE_MIN_WIDTH, widths[index], widths[index + 1]); + next[index] = Math.min(pair - floor, Math.max(floor, widths[index] + delta)); + next[index + 1] = pair - next[index]; + return next; +} + +/** + * @param {unknown} weights + * @param {number} count + * @returns {boolean} + */ +export function isValidColumnWeights(weights, count) { + return Array.isArray(weights) && weights.length === count + && weights.every((weight) => Number.isFinite(weight) && weight > 0); +} + +function readGrid(grid) { + const style = window.getComputedStyle(grid); + const widths = style.gridTemplateColumns.split(' ').map(parseFloat).filter(Number.isFinite); + const gap = parseFloat(style.columnGap) || 0; + const paddingLeft = parseFloat(style.paddingLeft) || 0; + const paddingRight = parseFloat(style.paddingRight) || 0; + return { + widths, + gap, + paddingLeft, + minWidth: parseFloat(style.getPropertyValue('--panel-min-width')) || 0, + available: grid.clientWidth - paddingLeft - paddingRight - gap * Math.max(0, widths.length - 1), + scrolls: grid.scrollWidth > grid.clientWidth + 1 + }; +} + +/** + * @param {{ + * grid: HTMLElement, + * getLayout: () => string, + * storage: {get: Function, set: Function}, + * label: string + * }} options - Does nothing without a grid. + * @returns {Promise} + */ +export async function setupPanelResize({ grid, getLayout, storage, label }) { + if (!grid) { + return; + } + let weightsByLayout = {}; + try { + const stored = await storage.get(PANEL_COLUMN_WEIGHTS_KEY); + weightsByLayout = stored?.[PANEL_COLUMN_WEIGHTS_KEY] || {}; + } catch (error) { + console.warn('[Panel resize] Could not load column widths:', error); + } + + const layer = document.createElement('div'); + layer.className = 'panel-resize-handles'; + grid.prepend(layer); + let drag = null; + + const save = () => { + storage.set({ [PANEL_COLUMN_WEIGHTS_KEY]: weightsByLayout }).catch((error) => { + console.warn('[Panel resize] Could not save column widths:', error); + }); + }; + + const renderHandles = () => { + const { widths, gap, paddingLeft } = readGrid(grid); + const count = Math.max(0, widths.length - 1); + while (layer.children.length > count) { + layer.lastElementChild.remove(); + } + while (layer.children.length < count) { + const handle = document.createElement('div'); + handle.className = 'panel-resize-handle'; + handle.tabIndex = 0; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('aria-label', label); + handle.title = label; + handle.dataset.index = String(layer.children.length); + layer.appendChild(handle); + } + let edge = paddingLeft; + Array.from(layer.children).forEach((handle, index) => { + edge += widths[index]; + handle.style.left = `${edge + gap / 2 - PANEL_RESIZE_HANDLE_WIDTH / 2}px`; + edge += gap; + }); + }; + + const applyWeights = (weights, minWidth) => { + grid.style.gridTemplateColumns = weights ? getColumnTemplate(weights, minWidth) : ''; + renderHandles(); + }; + + const applyLayout = () => { + grid.style.gridTemplateColumns = ''; + const { widths, minWidth } = readGrid(grid); + const weights = weightsByLayout[getLayout()]; + applyWeights(isValidColumnWeights(weights, widths.length) && widths.length > 1 ? weights : null, minWidth); + }; + + const moveEdge = (state, delta) => { + const widths = resizeColumns(state.widths, state.index, delta, state); + const weights = getColumnWeights(widths, state.available, state.minWidth); + weightsByLayout[getLayout()] = weights; + applyWeights(weights, state.minWidth); + }; + + const startState = (handle) => ({ ...readGrid(grid), index: Number(handle.dataset.index) }); + + layer.addEventListener('pointerdown', (event) => { + const handle = event.target.closest('.panel-resize-handle'); + if (!handle || event.button !== 0) { + return; + } + event.preventDefault(); + handle.setPointerCapture(event.pointerId); + drag = { ...startState(handle), handle, startX: event.clientX }; + handle.classList.add('active'); + document.body.classList.add('panel-resizing'); + }); + + layer.addEventListener('pointermove', (event) => { + if (drag) { + moveEdge(drag, event.clientX - drag.startX); + } + }); + + const endDrag = () => { + if (!drag) { + return; + } + drag.handle.classList.remove('active'); + drag = null; + document.body.classList.remove('panel-resizing'); + save(); + }; + layer.addEventListener('pointerup', endDrag); + layer.addEventListener('pointercancel', endDrag); + layer.addEventListener('lostpointercapture', endDrag); + + layer.addEventListener('dblclick', (event) => { + if (!event.target.closest('.panel-resize-handle')) { + return; + } + delete weightsByLayout[getLayout()]; + applyLayout(); + save(); + }); + + layer.addEventListener('keydown', (event) => { + const handle = event.target.closest('.panel-resize-handle'); + const direction = { ArrowLeft: -1, ArrowRight: 1 }[event.key]; + if (!handle || !direction) { + return; + } + event.preventDefault(); + moveEdge(startState(handle), direction * PANEL_RESIZE_KEY_STEP); + save(); + }); + + new MutationObserver(applyLayout).observe(grid, { attributes: true, attributeFilter: ['class'] }); + new ResizeObserver(renderHandles).observe(grid); + applyLayout(); +} diff --git a/modules/pointer-guard.js b/modules/pointer-guard.js new file mode 100644 index 0000000..92649c6 --- /dev/null +++ b/modules/pointer-guard.js @@ -0,0 +1,163 @@ +/** + * Keeps the mouse pointer out of tab-sharing snapshots. + * + * Chrome draws the pointer into a shared tab's frames whenever it moved or + * clicked inside the tab in the last 2 seconds, whatever the page's CSS + * cursor (on macOS it falls back to the arrow). A stitched snapshot would + * then show one pointer per screen. The page follows the pointer; while + * capturing, the guard covers the captured area with a transparent shield, + * so the provider frames get no hover and the page sees every move, and + * waits before each frame until the pointer has been still long enough for + * Chrome to stop drawing it. + */ + +/** + * Chrome stops drawing the pointer after 2s without movement + * (MouseCursorOverlayController kIdleTimeout); the rest is a margin. + */ +export const POINTER_IDLE_MS = 2200; +/** A pointer kept moving longer than this is captured anyway. */ +export const POINTER_MAX_WAIT_MS = 10000; +/** + * The drawn pointer reaches up to its image size (32px) past its hot spot, + * so a pointer just outside the captured area can still show up in it. + */ +export const POINTER_REACH_PX = 32; + +function abortError(signal) { + return signal.reason ?? new DOMException('The operation was aborted', 'AbortError'); +} + +function sleep(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError(signal)); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(abortError(signal)); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** + * Whether a pointer at `point` can be drawn inside `area`. + * @param {{x: number, y: number}} point + * @param {{left: number, top: number, right: number, bottom: number}} area + * @param {number} [reach] + * @returns {boolean} + */ +export function canPointerReach(point, area, reach = POINTER_REACH_PX) { + return point.x >= area.left - reach && point.x <= area.right + reach + && point.y >= area.top - reach && point.y <= area.bottom + reach; +} + +const OUTSIDE = Object.freeze({ x: -Infinity, y: -Infinity }); + +/** + * Follows the pointer over the page. Moves over a provider frame are not + * seen here, so a pointer inside one is only known to be somewhere in it. + * @param {object} [options] + * @param {Window} [options.target] + * @param {() => number} [options.now] + * @returns {{read: () => {point: {x: number, y: number} | null, time: number} | null, + * dispose: () => void}} read() is null until the pointer is first seen; a null + * point means it is inside a frame, at a time the page cannot know. + */ +export function createPointerTracker({ target = window, now = () => performance.now() } = {}) { + let last = null; + const record = (event) => { + last = { point: { x: event.clientX, y: event.clientY }, time: now() }; + }; + const leave = (event) => { + const into = event.relatedTarget; + if (!into) { + last = { point: OUTSIDE, time: now() }; + } else if (into.tagName === 'IFRAME') { + last = { point: null, time: now() }; + } + }; + target.addEventListener('pointermove', record, true); + target.addEventListener('pointerdown', record, true); + target.addEventListener('pointerout', leave, true); + return { + read: () => last, + dispose() { + target.removeEventListener('pointermove', record, true); + target.removeEventListener('pointerdown', record, true); + target.removeEventListener('pointerout', leave, true); + } + }; +} + +/** + * Shields `area` from the pointer until disposed. + * @param {object} options + * @param {HTMLElement} options.area - Element whose box is captured (#panel-grid). + * @param {ReturnType} options.tracker + * @param {(waiting: boolean) => void} [options.onWaitChange] - The guard + * started or stopped waiting for the pointer to keep still. + * @param {() => number} [options.now] - Same clock as the tracker. + * @param {number} [options.idleMs] + * @param {number} [options.maxWaitMs] + * @returns {{waitUntilStill: (signal?: AbortSignal) => Promise, dispose: () => void}} + */ +export function createPointerGuard({ + area, + tracker, + onWaitChange = () => {}, + now = () => performance.now(), + idleMs = POINTER_IDLE_MS, + maxWaitMs = POINTER_MAX_WAIT_MS +}) { + const doc = area.ownerDocument; + const rect = area.getBoundingClientRect(); + const shield = doc.createElement('div'); + shield.className = 'snapshot-pointer-shield'; + shield.setAttribute('aria-hidden', 'true'); + Object.assign(shield.style, { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px` + }); + // The shield takes the wheel too, so panels do not scroll under the capture. + const block = (event) => event.preventDefault(); + shield.addEventListener('wheel', block, { passive: false }); + doc.body.appendChild(shield); + + // A pointer last seen in a frame may have moved there just now; from here + // on the shield sees its moves. + const startedAt = now(); + const lastActivity = (last) => (last?.point ? last.time : Math.max(last?.time ?? startedAt, startedAt)); + const isDrawable = () => { + const last = tracker.read(); + return now() - lastActivity(last) < idleMs + && (!last?.point || canPointerReach(last.point, area.getBoundingClientRect())); + }; + + return { + async waitUntilStill(signal) { + if (!isDrawable()) return; + const deadline = now() + maxWaitMs; + onWaitChange(true); + try { + while (isDrawable() && now() < deadline) { + const stillAt = lastActivity(tracker.read()) + idleMs; + await sleep(Math.max(1, Math.min(stillAt, deadline) - now()), signal); + } + } finally { + onWaitChange(false); + } + }, + dispose() { + shield.remove(); + } + }; +} diff --git a/modules/rating-prompt.js b/modules/rating-prompt.js new file mode 100644 index 0000000..b5e2d13 --- /dev/null +++ b/modules/rating-prompt.js @@ -0,0 +1,129 @@ +// Store rating prompt: counts successful "Send All" actions locally and decides +// when the comparison page should ask the user to rate Panelize. Nothing leaves +// the device; the state lives in chrome.storage.local. + +export const RATING_PROMPT_STORAGE_KEY = 'ratingPrompt'; + +/** Successful sends before the banner first appears. */ +export const RATING_PROMPT_FIRST_THRESHOLD = 20; + +/** Further successful sends before the banner returns after "Later". */ +export const RATING_PROMPT_LATER_INTERVAL = 50; + +export const RATING_PROMPT_REVIEW_URL = + 'https://chromewebstore.google.com/detail/panelize/iokalaafkmjffolodkkgbbccmofbglii/reviews'; + +export const RATING_PROMPT_ACTIONS = Object.freeze({ + RATE: 'rate', + LATER: 'later', + NEVER: 'never' +}); + +/** + * @typedef {object} RatingPromptState + * @property {number} sendCount - Successful "Send All" actions so far. + * @property {number} nextPromptAt - sendCount at which the banner shows. + * @property {boolean} dismissed - True once the user rated or opted out. + */ + +/** @returns {RatingPromptState} */ +export function createInitialRatingPromptState() { + return { + sendCount: 0, + nextPromptAt: RATING_PROMPT_FIRST_THRESHOLD, + dismissed: false + }; +} + +function toCount(value, fallback) { + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback; +} + +/** + * Repairs a stored value so missing or corrupt fields fall back to defaults. + * @param {unknown} value + * @returns {RatingPromptState} + */ +export function normalizeRatingPromptState(value) { + const initial = createInitialRatingPromptState(); + if (!value || typeof value !== 'object') { + return initial; + } + return { + sendCount: toCount(value.sendCount, initial.sendCount), + nextPromptAt: toCount(value.nextPromptAt, initial.nextPromptAt), + dismissed: value.dismissed === true + }; +} + +/** + * @param {RatingPromptState} state + * @returns {RatingPromptState} + */ +export function recordSuccessfulSend(state) { + const current = normalizeRatingPromptState(state); + return { ...current, sendCount: current.sendCount + 1 }; +} + +/** + * @param {RatingPromptState} state + * @returns {boolean} + */ +export function shouldShowRatingPrompt(state) { + const current = normalizeRatingPromptState(state); + return !current.dismissed && current.sendCount >= current.nextPromptAt; +} + +/** + * Applies a banner button. "Rate" and "Don't ask again" stop the prompt for + * good; "Later" pushes it back by RATING_PROMPT_LATER_INTERVAL sends. + * @param {RatingPromptState} state + * @param {string} action - One of RATING_PROMPT_ACTIONS. + * @returns {RatingPromptState} + */ +export function applyRatingPromptAction(state, action) { + const current = normalizeRatingPromptState(state); + switch (action) { + case RATING_PROMPT_ACTIONS.RATE: + case RATING_PROMPT_ACTIONS.NEVER: + return { ...current, dismissed: true }; + case RATING_PROMPT_ACTIONS.LATER: + return { ...current, nextPromptAt: current.sendCount + RATING_PROMPT_LATER_INTERVAL }; + default: + throw new Error(`Unknown rating prompt action: ${action}`); + } +} + +/** + * @param {chrome.storage.StorageArea} [storage] + * @returns {Promise} + */ +export async function loadRatingPromptState(storage = chrome.storage.local) { + const result = await storage.get(RATING_PROMPT_STORAGE_KEY); + return normalizeRatingPromptState(result?.[RATING_PROMPT_STORAGE_KEY]); +} + +async function updateRatingPromptState(storage, update) { + const next = update(await loadRatingPromptState(storage)); + await storage.set({ [RATING_PROMPT_STORAGE_KEY]: next }); + return next; +} + +/** + * Counts one successful "Send All" and returns the new state. + * @param {chrome.storage.StorageArea} [storage] + * @returns {Promise} + */ +export function countSuccessfulSend(storage = chrome.storage.local) { + return updateRatingPromptState(storage, recordSuccessfulSend); +} + +/** + * Stores the user's answer to the banner and returns the new state. + * @param {string} action - One of RATING_PROMPT_ACTIONS. + * @param {chrome.storage.StorageArea} [storage] + * @returns {Promise} + */ +export function saveRatingPromptAction(action, storage = chrome.storage.local) { + return updateRatingPromptState(storage, (state) => applyRatingPromptAction(state, action)); +} diff --git a/modules/settings.js b/modules/settings.js index 2be883e..8c4b1f2 100644 --- a/modules/settings.js +++ b/modules/settings.js @@ -12,6 +12,7 @@ const DEFAULT_SETTINGS = { language: null, keyboardShortcutEnabled: true, openMode: 'tab', // 'tab' or 'popup' + shareWatermarkEnabled: true, // Adds the "Compared with Panelize" strip to shared snapshots enterKeyBehavior: { enabled: true, preset: 'default', // 'default', 'swapped', 'slack', 'discord', 'custom' diff --git a/modules/share-snapshot.js b/modules/share-snapshot.js new file mode 100644 index 0000000..f56e7e4 --- /dev/null +++ b/modules/share-snapshot.js @@ -0,0 +1,874 @@ +// Share snapshot composition: frames a captured comparison in the Panelize +// share layout (light theme "A", dark theme "B") and draws it on a 2D canvas +// context. Everything is pure or takes its canvas from the caller, so the +// layout can be unit tested without a browser. + +export const SNAPSHOT_SITE_LABEL = 'panelize.dev'; + +export const SNAPSHOT_BRAND_NAME = 'Panelize'; + +/** Longest title kept before measuring; the headline shows a few lines at most. */ +export const SNAPSHOT_MAX_PROMPT_CHARS = 400; + +export const SNAPSHOT_MAX_PROMPT_LINES = 3; + +/** The subline wraps once on narrow captures so the date is not cut off. */ +export const SNAPSHOT_MAX_SUBLINE_LINES = 2; + +/** Widest capture kept in the image, in device pixels. */ +export const SNAPSHOT_MAX_CAPTURE_WIDTH = 2560; + +/** Tallest image produced, in output pixels. Long answers are truncated to fit. */ +export const SNAPSHOT_MAX_OUTPUT_HEIGHT = 8000; + +/** Chrome refuses canvases beyond these limits. */ +export const CANVAS_MAX_DIMENSION = 32767; +export const CANVAS_MAX_AREA = 268435456; + +const ELLIPSIS = '…'; + +// System fonts only, with CJK families early enough that Chinese and Japanese +// titles do not fall back to a mismatched default. +export const SNAPSHOT_FONT_FAMILY = [ + '-apple-system', 'BlinkMacSystemFont', '"SF Pro Display"', '"Segoe UI"', + '"PingFang SC"', '"Hiragino Sans"', '"Hiragino Kaku Gothic ProN"', + '"Microsoft YaHei"', '"Yu Gothic UI"', '"Noto Sans CJK SC"', '"Noto Sans"', + 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif' +].join(', '); + +/** + * Frame styles in CSS pixels; multiplied by the capture scale when drawn. + * `light` is layout A (warm paper, tagline, prompt label) and `dark` is + * layout B (glows, quoted headline, window card). Both put the site link on + * the brand row and the providers and date in a subline under the headline. + */ +export const SNAPSHOT_FRAME_STYLES = Object.freeze({ + light: Object.freeze({ + variant: 'a', + background: '#F5F4F0', + paddingTop: 48, + paddingX: 64, + paddingBottom: 56, + brandHeight: 34, + iconSize: 34, + iconRadius: 8, + iconGap: 12, + brandFontSize: 20, + brandWeight: 650, + brandLetterSpacing: -0.01, + brandColor: '#16161A', + taglineFontSize: 15, + taglineColor: 'rgba(22, 22, 26, 0.55)', + ctaGap: 14, + captionFontSize: 14, + captionColor: '#8A8A90', + pillHeight: 34, + pillPaddingX: 16, + pillFontSize: 16, + pillWeight: 650, + pillBackground: '#16161A', + pillColor: '#ffffff', + labelGap: 40, + labelFontSize: 13, + labelWeight: 650, + labelLineHeight: 16, + labelLetterSpacing: 0.12, + labelColor: '#9A9AA0', + titleGap: 10, + titleGapWithoutLabel: 40, + titleFontSize: 36, + titleLineHeight: 45, + titleWeight: 680, + titleLetterSpacing: -0.02, + titleColor: '#16161A', + titleMaxWidth: 1240, + sublineGap: 10, + sublineGapWithoutTitle: 40, + sublineFontSize: 16, + sublineLineHeight: 22, + sublineColor: '#7A7A80', + cardGap: 32, + cardGapWithoutHeadline: 32, + cardRadius: 16, + cardBorder: 'rgba(0, 0, 0, 0.08)', + cardBackground: '#ffffff', + cardShadow: Object.freeze({ color: 'rgba(30, 30, 40, 0.22)', blur: 48, offsetY: 20 }), + titleBarHeight: 0, + noteGap: 16, + noteFontSize: 14, + noteLineHeight: 20, + noteColor: '#7A7A80' + }), + dark: Object.freeze({ + variant: 'b', + background: '#0E0F13', + glows: Object.freeze([ + Object.freeze({ x: 0.08, y: 0, radiusX: 900, radiusY: 500, color: [239, 68, 68], alpha: 0.28 }), + Object.freeze({ x: 1, y: 1, radiusX: 1000, radiusY: 600, color: [59, 130, 246], alpha: 0.32 }) + ]), + paddingTop: 44, + paddingX: 64, + paddingBottom: 64, + brandHeight: 34, + iconSize: 34, + iconRadius: 8, + iconGap: 12, + brandFontSize: 20, + brandWeight: 650, + brandLetterSpacing: -0.01, + brandColor: '#ffffff', + ctaGap: 14, + captionFontSize: 14, + captionColor: 'rgba(255, 255, 255, 0.55)', + pillHeight: 34, + pillPaddingX: 16, + pillFontSize: 16, + pillWeight: 650, + pillBackground: '#ffffff', + pillColor: '#16161A', + titleGap: 36, + titleGapWithoutBrand: 0, + titleFontSize: 38, + titleLineHeight: 46, + titleWeight: 700, + titleLetterSpacing: -0.02, + titleColor: '#ffffff', + titleMaxWidth: 1200, + openQuote: '“', + closeQuote: '”', + openQuoteColor: '#EF4444', + closeQuoteColor: '#3B82F6', + sublineGap: 12, + sublineGapWithoutTitle: 36, + sublineFontSize: 16, + sublineLineHeight: 22, + sublineColor: 'rgba(255, 255, 255, 0.55)', + cardGap: 34, + cardGapWithoutHeadline: 36, + cardRadius: 18, + cardBorder: 'rgba(255, 255, 255, 0.12)', + cardBackground: '#ffffff', + cardShadow: Object.freeze({ color: 'rgba(0, 0, 0, 0.6)', blur: 64, offsetY: 24 }), + titleBarHeight: 38, + titleBarColor: '#1B1C22', + titleBarDotSize: 12, + titleBarDotGap: 8, + titleBarPaddingX: 16, + titleBarDotColor: '#3A3B42', + noteGap: 18, + noteFontSize: 14, + noteLineHeight: 20, + noteColor: 'rgba(255, 255, 255, 0.6)' + }) +}); + +/** Text of the site link pill on the brand row. */ +export const SNAPSHOT_SITE_LINK_TEXT = `${SNAPSHOT_SITE_LABEL} ↗`; + +/** + * @param {string} theme - 'light' or 'dark'. + */ +export function getSnapshotFrameStyle(theme) { + return theme === 'dark' ? SNAPSHOT_FRAME_STYLES.dark : SNAPSHOT_FRAME_STYLES.light; +} + +/** + * Collapses whitespace so a multi-paragraph prompt reads as one line of text, + * and caps its length before it is measured. + * @param {string} text + * @param {number} [maxChars] + * @returns {string} + */ +export function normalizePromptText(text, maxChars = SNAPSHOT_MAX_PROMPT_CHARS) { + const collapsed = String(text ?? '').replace(/\s+/g, ' ').trim(); + const chars = Array.from(collapsed); + if (chars.length <= maxChars) { + return collapsed; + } + return `${chars.slice(0, maxChars).join('').trimEnd()}${ELLIPSIS}`; +} + +function splitIntoTokens(text) { + // Words keep their trailing space; CJK and other unspaced scripts break per + // character, which is where browsers may wrap them too. + return text.match(/[ -鿿가-힯豈-﫿＀-￯]|[^\s -鿿가-힯豈-﫿＀-￯]+\s*|\s+/g) || []; +} + +function fitWithEllipsis(text, maxWidth, measure) { + const chars = Array.from(text.trimEnd()); + while (chars.length > 0 && measure(`${chars.join('')}${ELLIPSIS}`) > maxWidth) { + chars.pop(); + } + return `${chars.join('').trimEnd()}${ELLIPSIS}`; +} + +function breakLongToken(token, maxWidth, measure) { + const pieces = []; + let piece = ''; + for (const char of Array.from(token)) { + if (piece && measure(piece + char) > maxWidth) { + pieces.push(piece); + piece = char; + } else { + piece += char; + } + } + if (piece) { + pieces.push(piece); + } + return pieces; +} + +/** + * Wraps text into at most `maxLines` lines no wider than `maxWidth`. When text + * is left over, the last line ends with an ellipsis. + * @param {string} text + * @param {number} maxWidth + * @param {(text: string) => number} measure - Width of a string, e.g. ctx.measureText. + * @param {number} [maxLines] + * @returns {string[]} + */ +export function wrapTextLines(text, maxWidth, measure, maxLines = SNAPSHOT_MAX_PROMPT_LINES) { + if (!text || maxLines < 1) { + return []; + } + + const tokens = splitIntoTokens(text).flatMap((token) => ( + measure(token.trimEnd()) > maxWidth ? breakLongToken(token, maxWidth, measure) : [token] + )); + + const lines = []; + let line = ''; + let index = 0; + for (; index < tokens.length; index += 1) { + const token = tokens[index]; + const candidate = line + token; + if (line && measure(candidate.trimEnd()) > maxWidth) { + lines.push(line.trimEnd()); + if (lines.length === maxLines) { + break; + } + line = token.trimStart(); + } else { + line = candidate; + } + } + + if (lines.length < maxLines) { + if (line.trim()) { + lines.push(line.trimEnd()); + } + return lines; + } + + const truncated = index < tokens.length || text.endsWith(ELLIPSIS); + if (truncated) { + lines[lines.length - 1] = fitWithEllipsis(lines[lines.length - 1], maxWidth, measure); + } + return lines; +} + +/** + * Maps an element's viewport rectangle onto a captured tab frame and picks the + * output size, capped at `maxWidth` device pixels. + * @param {object} options + * @param {number} options.frameWidth - Captured frame width in pixels. + * @param {number} options.frameHeight + * @param {number} options.viewportWidth - window.innerWidth when captured. + * @param {number} options.viewportHeight + * @param {{left: number, top: number, width: number, height: number}} options.rect + * Area to keep, in CSS pixels (getBoundingClientRect()). + * @param {number} [options.maxWidth] + * @returns {{sx: number, sy: number, sw: number, sh: number, + * width: number, height: number, scale: number}} Source crop in frame + * pixels, output size, and output pixels per CSS pixel. + */ +export function getCaptureCrop({ + frameWidth, + frameHeight, + viewportWidth, + viewportHeight, + rect, + maxWidth = SNAPSHOT_MAX_CAPTURE_WIDTH +}) { + if (!(frameWidth > 0 && frameHeight > 0 && viewportWidth > 0 && viewportHeight > 0)) { + throw new Error('Captured frame or viewport has no size'); + } + + const ratioX = frameWidth / viewportWidth; + const ratioY = frameHeight / viewportHeight; + const left = Math.max(0, rect.left); + const top = Math.max(0, rect.top); + const right = Math.min(viewportWidth, rect.left + rect.width); + const bottom = Math.min(viewportHeight, rect.top + rect.height); + if (right <= left || bottom <= top) { + throw new Error('Capture area is outside the viewport'); + } + + const sx = Math.round(left * ratioX); + const sy = Math.round(top * ratioY); + const sw = Math.min(frameWidth - sx, Math.round((right - left) * ratioX)); + const sh = Math.min(frameHeight - sy, Math.round((bottom - top) * ratioY)); + const downscale = Math.min(1, maxWidth / sw); + const width = Math.max(1, Math.round(sw * downscale)); + const height = Math.max(1, Math.round(sh * downscale)); + + return { sx, sy, sw, sh, width, height, scale: width / (right - left) }; +} + +function font(weight, size) { + return `${weight} ${size}px ${SNAPSHOT_FONT_FAMILY}`; +} + +/** + * Lays out the share frame around content of a given size. + * + * Elements are dropped, and the space they used collapses, when they have + * nothing to show: no title lines removes the headline (and the light + * theme's "PROMPT" label), `showWatermark: false` removes the brand row with + * its tagline, caption and site link, and an empty subline or note is + * skipped. The subline (providers and date) describes the content, so it + * stays without the watermark. + * The card around the content is always drawn. + * + * @param {object} options + * @param {number} options.contentWidth - Content size in output pixels. + * @param {number} options.contentHeight + * @param {number} options.scale - Output pixels per CSS pixel. + * @param {string} [options.theme] - 'light' (layout A) or 'dark' (layout B). + * @param {number} [options.titleLineCount] - Wrapped headline lines; 0 hides it. + * @param {boolean} [options.showWatermark] + * @param {boolean} [options.hasSubline] - Provider and date line under the headline. + * @param {number} [options.sublineLineCount] - Wrapped subline lines. + * @param {boolean} [options.hasNote] - Truncation note under the card. + */ +export function getSnapshotFrameLayout({ + contentWidth, + contentHeight, + scale, + theme = 'light', + titleLineCount = 0, + showWatermark = true, + hasSubline = false, + sublineLineCount = 1, + hasNote = false +}) { + const style = getSnapshotFrameStyle(theme); + const px = (value) => Math.round(value * scale); + const paddingX = px(style.paddingX); + const width = contentWidth + paddingX * 2; + const innerWidth = contentWidth; + const isDark = style.variant === 'b'; + + let y = px(style.paddingTop); + let brand = null; + if (showWatermark) { + const height = px(style.brandHeight); + brand = { + x: paddingX, + y, + height, + centerY: y + Math.round(height / 2), + iconSize: px(style.iconSize), + iconRadius: px(style.iconRadius), + iconGap: px(style.iconGap), + font: font(style.brandWeight, px(style.brandFontSize)), + letterSpacing: style.brandFontSize * style.brandLetterSpacing * scale, + taglineFont: style.taglineFontSize ? font(500, px(style.taglineFontSize)) : null, + right: paddingX + innerWidth, + ctaGap: px(style.ctaGap), + captionFont: font(500, px(style.captionFontSize)), + pillHeight: px(style.pillHeight), + pillPaddingX: px(style.pillPaddingX), + pillFont: font(style.pillWeight, px(style.pillFontSize)) + }; + y += height; + } + + let label = null; + let title = null; + if (titleLineCount > 0) { + if (!isDark) { + y += brand ? px(style.labelGap) : 0; + label = { + x: paddingX, + y, + height: px(style.labelLineHeight), + font: font(style.labelWeight, px(style.labelFontSize)), + letterSpacing: style.labelFontSize * style.labelLetterSpacing * scale + }; + y += label.height + px(style.titleGap); + } else { + y += brand ? px(style.titleGap) : 0; + } + const lineHeight = px(style.titleLineHeight); + title = { + x: paddingX, + y, + lineHeight, + lineCount: titleLineCount, + height: lineHeight * titleLineCount, + font: font(style.titleWeight, px(style.titleFontSize)), + letterSpacing: style.titleFontSize * style.titleLetterSpacing * scale, + quoteGap: px(style.openQuoteGap || 0), + maxWidth: Math.min(innerWidth, px(style.titleMaxWidth)) + }; + y += title.height; + } + + let subline = null; + if (hasSubline) { + if (title) { + y += px(style.sublineGap); + } else if (brand) { + y += px(style.sublineGapWithoutTitle); + } + subline = { + x: paddingX, + y, + lineHeight: px(style.sublineLineHeight), + height: px(style.sublineLineHeight) * Math.max(1, sublineLineCount), + font: font(400, px(style.sublineFontSize)), + maxWidth: innerWidth + }; + y += subline.height; + } + + const hasHeadline = Boolean(brand || title || subline); + if (hasHeadline) { + y += px(title || subline ? style.cardGap : style.cardGapWithoutHeadline); + } + + const titleBarHeight = px(style.titleBarHeight); + const card = { + x: paddingX, + y, + width: innerWidth, + height: titleBarHeight + contentHeight, + radius: px(style.cardRadius), + border: Math.max(1, px(1)), + titleBarHeight, + dotSize: style.titleBarDotSize ? px(style.titleBarDotSize) : 0, + dotGap: style.titleBarDotGap ? px(style.titleBarDotGap) : 0, + dotPaddingX: style.titleBarPaddingX ? px(style.titleBarPaddingX) : 0, + shadowBlur: px(style.cardShadow.blur), + shadowOffsetY: px(style.cardShadow.offsetY) + }; + const content = { x: paddingX, y: y + titleBarHeight, width: contentWidth, height: contentHeight }; + y += card.height; + + let note = null; + if (hasNote) { + y += px(style.noteGap); + note = { + x: paddingX, + y, + height: px(style.noteLineHeight), + font: font(500, px(style.noteFontSize)), + maxWidth: innerWidth + }; + y += note.height; + } + + y += px(style.paddingBottom); + + return { width, height: y, scale, style, brand, label, title, subline, card, content, note }; +} + +/** + * Height the frame adds around content, for fitting tall captures under + * SNAPSHOT_MAX_OUTPUT_HEIGHT. + * @param {Parameters[0]} options - Without contentHeight. + */ +export function getSnapshotFrameChromeHeight(options) { + return getSnapshotFrameLayout({ ...options, contentHeight: 0 }).height; +} + +function withLetterSpacing(ctx, spacing, draw) { + const supported = 'letterSpacing' in ctx; + const previous = supported ? ctx.letterSpacing : null; + if (supported) { + ctx.letterSpacing = `${spacing}px`; + } + try { + return draw(); + } finally { + if (supported) { + ctx.letterSpacing = previous; + } + } +} + +function roundedRectPath(ctx, x, y, width, height, radius) { + const r = Math.max(0, Math.min(radius, width / 2, height / 2)); + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') { + ctx.roundRect(x, y, width, height, r); + return; + } + ctx.moveTo(x + r, y); + ctx.arcTo(x + width, y, x + width, y + height, r); + ctx.arcTo(x + width, y + height, x, y + height, r); + ctx.arcTo(x, y + height, x, y, r); + ctx.arcTo(x, y, x + width, y, r); + ctx.closePath(); +} + +function drawBackground(ctx, layout) { + const { style, width, height, scale } = layout; + ctx.fillStyle = style.background; + ctx.fillRect(0, 0, width, height); + if (!style.glows || typeof ctx.createRadialGradient !== 'function') { + return; + } + // CSS-like elliptical glows: draw a circular gradient in a scaled space. + style.glows.forEach((glow) => { + const radiusX = glow.radiusX * scale; + const radiusY = glow.radiusY * scale; + ctx.save(); + ctx.translate(glow.x * width, glow.y * height); + ctx.scale(1, radiusY / radiusX); + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX); + const [r, g, b] = glow.color; + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${glow.alpha})`); + gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0)`); + ctx.fillStyle = gradient; + ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2); + ctx.restore(); + }); +} + +// The site link sits at the right end of the brand row: a solid pill, with +// the caption to its left. When the row is too narrow the tagline goes +// first, then the caption; the pill always stays. +function drawBrand(ctx, layout, { icon, taglineText, captionText }) { + const { brand, style } = layout; + let x = brand.x; + if (icon) { + ctx.save(); + roundedRectPath(ctx, x, brand.centerY - brand.iconSize / 2, brand.iconSize, brand.iconSize, brand.iconRadius); + ctx.clip(); + ctx.drawImage(icon, x, brand.centerY - brand.iconSize / 2, brand.iconSize, brand.iconSize); + ctx.restore(); + x += brand.iconSize + brand.iconGap; + } + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.font = brand.font; + ctx.fillStyle = style.brandColor; + x += withLetterSpacing(ctx, brand.letterSpacing, () => { + ctx.fillText(SNAPSHOT_BRAND_NAME, x, brand.centerY); + return ctx.measureText(SNAPSHOT_BRAND_NAME).width; + }); + + ctx.font = brand.pillFont; + const pillWidth = Math.round(ctx.measureText(SNAPSHOT_SITE_LINK_TEXT).width) + brand.pillPaddingX * 2; + const pillX = brand.right - pillWidth; + ctx.font = brand.captionFont; + const captionWidth = captionText ? ctx.measureText(captionText).width : 0; + const captionRight = pillX - brand.ctaGap; + const showCaption = captionWidth > 0 && captionRight - captionWidth >= x + brand.ctaGap; + const ctaLeft = showCaption ? captionRight - captionWidth : pillX; + + if (brand.taglineFont && taglineText) { + ctx.font = brand.taglineFont; + const tagline = `· ${taglineText}`; + const taglineX = x + brand.iconGap; + if (taglineX + ctx.measureText(tagline).width + brand.ctaGap <= ctaLeft) { + ctx.fillStyle = style.taglineColor; + ctx.fillText(tagline, taglineX, brand.centerY); + } + } + + if (showCaption) { + ctx.font = brand.captionFont; + ctx.fillStyle = style.captionColor; + ctx.textAlign = 'right'; + ctx.fillText(captionText, captionRight, brand.centerY); + } + + roundedRectPath(ctx, pillX, brand.centerY - brand.pillHeight / 2, pillWidth, brand.pillHeight, brand.pillHeight / 2); + ctx.fillStyle = style.pillBackground; + ctx.fill(); + ctx.font = brand.pillFont; + ctx.fillStyle = style.pillColor; + ctx.textAlign = 'center'; + ctx.fillText(SNAPSHOT_SITE_LINK_TEXT, pillX + pillWidth / 2, brand.centerY); + ctx.textAlign = 'left'; +} + +function drawTitle(ctx, layout, titleLines, labelText) { + const { label, title, style } = layout; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + if (label && labelText) { + ctx.font = label.font; + ctx.fillStyle = style.labelColor; + withLetterSpacing(ctx, label.letterSpacing, () => { + ctx.fillText(labelText.toLocaleUpperCase(), label.x, label.y + label.height / 2); + }); + } + + ctx.font = title.font; + withLetterSpacing(ctx, title.letterSpacing, () => { + const openQuote = style.openQuote || ''; + const openWidth = openQuote ? ctx.measureText(openQuote).width + title.quoteGap : 0; + titleLines.forEach((line, index) => { + const lineY = title.y + title.lineHeight * (index + 0.5); + let x = title.x; + if (openQuote && index === 0) { + ctx.fillStyle = style.openQuoteColor; + ctx.fillText(openQuote, x, lineY); + } + x += openWidth; + ctx.fillStyle = style.titleColor; + ctx.fillText(line, x, lineY); + if (style.closeQuote && index === titleLines.length - 1) { + ctx.fillStyle = style.closeQuoteColor; + ctx.fillText(style.closeQuote, x + ctx.measureText(line).width, lineY); + } + }); + }); +} + +function drawSingleLine(ctx, box, text, color, align = 'left', measure) { + ctx.font = box.font; + ctx.fillStyle = color; + ctx.textAlign = align; + ctx.textBaseline = 'middle'; + const [line = ''] = wrapTextLines(text, box.maxWidth, measure, 1); + const x = align === 'center' ? box.x + box.maxWidth / 2 : box.x; + ctx.fillText(line, x, box.y + box.height / 2); + ctx.textAlign = 'left'; +} + +function drawSubline(ctx, layout, text, measure) { + const { subline, style } = layout; + ctx.font = subline.font; + ctx.fillStyle = style.sublineColor; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const lines = wrapTextLines(text, subline.maxWidth, measure, Math.round(subline.height / subline.lineHeight)); + lines.forEach((line, index) => { + ctx.fillText(line, subline.x, subline.y + subline.lineHeight * (index + 0.5)); + }); +} + +function drawCard(ctx, layout, content, { fadeBottom }) { + const { card, style } = layout; + ctx.save(); + ctx.shadowColor = style.cardShadow.color; + ctx.shadowBlur = card.shadowBlur; + ctx.shadowOffsetY = card.shadowOffsetY; + roundedRectPath(ctx, card.x, card.y, card.width, card.height, card.radius); + ctx.fillStyle = style.cardBackground; + ctx.fill(); + ctx.restore(); + + ctx.save(); + roundedRectPath(ctx, card.x, card.y, card.width, card.height, card.radius); + ctx.clip(); + if (card.titleBarHeight > 0) { + ctx.fillStyle = style.titleBarColor; + ctx.fillRect(card.x, card.y, card.width, card.titleBarHeight); + ctx.fillStyle = style.titleBarDotColor; + for (let index = 0; index < 3; index += 1) { + const cx = card.x + card.dotPaddingX + card.dotSize / 2 + index * (card.dotSize + card.dotGap); + ctx.beginPath(); + ctx.arc(cx, card.y + card.titleBarHeight / 2, card.dotSize / 2, 0, Math.PI * 2); + ctx.fill(); + } + } + const box = layout.content; + if (box.height > 0) { + ctx.drawImage(content, box.x, box.y, box.width, box.height); + } + if (fadeBottom && typeof ctx.createLinearGradient === 'function') { + const fadeHeight = Math.min(box.height, Math.round(140 * layout.scale)); + const gradient = ctx.createLinearGradient(0, box.y + box.height - fadeHeight, 0, box.y + box.height); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0)'); + gradient.addColorStop(1, style.cardBackground); + ctx.fillStyle = gradient; + ctx.fillRect(box.x, box.y + box.height - fadeHeight, box.width, fadeHeight); + } + ctx.restore(); + + ctx.save(); + roundedRectPath(ctx, card.x + card.border / 2, card.y + card.border / 2, + card.width - card.border, card.height - card.border, card.radius); + ctx.lineWidth = card.border; + ctx.strokeStyle = style.cardBorder; + ctx.stroke(); + ctx.restore(); +} + +/** + * Draws a laid-out snapshot. + * @param {CanvasRenderingContext2D} ctx - Context of a canvas sized to layout. + * @param {object} options + * @param {ReturnType} options.layout + * @param {CanvasImageSource} options.content - Drawn at layout.content size. + * @param {CanvasImageSource | null} [options.icon] + * @param {string[]} [options.titleLines] + * @param {string} [options.labelText] - Light theme "PROMPT" label. + * @param {string} [options.taglineText] - Light theme "Compare AI side by side". + * @param {string} [options.captionText] - "Free Chrome extension" beside the site link. + * @param {string} [options.sublineText] - Providers and date, see formatSnapshotSubline. + * @param {string} [options.noteText] - Truncation note; also fades the content bottom. + */ +export function drawSnapshot(ctx, { + layout, + content, + icon = null, + titleLines = [], + labelText = '', + taglineText = '', + captionText = '', + sublineText = '', + noteText = '' +}) { + const measure = (text) => ctx.measureText(text).width; + drawBackground(ctx, layout); + if (layout.brand) { + drawBrand(ctx, layout, { icon, taglineText: layout.style.variant === 'a' ? taglineText : '', captionText }); + } + if (layout.title) { + drawTitle(ctx, layout, titleLines, labelText); + } + if (layout.subline) { + drawSubline(ctx, layout, sublineText, measure); + } + drawCard(ctx, layout, content, { fadeBottom: Boolean(layout.note) }); + if (layout.note) { + drawSingleLine(ctx, layout.note, noteText, layout.style.noteColor, 'center', measure); + } +} + +function measureTitleLines(ctx, layoutOptions, title, theme) { + const probe = getSnapshotFrameLayout({ ...layoutOptions, titleLineCount: 1 }); + const style = probe.style; + ctx.font = probe.title.font; + return withLetterSpacing(ctx, probe.title.letterSpacing, () => { + const quotes = theme === 'dark' + ? ctx.measureText(style.openQuote).width + probe.title.quoteGap + + ctx.measureText(style.closeQuote).width + : 0; + return wrapTextLines(title, probe.title.maxWidth - quotes, (text) => ctx.measureText(text).width); + }); +} + +function measureSublineLines(ctx, layoutOptions, text) { + const probe = getSnapshotFrameLayout(layoutOptions); + ctx.font = probe.subline.font; + return wrapTextLines(text, probe.subline.maxWidth, (line) => ctx.measureText(line).width, SNAPSHOT_MAX_SUBLINE_LINES); +} + +/** + * Measures, lays out and draws a snapshot onto `canvas`. + * @param {HTMLCanvasElement | OffscreenCanvas} canvas + * @param {object} options + * @param {CanvasImageSource & {width: number, height: number}} options.content + * The capture (or stitched grid) in output pixels. + * @param {number} options.scale - Output pixels per CSS pixel of the content. + * @param {string} [options.title] - Headline; empty removes it. + * @param {string} [options.theme] - 'light' or 'dark'. + * @param {boolean} [options.showWatermark] + * @param {CanvasImageSource | null} [options.icon] + * @param {string} [options.labelText] + * @param {string} [options.taglineText] + * @param {string} [options.captionText] + * @param {string} [options.sublineText] + * @param {string} [options.noteText] + * @returns {ReturnType} + */ +export function composeSnapshot(canvas, { + content, + scale, + title = '', + theme = 'light', + showWatermark = true, + icon = null, + labelText = '', + taglineText = '', + captionText = '', + sublineText = '', + noteText = '' +}) { + const ctx = canvas.getContext('2d'); + const layoutOptions = { + contentWidth: content.width, + contentHeight: content.height, + scale, + theme, + showWatermark, + hasSubline: Boolean(sublineText), + hasNote: Boolean(noteText) + }; + + // Measure with the final font before the canvas is resized, which resets + // the context state. + const titleText = normalizePromptText(title); + const titleLines = titleText ? measureTitleLines(ctx, layoutOptions, titleText, theme) : []; + const sublineLineCount = sublineText ? measureSublineLines(ctx, layoutOptions, sublineText).length : 1; + const layout = getSnapshotFrameLayout({ ...layoutOptions, titleLineCount: titleLines.length, sublineLineCount }); + canvas.width = layout.width; + canvas.height = layout.height; + drawSnapshot(ctx, { + layout, content, icon, titleLines, labelText, taglineText, captionText, sublineText, noteText + }); + return layout; +} + +/** + * Subline of both themes, e.g. + * "Same prompt · ChatGPT, Grok, and DeepSeek · side by side · Oct 22, 2026". + * @param {object} options + * @param {string} [options.providersText] - Localized provider sentence; empty leaves only the date. + * @param {Date} options.date + * @param {string} [options.locale] - BCP 47 tag for the date. + */ +export function formatSnapshotSubline({ providersText, date, locale }) { + let dateText; + try { + dateText = new Intl.DateTimeFormat(locale || undefined, { dateStyle: 'medium' }).format(date); + } catch { + dateText = date.toDateString(); + } + return [providersText, dateText].filter(Boolean).join(' · '); +} + +/** + * Joins provider names the way the UI language lists things. + * @param {string[]} names + * @param {string} [locale] + */ +export function formatProviderList(names, locale) { + const unique = [...new Set(names.filter(Boolean))]; + try { + return new Intl.ListFormat(locale || undefined, { style: 'long', type: 'conjunction' }).format(unique); + } catch { + return unique.join(', '); + } +} + +/** + * Converts an extension locale id (zh_CN) to a BCP 47 tag (zh-CN). + * @param {string} locale + */ +export function toLanguageTag(locale) { + return String(locale || '').replace('_', '-') || undefined; +} + +function pad(value) { + return String(value).padStart(2, '0'); +} + +/** + * @param {Date} [date] + * @returns {string} e.g. panelize-comparison-20261020-213005.png + */ +export function getSnapshotFileName(date = new Date()) { + const day = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`; + const time = `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; + return `panelize-comparison-${day}-${time}.png`; +} diff --git a/modules/share-trigger.js b/modules/share-trigger.js new file mode 100644 index 0000000..a036311 --- /dev/null +++ b/modules/share-trigger.js @@ -0,0 +1,61 @@ +// Invoking the extension on the Multi-Panel page itself (toolbar icon, its +// shortcut or the context menu) takes a snapshot there instead of opening +// another Panelize tab. That same gesture gives the page Chrome's activeTab +// grant, so the capture needs no confirmation dialog. + +export const SHARE_SNAPSHOT_ACTION = 'shareSnapshot'; + +/** Command Chrome runs for the toolbar icon's keyboard shortcut. */ +export const EXECUTE_ACTION_COMMAND = '_execute_action'; + +export const MULTI_PANEL_PAGE_PATH = 'multi-panel/multi-panel.html'; + +function stripQueryAndHash(url) { + return String(url || '').split(/[?#]/)[0]; +} + +/** + * True when `url` is the Multi-Panel page, whatever its query or hash. + * @param {string | undefined} url + * @param {string} multiPanelUrl - chrome.runtime.getURL(MULTI_PANEL_PAGE_PATH). + */ +export function isMultiPanelPageUrl(url, multiPanelUrl) { + return Boolean(url) && stripQueryAndHash(url) === stripQueryAndHash(multiPanelUrl); +} + +/** + * Whether `tab` shows the Multi-Panel page. Falls back to the extension's own + * contexts when Chrome leaves the tab URL out. + * @param {chrome.tabs.Tab | undefined} tab + * @param {typeof chrome.runtime} [runtime] + */ +export async function isMultiPanelTab(tab, runtime = globalThis.chrome?.runtime) { + if (!tab || typeof tab.id !== 'number' || tab.id < 0) { + return false; + } + const multiPanelUrl = runtime.getURL(MULTI_PANEL_PAGE_PATH); + const url = tab.url || tab.pendingUrl; + if (url) { + return isMultiPanelPageUrl(url, multiPanelUrl); + } + if (typeof runtime.getContexts !== 'function') { + return false; + } + try { + const contexts = await runtime.getContexts({ contextTypes: ['TAB'], tabIds: [tab.id] }); + return contexts.some((context) => context.frameId === 0 + && isMultiPanelPageUrl(context.documentUrl, multiPanelUrl)); + } catch (error) { + return false; + } +} + +/** + * The shortcut bound to the toolbar icon, as Chrome shows it (e.g. "⇧⌘E" or + * "Ctrl+Shift+E"), or '' when the user removed it. + * @param {chrome.commands.Command[]} commands - chrome.commands.getAll() result. + */ +export function getExecuteActionShortcut(commands) { + const command = (commands || []).find((item) => item?.name === EXECUTE_ACTION_COMMAND); + return String(command?.shortcut || '').trim(); +} diff --git a/modules/snapshot-capture.js b/modules/snapshot-capture.js new file mode 100644 index 0000000..4805656 --- /dev/null +++ b/modules/snapshot-capture.js @@ -0,0 +1,776 @@ +// Captures the panel grid for a share snapshot. "Current view" lines every +// conversation up on the shared prompt and takes one frame. "Full answers" +// scrolls all panels in lock-step, takes one frame per step and stitches +// each panel's conversation, laid out like the grid on screen. Provider +// pages are scrolled through the content script's PANELIZE_SNAPSHOT_* +// messages and put back afterwards. + +import { + CANVAS_MAX_AREA, + CANVAS_MAX_DIMENSION, + SNAPSHOT_MAX_CAPTURE_WIDTH, + SNAPSHOT_MAX_OUTPUT_HEIGHT +} from './share-snapshot.js'; +import { + STITCH_GRID_GAP, + STITCH_FULL_PANEL_TOLERANCE, + STITCH_GRID_PADDING, + STITCH_MIN_VISIBLE_WIDTH, + addStitchScreen, + allocateRowContentHeights, + createStitchTrack, + fitCanvasScale, + getDominantColor, + getNextStitchScrollTop, + getRemainingStitchHeight, + groupCellsIntoRows, + layoutStitchedGrid +} from './snapshot-stitch.js'; +import { cropCapturedFrame } from './tab-capture.js'; + +export const SNAPSHOT_MODES = Object.freeze({ + CURRENT: 'current', + FULL: 'full' +}); + +export const SNAPSHOT_MESSAGES = Object.freeze({ + PREPARE: 'PANELIZE_SNAPSHOT_PREPARE', + SCROLL: 'PANELIZE_SNAPSHOT_SCROLL', + RESTORE: 'PANELIZE_SNAPSHOT_RESTORE', + STATE: 'PANELIZE_SNAPSHOT_STATE' +}); + +const HOST_CONTEXT = 'multi-panel'; +const STATE_CONTEXT = 'multi-panel-snapshot'; + +// Matching the prompt walks the whole page text; scrolling waits for paint. +const PREPARE_TIMEOUT_MS = 2500; +const SCROLL_TIMEOUT_MS = 1500; +const RESTORE_TIMEOUT_MS = 1500; + +/** Upper bound on captures for one snapshot, whatever the pages report. */ +export const MAX_STITCH_SCREENS = 60; + +/** Height of the strip sampled for the fill color under shorter answers. */ +const FILL_SAMPLE_HEIGHT = 24; +/** Frame pixels an edge may be off by float error and still count as on a row. */ +const EDGE_EPSILON = 0.01; + +export class SnapshotCancelledError extends Error { + constructor() { + super('Snapshot cancelled'); + this.name = 'SnapshotCancelledError'; + } +} + +/** + * The window, grid or a panel changed size during a capture, so the panels + * are no longer where they were measured and the frames cannot be cropped. + */ +export class SnapshotLayoutChangedError extends Error { + constructor() { + super('The layout changed during the snapshot'); + this.name = 'SnapshotLayoutChangedError'; + } +} + +function throwIfAborted(signal) { + if (signal?.aborted) { + throw new SnapshotCancelledError(); + } +} + +/** + * Returns a check that throws SnapshotLayoutChangedError once the window + * size, device scale, grid box, grid scroll or a panel's box differs from + * now, e.g. after a tiling window manager re-tiles the window or a panel + * divider is dragged mid-capture. + * @param {HTMLElement} grid + * @param {SnapshotPanel[]} panels + */ +function watchLayout(grid, panels) { + const boxOf = (element) => { + const box = element.getBoundingClientRect(); + return [box.left, box.top, box.width, box.height]; + }; + const read = () => [ + window.innerWidth, window.innerHeight, window.devicePixelRatio, + ...boxOf(grid), grid.scrollLeft, grid.scrollTop, + ...panels.flatMap((panel) => [...boxOf(panel.element), ...boxOf(panel.iframe)]) + ] + .map((value) => Math.round(value * 100)) + .join(','); + const measured = read(); + return () => { + if (read() !== measured) { + throw new SnapshotLayoutChangedError(); + } + }; +} + +/** + * Request/response over postMessage with the provider frames. Replies are + * accepted only from the frame that was asked. + * @param {Window} [host] + */ +export function createSnapshotBridge(host = window) { + const pending = new Map(); + let nextId = 0; + const sessionId = `snapshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + + const onMessage = (event) => { + const data = event.data; + if (!data || data.type !== SNAPSHOT_MESSAGES.STATE || data.context !== STATE_CONTEXT) { + return; + } + const entry = pending.get(data.requestId); + if (!entry || event.source !== entry.target) { + return; + } + pending.delete(data.requestId); + clearTimeout(entry.timer); + entry.resolve(data); + }; + host.addEventListener('message', onMessage); + + return { + sessionId, + /** + * @returns {Promise} The frame's state, or null when it + * did not answer in time (no content script, still loading). + */ + request(iframe, type, data = {}, timeoutMs = SCROLL_TIMEOUT_MS) { + const target = iframe?.contentWindow; + if (!target) { + return Promise.resolve(null); + } + nextId += 1; + const requestId = `${sessionId}-${nextId}`; + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(requestId); + resolve(null); + }, timeoutMs); + pending.set(requestId, { target, resolve, timer }); + try { + target.postMessage({ ...data, type, context: HOST_CONTEXT, requestId, sessionId }, '*'); + } catch (error) { + pending.delete(requestId); + clearTimeout(timer); + resolve(null); + } + }); + }, + dispose() { + host.removeEventListener('message', onMessage); + pending.forEach((entry) => { + clearTimeout(entry.timer); + entry.resolve(null); + }); + pending.clear(); + } + }; +} + +function toRect({ left, top, right, bottom }) { + return { left, top, right, bottom, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }; +} + +/** Intersection of two rectangles, possibly empty. */ +export function intersectRects(a, b) { + return toRect({ + left: Math.max(a.left, b.left), + top: Math.max(a.top, b.top), + right: Math.max(Math.max(a.left, b.left), Math.min(a.right, b.right)), + bottom: Math.max(Math.max(a.top, b.top), Math.min(a.bottom, b.bottom)) + }); +} + +/** + * Panels worth capturing: rendered and fully inside the visible part of the + * grid, so a scrolled grid never yields half panels. When none fits, as in a + * window narrower than one column, the most visible panel is taken instead. + * @param {{rect: DOMRectReadOnly, hidden?: boolean}[]} panels + * @param {ReturnType} visibleGrid + * @returns {{captured: object[], outOfView: object[]}} Panels to capture, + * and the rendered ones left out. + */ +export function selectVisiblePanels(panels, visibleGrid) { + const shown = panels.filter((panel) => !panel.hidden); + const visibleOf = new Map(shown.map((panel) => [panel, intersectRects(panel.rect, visibleGrid)])); + let captured = shown.filter((panel) => { + const visible = visibleOf.get(panel); + // The grid only scrolls sideways, so columns are what get cut. + return visible.height > 0 && visible.width >= panel.rect.width - STITCH_FULL_PANEL_TOLERANCE; + }); + if (captured.length === 0) { + const area = (panel) => visibleOf.get(panel).width * visibleOf.get(panel).height; + const best = shown + .filter((panel) => visibleOf.get(panel).width >= STITCH_MIN_VISIBLE_WIDTH && visibleOf.get(panel).height > 0) + .reduce((most, panel) => (!most || area(panel) > area(most) ? panel : most), null); + captured = best ? [best] : []; + } + return { captured, outOfView: shown.filter((panel) => !captured.includes(panel)) }; +} + +/** + * Part of the grid to capture: the captured panels plus the grid padding + * around them, within the visible grid. Sideways, the padding is as wide on + * both sides: a scrolled grid snaps a panel flush to its edge. + * @param {{rect: DOMRectReadOnly}[]} panels + * @param {ReturnType} visibleGrid + */ +export function getCaptureArea(panels, visibleGrid) { + if (panels.length === 0) { + return visibleGrid; + } + const left = Math.min(...panels.map((panel) => panel.rect.left)); + const right = Math.max(...panels.map((panel) => panel.rect.right)); + const paddingX = Math.max(0, Math.min(STITCH_GRID_PADDING, left - visibleGrid.left, visibleGrid.right - right)); + return intersectRects(toRect({ + left: left - paddingX, + top: Math.min(...panels.map((panel) => panel.rect.top)) - STITCH_GRID_PADDING, + right: right + paddingX, + bottom: Math.max(...panels.map((panel) => panel.rect.bottom)) + STITCH_GRID_PADDING + }), visibleGrid); +} + +/** + * Where the parts of one panel sit on screen, in page CSS pixels. + * @param {object} options + * @param {DOMRectReadOnly} options.panelRect - The .panel-item. + * @param {DOMRectReadOnly} options.iframeRect + * @param {object} options.state - The frame's PANELIZE_SNAPSHOT_STATE. + * @param {ReturnType} options.visibleGrid + * @returns {{left: number, right: number, top: number, headBottom: number, + * contentLeft: number, contentRight: number, zoom: number}} + * Columns of the cell, the panel header plus everything above the + * conversation (head), and the conversation's columns. + */ +export function getPanelCellGeometry({ panelRect, iframeRect, state, visibleGrid }) { + const left = Math.max(panelRect.left, visibleGrid.left); + const right = Math.min(panelRect.right, visibleGrid.right); + const zoom = state.viewport?.width > 0 ? iframeRect.width / state.viewport.width : 1; + const clip = state.clip; + return { + left, + right, + top: panelRect.top, + headBottom: Math.min(panelRect.bottom, iframeRect.top + clip.top * zoom), + contentLeft: Math.max(left, iframeRect.left + clip.left * zoom), + contentRight: Math.min(right, iframeRect.left + clip.right * zoom), + zoom + }; +} + +/** + * Output scale and the content height budget, in CSS pixels, for a stitched + * grid of `gridWidth` CSS pixels. + * @param {object} options + * @param {number} options.frameScale - Captured pixels per CSS pixel. + * @param {number} options.gridWidth + * @param {(scale: number) => number} options.getChromeHeight - Frame height + * around the content, in output pixels. + * @param {number} [options.maxOutputHeight] + */ +export function getStitchBudget({ + frameScale, + gridWidth, + getChromeHeight, + maxOutputHeight = SNAPSHOT_MAX_OUTPUT_HEIGHT +}) { + const scale = Math.min(frameScale, SNAPSHOT_MAX_CAPTURE_WIDTH / gridWidth); + const chrome = getChromeHeight(scale); + let height = Math.max(0, (maxOutputHeight - chrome) / scale); + // Canvas limits only bite for unusual sizes, but must never be crossed. + const fitted = fitCanvasScale(gridWidth, height, scale, { + maxDimension: CANVAS_MAX_DIMENSION, + maxArea: CANVAS_MAX_AREA + }); + if (fitted < scale) { + height = Math.floor(height * fitted / scale); + } + return { scale, contentHeight: Math.floor(height) }; +} + +function createCanvas(width, height) { + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + return canvas; +} + +/** + * Crops page CSS rectangle `rect` from a whole-tab frame at `scale`. + * `height` overrides the output height, e.g. to end a segment exactly where + * the next one starts. + * @param {object} [options] + * @param {boolean} [options.inside] - Keep the top and bottom edges to rows + * wholly inside `rect`. A stitched screen of a page scaled to fit its + * panel starts and ends between rows, and the row it shares with the page's + * header or composer would show as a line at every seam. + */ +function cropPageRect(frame, rect, scale, height = Math.max(1, Math.round(rect.height * scale)), { inside = false } = {}) { + const frameScaleX = frame.width / window.innerWidth; + const frameScaleY = frame.height / window.innerHeight; + // Round edges rather than sizes, so adjacent rectangles share their edge. + const left = Math.round(rect.left * frameScaleX); + const frameTop = rect.top * frameScaleY; + const frameBottom = (rect.top + rect.height) * frameScaleY; + // The margin keeps float error from dropping a whole row. + const top = inside ? Math.ceil(frameTop - EDGE_EPSILON) : Math.round(frameTop); + const bottom = inside ? Math.floor(frameBottom + EDGE_EPSILON) : Math.round(frameBottom); + return createImageBitmap( + frame, + left, + top, + Math.max(1, Math.round((rect.left + rect.width) * frameScaleX) - left), + Math.max(1, bottom - top), + { resizeWidth: Math.max(1, Math.round(rect.width * scale)), resizeHeight: height, resizeQuality: 'high' } + ); +} + +function sampleFillColor(bitmap) { + if (!bitmap) { + return null; + } + const height = Math.min(bitmap.height, FILL_SAMPLE_HEIGHT); + const canvas = createCanvas(bitmap.width, height); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(bitmap, 0, bitmap.height - height, bitmap.width, height, 0, 0, bitmap.width, height); + return getDominantColor(ctx.getImageData(0, 0, canvas.width, canvas.height).data); +} + +/** + * Lines the panels up and hides floating buttons, fades and overlay + * scrollbars. Stitching also un-sticks headings and always starts from the + * latest turn. + * @param {object} options + * @param {string} options.prompt + * @param {boolean} [options.stitch] + * @param {boolean} [options.hideCursor] - Hide the pointer, which tab + * sharing can draw into the frames. + */ +async function prepareAll(bridge, panels, { prompt, stitch = false, hideCursor = false }, signal) { + const states = await Promise.all(panels.map((panel) => bridge.request( + panel.iframe, + SNAPSHOT_MESSAGES.PREPARE, + { align: Boolean(prompt), prompt: prompt || '', stitch, hideFloating: true, hideCursor }, + PREPARE_TIMEOUT_MS + ))); + throwIfAborted(signal); + return states; +} + +function restoreAll(bridge, panels) { + return Promise.all(panels.map((panel) => bridge.request( + panel.iframe, + SNAPSHOT_MESSAGES.RESTORE, + {}, + RESTORE_TIMEOUT_MS + ))); +} + +function isUsableState(state) { + return Boolean(state && !state.error && state.clip && state.clip.bottom > state.clip.top); +} + +/** + * @typedef {object} SnapshotPanel + * @property {string} id + * @property {string} name - Provider name shown in notes. + * @property {HTMLIFrameElement} iframe + * @property {HTMLElement} element - The .panel-item. + */ + +/** + * @typedef {object} SnapshotCaptureResult + * @property {CanvasImageSource & {width: number, height: number}} content + * @property {number} scale - Output pixels per CSS pixel. + * @property {string[]} names - Providers in the image, in grid order. + * @property {string[]} currentViewOnly - Providers shown as on screen in full mode. + * @property {string[]} outOfView - Open providers left out because they were + * not fully on screen, in grid order. + * @property {string[]} truncated - Providers whose answer was cut short to + * fit the image, in grid order. + * @property {string[]} interrupted - Providers whose page stopped answering + * or scrolling partway, so only the start of their answer is shown, in + * grid order. + * @property {number} screens - Captures taken. + * @property {SnapshotAlignment[]} alignment - Where each panel started and ended. + */ + +/** + * @typedef {object} SnapshotAlignment + * @property {string} name + * @property {'prompt' | 'user-turn' | 'top' | null} alignedBy - What the + * capture started from; null when the panel was left as it was. + * @property {number | null} originalScrollTop + * @property {number | null} startScrollTop + * @property {number | null} endScrollTop + * @property {number | null} maxScrollTop + */ + +function describeAlignment(name, firstState, lastState = firstState) { + const usable = isUsableState(firstState); + return { + name, + alignedBy: usable ? firstState.alignedBy || null : null, + originalScrollTop: usable ? firstState.originalScrollTop ?? null : null, + startScrollTop: usable ? firstState.scrollTop : null, + endScrollTop: isUsableState(lastState) ? lastState.scrollTop : null, + maxScrollTop: isUsableState(lastState) ? lastState.maxScrollTop : null + }; +} + +/** Panels to capture, the area around them and the names left out. */ +function readPanels(panels, grid) { + const visibleGrid = intersectRects(grid.getBoundingClientRect(), toRect({ + left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight + })); + const { captured, outOfView } = selectVisiblePanels(panels.map((panel) => ({ + ...panel, + rect: panel.element.getBoundingClientRect(), + hidden: !panel.element.isConnected || panel.element.checkVisibility?.() === false + })), visibleGrid); + return { + captured, + area: getCaptureArea(captured, visibleGrid), + outOfView: orderByGrid(outOfView).map((panel) => panel.name) + }; +} + +function orderByGrid(panels) { + return groupCellsIntoRows(panels).flat(); +} + +/** + * Current view: aligns each conversation on the prompt, then takes one frame + * of the visible grid. + * @param {object} options + * @param {{grab: () => Promise}} options.source + * @param {HTMLElement} options.grid - #panel-grid. + * @param {SnapshotPanel[]} options.panels + * @param {string} options.prompt - Shared prompt to align on; '' leaves panels as they are. + * @param {boolean} [options.hideCursor] + * @param {(signal?: AbortSignal) => Promise} [options.beforeGrab] - Runs + * before each frame, e.g. to wait for the pointer to keep still. + * @param {AbortSignal} [options.signal] + * @returns {Promise} + */ +export async function captureCurrentView({ + source, grid, panels, prompt, hideCursor = false, beforeGrab = async () => {}, signal +}) { + const bridge = createSnapshotBridge(); + const checkLayout = watchLayout(grid, panels); + const { captured, area, outOfView } = readPanels(panels, grid); + const visiblePanels = orderByGrid(captured); + try { + const states = await prepareAll(bridge, visiblePanels, { prompt, hideCursor }, signal); + await beforeGrab(signal); + const frame = await source.grab(); + try { + throwIfAborted(signal); + checkLayout(); + const { bitmap, scale } = await cropCapturedFrame(frame, area, { + maxWidth: SNAPSHOT_MAX_CAPTURE_WIDTH + }); + return { + content: bitmap, + scale, + names: visiblePanels.map((panel) => panel.name), + currentViewOnly: [], + outOfView, + truncated: [], + interrupted: [], + screens: 1, + alignment: visiblePanels.map((panel, index) => describeAlignment(panel.name, states[index])) + }; + } finally { + frame.close?.(); + } + } finally { + await restoreAll(bridge, visiblePanels); + bridge.dispose(); + } +} + +/** + * Full answers: stitches every visible panel's conversation from the + * prompt down, one frame per lock-step scroll, within the output limits. + * Panels whose page does not answer are shown as on screen. + * @param {object} options + * @param {{grab: () => Promise}} options.source + * @param {HTMLElement} options.grid + * @param {SnapshotPanel[]} options.panels + * @param {string} options.prompt + * @param {(scale: number) => number} options.getChromeHeight - See getStitchBudget. + * @param {boolean} [options.hideCursor] + * @param {(signal?: AbortSignal) => Promise} [options.beforeGrab] - Runs + * before each frame, e.g. to wait for the pointer to keep still. + * @param {AbortSignal} [options.signal] + * @param {(progress: {step: number, total: number}) => void} [options.onProgress] + * @returns {Promise} + */ +export async function captureFullAnswers({ + source, grid, panels, prompt, getChromeHeight, hideCursor = false, beforeGrab = async () => {}, signal, + onProgress = () => {} +}) { + const bridge = createSnapshotBridge(); + const checkLayout = watchLayout(grid, panels); + const { captured: visiblePanels, area, outOfView } = readPanels(panels, grid); + const bitmaps = []; + try { + const states = await prepareAll(bridge, visiblePanels, { prompt, stitch: true, hideCursor }, signal); + await beforeGrab(signal); + let frame = await source.grab(); + let screens = 1; + try { + throwIfAborted(signal); + checkLayout(); + const frameScale = frame.width / window.innerWidth; + const { scale, contentHeight: budget } = getStitchBudget({ + frameScale, + gridWidth: area.width, + getChromeHeight + }); + + // Head (panel header and anything above the conversation) comes from + // the first frame; so does the whole panel when its page did not answer. + const cells = await Promise.all(visiblePanels.map(async (panel, index) => { + const state = states[index]; + const iframeRect = panel.iframe.getBoundingClientRect(); + const panelRect = panel.rect; + if (!isUsableState(state)) { + const rect = intersectRects(panelRect, area); + const head = await cropPageRect(frame, rect, scale); + bitmaps.push(head); + return { + panel, rect: { left: rect.left, top: panelRect.top, width: rect.width }, + fallback: true, headHeight: rect.height, head, track: null, segments: [] + }; + } + const geometry = getPanelCellGeometry({ panelRect, iframeRect, state, visibleGrid: area }); + const headHeight = Math.max(0, geometry.headBottom - geometry.top); + const head = headHeight > 0 + ? await cropPageRect(frame, toRect({ + left: geometry.left, top: geometry.top, right: geometry.right, bottom: geometry.headBottom + }), scale) + : null; + if (head) { + bitmaps.push(head); + } + return { + panel, + rect: { left: geometry.left, top: panelRect.top, width: geometry.right - geometry.left }, + fallback: false, + geometry, + iframeTop: iframeRect.top, + headHeight, + head, + state, + track: createStitchTrack(state), + segments: [] + }; + })); + + // Pages scaled to fit their panel are measured in their own CSS pixels; + // the image is planned in this page's. + const zoomOf = (cell) => cell.geometry.zoom; + + // Caps per row so capturing stops once the image is full. + const rows = groupCellsIntoRows(cells); + const rowBudget = budget - STITCH_GRID_PADDING * 2 - STITCH_GRID_GAP * Math.max(0, rows.length - 1); + const estimates = rows.map((row) => { + const fixed = Math.max(...row.map((cell) => cell.headHeight)); + const content = Math.max(0, ...row.map((cell) => ( + cell.track ? cell.headHeight + getRemainingStitchHeight(cell.track) * zoomOf(cell) - fixed : 0 + ))); + return { fixed, content }; + }); + const planned = allocateRowContentHeights(estimates, rowBudget); + const slack = planned.truncated + ? 0 + : Math.max(0, rowBudget - estimates.reduce((sum, row) => sum + row.fixed + row.content, 0)); + rows.forEach((row, rowIndex) => { + row.forEach((cell) => { + if (cell.track) { + // Cells with a taller head get less room for the conversation. + const cap = Math.max(0, planned.caps[rowIndex] + slack + estimates[rowIndex].fixed - cell.headHeight); + cell.track.cap = cap / zoomOf(cell); + } + }); + }); + + const takeSegments = async (currentFrame, frameIndex, statesByCell) => { + for (const cell of cells) { + const state = statesByCell.get(cell); + if (!cell.track || cell.track.done || !state) { + continue; + } + const segment = addStitchScreen(cell.track, state, frameIndex); + if (!segment) { + continue; + } + const { geometry } = cell; + const top = cell.iframeTop + segment.top * zoomOf(cell); + const contentTop = segment.contentTop * zoomOf(cell); + const height = segment.height * zoomOf(cell); + // Rounded where it is drawn, so it ends where the next one starts. + const bitmap = await cropPageRect(currentFrame, toRect({ + left: geometry.contentLeft, + right: geometry.contentRight, + top, + bottom: top + height + }), scale, Math.max(1, Math.round((contentTop + height) * scale) - Math.round(contentTop * scale)), { inside: true }); + bitmaps.push(bitmap); + cell.segments.push({ bitmap, contentTop, height }); + } + }; + + const estimateTotal = () => screens + Math.max(0, ...cells.map((cell) => ( + cell.track && !cell.track.done + ? Math.ceil(getRemainingStitchHeight(cell.track) / cell.track.step) + : 0 + ))); + + await takeSegments(frame, 0, new Map(cells.map((cell) => [cell, cell.state]))); + onProgress({ step: screens, total: estimateTotal() }); + + while (screens < MAX_STITCH_SCREENS && cells.some((cell) => cell.track && !cell.track.done)) { + throwIfAborted(signal); + const active = cells.filter((cell) => cell.track && !cell.track.done); + const nextStates = await Promise.all(active.map((cell) => bridge.request( + cell.panel.iframe, + SNAPSHOT_MESSAGES.SCROLL, + { scrollTop: getNextStitchScrollTop(cell.track, cell.state) }, + SCROLL_TIMEOUT_MS + ))); + throwIfAborted(signal); + const statesByCell = new Map(); + active.forEach((cell, index) => { + const state = nextStates[index]; + if (!isUsableState(state)) { + // The page stopped answering (e.g. it navigated): keep what we + // have and say it is not the whole answer. + cell.track.done = true; + cell.interrupted = true; + return; + } + cell.state = state; + statesByCell.set(cell, state); + }); + if (statesByCell.size === 0) { + break; + } + frame.close?.(); + await beforeGrab(signal); + frame = await source.grab(); + throwIfAborted(signal); + checkLayout(); + await takeSegments(frame, screens, statesByCell); + screens += 1; + onProgress({ step: screens, total: estimateTotal() }); + } + + const content = drawStitchedGrid({ cells, rows, area, scale, budget, grid }); + // Short of its end because the page stopped answering or scrolling. + const isInterrupted = (cell) => Boolean(cell.interrupted || cell.track?.stalled); + const isCut = (cell) => Boolean(cell.track) + && (cell.track.truncated || !cell.track.done || content.cut.has(cell)); + return { + content: content.canvas, + scale, + names: orderByGrid(cells).map((cell) => cell.panel.name), + currentViewOnly: orderByGrid(cells).filter((cell) => cell.fallback).map((cell) => cell.panel.name), + outOfView, + truncated: orderByGrid(cells).filter((cell) => !isInterrupted(cell) && isCut(cell)).map((cell) => cell.panel.name), + interrupted: orderByGrid(cells).filter(isInterrupted).map((cell) => cell.panel.name), + screens, + alignment: orderByGrid(cells).map((cell) => describeAlignment( + cell.panel.name, + states[visiblePanels.indexOf(cell.panel)], + cell.state + )) + }; + } finally { + frame.close?.(); + } + } finally { + bitmaps.forEach((bitmap) => bitmap.close?.()); + await restoreAll(bridge, visiblePanels); + bridge.dispose(); + } +} + +function getStitchedCellHeight(cell) { + if (cell.fallback) { + return cell.headHeight; + } + const content = cell.segments.reduce((end, segment) => Math.max(end, segment.contentTop + segment.height), 0); + return cell.headHeight + content; +} + +function drawStitchedGrid({ cells, rows, area, scale, budget, grid }) { + // Fit what was actually captured, which can differ from the estimate. + const rowBudget = budget - STITCH_GRID_PADDING * 2 - STITCH_GRID_GAP * Math.max(0, rows.length - 1); + const fitted = allocateRowContentHeights(rows.map((row) => { + const fixed = Math.max(...row.map((cell) => cell.headHeight)); + const content = Math.max(0, ...row.map((cell) => getStitchedCellHeight(cell) - fixed)); + return { fixed, content }; + }), rowBudget); + // Cells taller than their row's cap lose the end of their answer. + const cut = new Set(); + const layout = layoutStitchedGrid({ + rows: rows.map((row, rowIndex) => { + const fixed = Math.max(...row.map((cell) => cell.headHeight)); + return row.map((cell) => { + const full = getStitchedCellHeight(cell); + const height = Math.min(full, fixed + fitted.caps[rowIndex]); + if (height < full) { + cut.add(cell); + } + return { rect: cell.rect, height }; + }); + }), + gridLeft: area.left, + gridWidth: area.width + }); + + const canvas = createCanvas(layout.width * scale, layout.height * scale); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = window.getComputedStyle(grid).backgroundColor || '#e0e0e0'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const px = (value) => Math.round(value * scale); + + rows.forEach((row, rowIndex) => { + const placedRow = layout.rows[rowIndex]; + row.forEach((cell, cellIndex) => { + const placed = placedRow.cells[cellIndex]; + const x = px(placed.x); + const y = px(placed.y); + const width = px(placed.width); + const height = px(placed.height); + const lastSegment = cell.segments[cell.segments.length - 1]; + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, width, height); + ctx.clip(); + // Below shorter answers, continue the page's own background. + ctx.fillStyle = sampleFillColor(lastSegment?.bitmap || cell.head) || '#ffffff'; + ctx.fillRect(x, y, width, height); + if (cell.head) { + ctx.drawImage(cell.head, x, y, width, px(cell.headHeight)); + } + if (!cell.fallback) { + const contentX = x + px(cell.geometry.contentLeft - cell.rect.left); + const contentY = y + px(cell.headHeight); + cell.segments.forEach((segment) => { + ctx.drawImage(segment.bitmap, contentX, contentY + px(segment.contentTop), segment.bitmap.width, segment.bitmap.height); + }); + } + ctx.restore(); + }); + }); + return { canvas, cut }; +} diff --git a/modules/snapshot-stitch.js b/modules/snapshot-stitch.js new file mode 100644 index 0000000..31e2325 --- /dev/null +++ b/modules/snapshot-stitch.js @@ -0,0 +1,279 @@ +// Planning for "Full answers" snapshots: every panel scrolls in lock-step, +// one tab capture per step serves all of them, and each panel's +// conversation is stitched from the new part of every screen. Pure helpers, +// in CSS pixels unless noted. + +/** Share of the visible conversation taken from each screen. The rest, at + * the bottom, is where floating buttons and fades sit; it is only used on + * the last screen. */ +export const STITCH_STEP_RATIO = 0.75; + +/** Gap and padding of #panel-grid. */ +export const STITCH_GRID_GAP = 4; +export const STITCH_GRID_PADDING = 4; + +/** + * When no panel is fully on screen, the most visible one is captured if it + * shows at least this much. + */ +export const STITCH_MIN_VISIBLE_WIDTH = 40; + +/** Panels cut by at most this much still count as fully on screen. */ +export const STITCH_FULL_PANEL_TOLERANCE = 2; + +const ROW_TOLERANCE_PX = 2; + +/** Rounding a page may add to where it was asked to scroll. */ +const SCROLL_TOLERANCE_PX = 1; + +/** + * Times a page may answer without scrolling on, or by jumping past what is + * covered, before it is given up on, e.g. once while it puts back its own + * scroll position. + */ +export const STITCH_STALL_RETRIES = 1; + +/** + * Scroll distance between two captures. + * @param {number} clipHeight - Visible conversation height. + */ +export function getStitchStep(clipHeight) { + return Math.max(1, Math.floor(clipHeight * STITCH_STEP_RATIO)); +} + +/** + * Starts tracking one panel. + * @param {object} state - First PANELIZE_SNAPSHOT_STATE of the panel. + * @param {{top: number, bottom: number}} state.clip - Frame viewport pixels. + * @param {number} state.scrollTop + * @param {number} state.maxScrollTop + * @param {number} cap - Most content to keep, or Infinity. + */ +export function createStitchTrack({ clip, scrollTop, maxScrollTop }, cap = Infinity) { + const clipHeight = Math.max(0, clip.bottom - clip.top); + const start = scrollTop + clip.top; + return { + clip, + step: getStitchStep(clipHeight), + start, + coveredUntil: start, + // Where the conversation ends, in content pixels; updated as it grows. + end: maxScrollTop + clip.bottom, + cap, + done: clipHeight === 0, + truncated: false, + // Stopped short of its end because the page would not scroll on. + stalled: false, + stalls: 0, + segments: [] + }; +} + +/** Content still to capture, capped. */ +export function getRemainingStitchHeight(track) { + return Math.max(0, Math.min(track.end, track.start + track.cap) - track.coveredUntil); +} + +/** + * Adds the part of a screen not yet covered. Mutates and returns `track`. + * @param {ReturnType} track + * @param {{scrollTop: number, maxScrollTop: number}} state - Panel state at this capture. + * @param {number} frameIndex - Capture the segment comes from. + * @returns {{frameIndex: number, top: number, height: number, contentTop: number} | null} + * Segment in frame viewport pixels (top) and its offset in the stitched + * content (contentTop). + */ +export function addStitchScreen(track, { scrollTop, maxScrollTop }, frameIndex) { + if (track.done) { + return null; + } + const { clip } = track; + track.end = Math.max(track.end, maxScrollTop + clip.bottom); + const atEnd = scrollTop >= maxScrollTop - 1; + // Each screen starts where the last one stopped; only the last screen + // reaches down to the bottom of the visible conversation. + const visibleTop = scrollTop + clip.top; + const visibleBottom = scrollTop + clip.bottom; + const limit = track.start + track.cap; + // A page that jumped past what is covered would leave a hole. + const skipped = visibleTop > track.coveredUntil + SCROLL_TOLERANCE_PX; + + const from = Math.max(track.coveredUntil, visibleTop); + let to = atEnd ? visibleBottom : Math.min(visibleBottom, visibleTop + track.step); + to = Math.min(to, limit); + + let segment = null; + if (!skipped && to > from) { + segment = { + frameIndex, + top: from - scrollTop, + height: to - from, + contentTop: from - track.start + }; + track.segments.push(segment); + track.coveredUntil = to; + } + + if (track.coveredUntil >= limit && track.end > limit) { + track.truncated = true; + track.done = true; + } else if (segment) { + track.stalls = 0; + // The bottom of a page at its end is the end, even if the page got shorter. + track.done = track.coveredUntil >= track.end || (atEnd && to >= visibleBottom); + } else if (atEnd && !skipped) { + // Nothing below what is covered, e.g. the page got shorter. + track.done = true; + } else { + // The page did not scroll on, or jumped past what is covered: ask + // again, then keep what joins up. + track.stalls += 1; + if (track.stalls > STITCH_STALL_RETRIES) { + track.stalled = true; + track.done = true; + } + } + return segment; +} + +/** + * Where to scroll the panel for the next capture, so the next segment starts + * right at the top of the visible conversation; null when done. + */ +export function getNextStitchScrollTop(track, { maxScrollTop }) { + if (track.done) { + return null; + } + return Math.min(maxScrollTop, track.coveredUntil - track.clip.top); +} + +/** Height of the stitched content so far. */ +export function getStitchedHeight(track) { + return track.coveredUntil - track.start; +} + +/** + * Groups on-screen panel rectangles into grid rows, top to bottom, each + * sorted left to right. + * @template {{rect: {top: number, left: number}}} T + * @param {T[]} cells + * @returns {T[][]} + */ +export function groupCellsIntoRows(cells) { + const rows = []; + [...cells] + .sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left) + .forEach((cell) => { + const row = rows.find((candidate) => Math.abs(candidate[0].rect.top - cell.rect.top) <= ROW_TOLERANCE_PX); + if (row) { + row.push(cell); + } else { + rows.push([cell]); + } + }); + rows.forEach((row) => row.sort((a, b) => a.rect.left - b.rect.left)); + return rows; +} + +/** + * Splits a height budget over grid rows. Each row needs its fixed part + * (panel header, provider header) plus content; when everything does not + * fit, the content of the tallest rows is cut to a common cap. + * @param {{fixed: number, content: number}[]} rows - Largest cell of each row. + * @param {number} budget - Height available to all rows together. + * @returns {{caps: number[], truncated: boolean}} Content cap per row. + */ +export function allocateRowContentHeights(rows, budget) { + const fixed = rows.reduce((sum, row) => sum + row.fixed, 0); + const total = rows.reduce((sum, row) => sum + row.fixed + row.content, 0); + if (total <= budget) { + return { caps: rows.map((row) => row.content), truncated: false }; + } + let available = Math.max(0, budget - fixed); + // Water-fill: short rows keep everything, the rest share what is left. + const order = rows.map((row, index) => ({ index, content: row.content })) + .sort((a, b) => a.content - b.content); + const caps = new Array(rows.length).fill(0); + let remaining = order.length; + for (const { index, content } of order) { + const share = Math.floor(available / remaining); + caps[index] = Math.min(content, share); + available -= caps[index]; + remaining -= 1; + } + return { caps, truncated: true }; +} + +/** + * Places cells as on screen: same columns, each row as tall as its tallest + * cell. + * @param {object} options + * @param {{rect: {left: number, top: number, width: number}, height: number}[][]} options.rows + * @param {number} options.gridLeft - Left edge of the visible grid. + * @param {number} options.gridWidth + * @param {number} [options.gap] + * @param {number} [options.padding] + * @returns {{width: number, height: number, rows: {y: number, height: number, + * cells: {x: number, y: number, width: number, height: number}[]}[]}} + */ +export function layoutStitchedGrid({ + rows, + gridLeft, + gridWidth, + gap = STITCH_GRID_GAP, + padding = STITCH_GRID_PADDING +}) { + let y = padding; + const placed = rows.map((row, rowIndex) => { + const height = Math.max(...row.map((cell) => cell.height)); + const rowY = y + (rowIndex > 0 ? gap : 0); + y = rowY + height; + return { + y: rowY, + height, + cells: row.map((cell) => ({ + x: Math.round(cell.rect.left - gridLeft), + y: rowY, + width: Math.round(cell.rect.width), + height + })) + }; + }); + return { width: Math.round(gridWidth), height: y + padding, rows: placed }; +} + +/** + * Most common color among pixels, to fill below shorter answers. + * @param {Uint8ClampedArray | number[]} data - RGBA pixels. + * @returns {string} CSS rgb() color. + */ +export function getDominantColor(data) { + const counts = new Map(); + let best = null; + let bestCount = 0; + for (let index = 0; index + 3 < data.length; index += 4) { + // Quantize so antialiasing noise does not split one background. + const key = ((data[index] >> 3) << 10) | ((data[index + 1] >> 3) << 5) | (data[index + 2] >> 3); + const entry = counts.get(key) || { count: 0, r: data[index], g: data[index + 1], b: data[index + 2] }; + entry.count += 1; + counts.set(key, entry); + if (entry.count > bestCount) { + best = entry; + bestCount = entry.count; + } + } + return best ? `rgb(${best.r}, ${best.g}, ${best.b})` : 'rgb(255, 255, 255)'; +} + +/** + * Largest output scale that keeps a canvas within Chrome's limits. + * @param {number} width - CSS pixels. + * @param {number} height - CSS pixels. + * @param {number} scale - Wanted output pixels per CSS pixel. + * @param {{maxDimension: number, maxArea: number}} limits + */ +export function fitCanvasScale(width, height, scale, { maxDimension, maxArea }) { + const byDimension = Math.min(maxDimension / width, maxDimension / height); + const byArea = Math.sqrt(maxArea / (width * height)); + return Math.min(scale, byDimension, byArea); +} diff --git a/modules/tab-capture.js b/modules/tab-capture.js new file mode 100644 index 0000000..5886356 --- /dev/null +++ b/modules/tab-capture.js @@ -0,0 +1,515 @@ +// Captures the pixels of the current tab, cross-origin provider iframes +// included. chrome.tabs.captureVisibleTab is silent but needs the activeTab +// grant, which Chrome gives when the user invokes the extension on this page +// (toolbar icon, its shortcut or the context menu). Without that grant the +// Screen Capture API is used instead, and Chrome asks the user to confirm +// sharing this tab. That stream is kept for later snapshots, paused between +// them, until the user stops sharing or the page goes away. Frames never +// leave the page. + +import { getCaptureCrop } from './share-snapshot.js'; + +export const TAB_CAPTURE_ERRORS = Object.freeze({ + CANCELLED: 'capture-cancelled', + ENDED: 'capture-ended', + UNSUPPORTED: 'capture-unsupported', + WRONG_SURFACE: 'capture-wrong-surface', + PERMISSION: 'capture-permission', + NO_FRAME: 'capture-no-frame', + TAB_HIDDEN: 'capture-tab-hidden' +}); + +export const TAB_CAPTURE_KINDS = Object.freeze({ + VISIBLE_TAB: 'visible-tab', + DISPLAY_MEDIA: 'display-media' +}); + +/** chrome.tabs.MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND. */ +export const MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND = 2; + +/** Spacing between captureVisibleTab calls, with a margin over the quota. */ +export const VISIBLE_TAB_CAPTURE_INTERVAL_MS = Math.ceil(1000 / MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND) + 50; + +const VISIBLE_TAB_QUOTA_RETRIES = 3; + +export class TabCaptureError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'TabCaptureError'; + this.code = code; + } +} + +/** Frames to wait after the stream starts, so the "sharing this tab" bar settles. */ +const SETTLE_FRAMES = 2; + +/** Upper bound for one frame callback; capture streams can idle on static pages. */ +const FRAME_WAIT_TIMEOUT_MS = 500; + +/** Tries to get a frame taken after a grab() was asked for. */ +const FRESH_FRAME_ATTEMPTS = 4; + +/** Frame rate of a kept stream between snapshots, to keep it nearly free. */ +export const IDLE_CAPTURE_FRAME_RATE = 1; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** True when captureVisibleTab failed because this tab has no activeTab grant. */ +export function isCapturePermissionError(error) { + const message = String(error?.message || error || ''); + return /'' or 'activeTab' permission/i.test(message) + || /permission is required/i.test(message) + || /cannot access contents of/i.test(message); +} + +/** True when captureVisibleTab was called more often than Chrome allows. */ +export function isCaptureQuotaError(error) { + return /MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND/.test(String(error?.message || error || '')); +} + +/** + * Spaces out calls: wait() resolves once `intervalMs` has passed since the + * previous call started. + * @param {number} intervalMs + * @param {object} [options] + * @param {() => number} [options.now] + * @param {(ms: number) => Promise} [options.wait] + */ +export function createCallThrottle(intervalMs, { now = () => Date.now(), wait = sleep } = {}) { + let last = -Infinity; + return { + async wait() { + const delay = last + intervalMs - now(); + if (delay > 0) { + await wait(delay); + } + last = now(); + }, + /** Pushes the next call out, e.g. after Chrome reported the quota. */ + defer() { + last = now(); + } + }; +} + +// Shared by every capture in this page: the quota is per extension. +const visibleTabThrottle = createCallThrottle(VISIBLE_TAB_CAPTURE_INTERVAL_MS); + +/** + * Decodes a data: URL without fetch, which extension pages may restrict. + * @param {string} dataUrl + * @returns {Blob} + */ +export function dataUrlToBlob(dataUrl) { + const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(String(dataUrl)); + if (!match) { + throw new Error('Capture did not return an image'); + } + const [, type = 'application/octet-stream', base64, data] = match; + if (!base64) { + return new Blob([decodeURIComponent(data)], { type }); + } + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new Blob([bytes], { type }); +} + +/** + * Silent capture through chrome.tabs.captureVisibleTab. That takes whatever + * tab is in front of the window, so each frame is only kept when this tab + * was in front from before it was taken until after: switching tabs midway + * would put another tab's pixels under this comparison's title. + * @param {object} options + * @param {number} [options.windowId] - Window showing this page, when tabId is unknown. + * @param {number | null} [options.tabId] - This page's tab. + * @param {typeof chrome.tabs} [options.tabs] + * @param {ReturnType} [options.throttle] + * @param {(blob: Blob) => Promise} [options.decode] + * @param {Document} [options.doc] - This page, which is hidden while another tab is in front. + */ +export function createVisibleTabSource({ + windowId, + tabId = null, + tabs = globalThis.chrome?.tabs, + throttle = visibleTabThrottle, + decode = (blob) => createImageBitmap(blob), + doc = globalThis.document +} = {}) { + if (typeof tabs?.captureVisibleTab !== 'function') { + throw new TabCaptureError(TAB_CAPTURE_ERRORS.UNSUPPORTED, 'Tab capture is not available'); + } + // Counts the times the page was hidden, so a switch away and back is seen too. + let hidings = 0; + const onVisibilityChange = () => { + if (doc.visibilityState === 'hidden') { + hidings += 1; + } + }; + doc?.addEventListener?.('visibilitychange', onVisibilityChange); + const tabHidden = () => new TabCaptureError(TAB_CAPTURE_ERRORS.TAB_HIDDEN, 'This tab is not in front'); + /** Window to capture: the one this tab is in front of. */ + const findShowingWindow = async () => { + if (doc?.visibilityState === 'hidden') { + throw tabHidden(); + } + if (tabId === null || typeof tabs.get !== 'function') { + return windowId ?? null; + } + const tab = await tabs.get(tabId); + if (!tab?.active) { + throw tabHidden(); + } + return tab.windowId; + }; + return { + kind: TAB_CAPTURE_KINDS.VISIBLE_TAB, + async grab() { + for (let attempt = 0; ; attempt += 1) { + await throttle.wait(); + try { + const hidingsBefore = hidings; + const showingWindow = await findShowingWindow(); + const dataUrl = await tabs.captureVisibleTab(showingWindow, { format: 'png' }); + if (hidings !== hidingsBefore || await findShowingWindow() !== showingWindow) { + throw tabHidden(); + } + return await decode(dataUrlToBlob(dataUrl)); + } catch (error) { + if (error instanceof TabCaptureError) { + throw error; + } + if (isCapturePermissionError(error)) { + throw new TabCaptureError(TAB_CAPTURE_ERRORS.PERMISSION, 'This tab has no capture grant', { cause: error }); + } + if (!isCaptureQuotaError(error) || attempt >= VISIBLE_TAB_QUOTA_RETRIES) { + throw error; + } + throttle.defer(); + } + } + }, + close() { + doc?.removeEventListener?.('visibilitychange', onVisibilityChange); + } + }; +} + +function waitForVideoFrame(video, signal) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, FRAME_WAIT_TIMEOUT_MS); + video.requestVideoFrameCallback(() => { + clearTimeout(timer); + resolve(); + }); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); +} + +/** + * Resolves with the capture time of the first frame taken at or after + * `since`, or null when none comes in time. Frames already on their way can + * be older than the page now is, so they are skipped. Without capture times + * the next frame is taken as it is. + */ +function waitForVideoFrameSince(video, since) { + return new Promise((resolve) => { + let settled = false; + const finish = (value) => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(value); + } + }; + const timer = setTimeout(() => finish(null), FRAME_WAIT_TIMEOUT_MS); + const onFrame = (now, metadata) => { + const capturedAt = metadata?.captureTime ?? now; + if (typeof metadata?.captureTime !== 'number' || capturedAt >= since) { + finish(capturedAt); + } else if (!settled) { + video.requestVideoFrameCallback(onFrame); + } + }; + video.requestVideoFrameCallback(onFrame); + }); +} + +/** + * A pixel in the corner of the page, nearly transparent, whose shade flips + * on every grab. Tab capture sends a frame only when the page changes, so a + * page that already painted its latest scroll would send nothing new and an + * older frame could be taken instead; the flip makes Chrome send one. + */ +function createFrameTrigger() { + const element = document.createElement('div'); + element.setAttribute('aria-hidden', 'true'); + element.style.cssText = [ + 'position: fixed', 'left: 0', 'bottom: 0', 'width: 1px', 'height: 1px', + 'pointer-events: none', 'z-index: 2147483647' + ].join(';'); + let shade = false; + return { + pull() { + if (!element.isConnected) { + document.documentElement.appendChild(element); + } + shade = !shade; + element.style.backgroundColor = shade ? 'rgba(0, 0, 0, 0.02)' : 'rgba(0, 0, 0, 0.01)'; + }, + remove() { + element.remove(); + } + }; +} + +function createCaptureHandle() { + return `panelize-snapshot-${crypto.randomUUID()}`; +} + +/** + * Confirms the stream shows this tab. Chrome's "share this tab" dialog can + * still switch to another surface; cropping that by our layout would produce + * a wrong image. + */ +function assertCapturedThisTab(track, handle) { + const settings = track.getSettings?.() || {}; + if (settings.displaySurface && settings.displaySurface !== 'browser') { + throw new TabCaptureError(TAB_CAPTURE_ERRORS.WRONG_SURFACE, 'Another screen surface was shared'); + } + if (handle && typeof track.getCaptureHandle === 'function') { + const captured = track.getCaptureHandle(); + if (captured?.handle !== handle) { + throw new TabCaptureError(TAB_CAPTURE_ERRORS.WRONG_SURFACE, 'Another tab was shared'); + } + } +} + +/** + * At most the tab's size in device pixels. A larger frame makes Chrome raise + * the device scale of the captured page to fill it, and reset and re-apply + * that scale whenever the constraints change: the panels then lay out at + * another size for a moment, which moves virtual lists such as DeepSeek's. + */ +function getTabCaptureSize() { + const scale = window.devicePixelRatio || 1; + return { + width: { max: Math.round(window.innerWidth * scale) }, + height: { max: Math.round(window.innerHeight * scale) } + }; +} + +/** + * Sets the frame rate, with the tab's current size: constraints are + * replaced as a whole, and the tab may have been resized meanwhile. + */ +function setCaptureFrameRate(track, frameRate) { + if (!frameRate || typeof track.applyConstraints !== 'function') { + return Promise.resolve(); + } + return track.applyConstraints({ ...getTabCaptureSize(), frameRate }).catch((error) => { + console.warn('[TabCapture] Could not change the capture frame rate:', error); + }); +} + +/** + * Capture through the Screen Capture API. The stream outlives a run, so + * later snapshots do not ask again: start() before a run plays it at full + * rate, stop() after it pauses it at IDLE_CAPTURE_FRAME_RATE, close() ends + * it. Sharing can also end from Chrome's own "Stop sharing" bar. + * @param {object} [options] + * @param {() => void} [options.onEnded] - Sharing ended, whoever stopped it. + * @returns {Promise<{kind: string, isActive: () => boolean, start: () => Promise, + * grab: () => Promise, stop: () => Promise, close: () => void}>} + */ +export async function createDisplayMediaSource({ onEnded = () => {} } = {}) { + const mediaDevices = navigator.mediaDevices; + if (typeof mediaDevices?.getDisplayMedia !== 'function') { + throw new TabCaptureError(TAB_CAPTURE_ERRORS.UNSUPPORTED, 'Screen capture is not available'); + } + + // A capture handle lets the stream prove it shows this very tab. + let handle = null; + if (typeof mediaDevices.setCaptureHandleConfig === 'function') { + handle = createCaptureHandle(); + mediaDevices.setCaptureHandleConfig({ + handle, + exposeOrigin: false, + permittedOrigins: [window.location.origin] + }); + } + + let stream; + try { + stream = await mediaDevices.getDisplayMedia({ + // The pointer would be drawn over the panels. Chrome ignores this for + // tabs ("motion"), so the page also hides it while capturing. + video: { displaySurface: 'browser', cursor: 'never', ...getTabCaptureSize() }, + audio: false, + preferCurrentTab: true, + selfBrowserSurface: 'include', + surfaceSwitching: 'exclude', + monitorTypeSurfaces: 'exclude' + }); + } catch (error) { + if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') { + throw new TabCaptureError(TAB_CAPTURE_ERRORS.CANCELLED, 'Screen capture was cancelled', { cause: error }); + } + throw error; + } + + const [track] = stream.getVideoTracks(); + const video = document.createElement('video'); + const frameTrigger = createFrameTrigger(); + const activeFrameRate = track.getSettings?.().frameRate; + let ended = false; + const end = () => { + if (ended) { + return; + } + ended = true; + stream.getTracks().forEach((streamTrack) => streamTrack.stop()); + video.pause(); + video.srcObject = null; + frameTrigger.remove(); + onEnded(); + }; + // Chrome's "Stop sharing" ends the track without calling close(). + track.addEventListener('ended', end, { once: true }); + const isActive = () => !ended && track.readyState === 'live'; + const assertActive = () => { + if (!isActive()) { + throw new TabCaptureError(TAB_CAPTURE_ERRORS.ENDED, 'Tab sharing has ended'); + } + // Checked on every use: the shared surface must still be this tab. + assertCapturedThisTab(track, handle); + }; + const waitForFreshFrames = async () => { + for (let frame = 0; frame < SETTLE_FRAMES; frame += 1) { + await waitForVideoFrame(video); + } + }; + + try { + assertCapturedThisTab(track, handle); + video.muted = true; + video.playsInline = true; + video.srcObject = stream; + await video.play(); + await waitForFreshFrames(); + } catch (error) { + end(); + throw error; + } + + return { + kind: TAB_CAPTURE_KINDS.DISPLAY_MEDIA, + isActive, + async start() { + assertActive(); + await setCaptureFrameRate(track, activeFrameRate); + await video.play(); + await waitForFreshFrames(); + }, + async grab() { + assertActive(); + // Only a frame taken after this call shows what the page has painted + // by now; the trigger makes Chrome take one. The bitmap takes the + // video's current size, so a resized tab is measured again each time. + for (let attempt = 0; attempt < FRESH_FRAME_ATTEMPTS; attempt += 1) { + const since = performance.now(); + frameTrigger.pull(); + if (await waitForVideoFrameSince(video, since) !== null) { + assertActive(); + return createImageBitmap(video); + } + } + throw new TabCaptureError(TAB_CAPTURE_ERRORS.NO_FRAME, 'Tab sharing sent no new frame'); + }, + async stop() { + if (!isActive()) { + return; + } + video.pause(); + await setCaptureFrameRate(track, IDLE_CAPTURE_FRAME_RATE); + }, + close: end + }; +} + +/** + * Opens a capture source for this tab, silent when Chrome allows it. + * Call it straight from the user's click: the fallback needs the click's + * user activation to show Chrome's share dialog. + * @param {object} [options] + * @param {number} [options.windowId] + * @param {number | null} [options.tabId] - This page's tab, which silent capture is bound to. + * @param {boolean} [options.preferVisibleTab] + * @param {object | null} [options.keptSource] - A display media source from + * an earlier snapshot, used instead of asking again while it is active. + * @param {() => void} [options.onEnded] - For a new display media source. + * @returns {Promise<{source: object, fallbackReason: string | null, reused: boolean}>} + * fallbackReason is TAB_CAPTURE_ERRORS.PERMISSION when this tab has no + * activeTab grant, or UNSUPPORTED without the tabs API. Rejects with + * TAB_HIDDEN when another tab is in front. + */ +export async function openTabCaptureSource({ + windowId, + tabId = null, + preferVisibleTab = true, + keptSource = null, + onEnded +} = {}) { + let fallbackReason = null; + if (preferVisibleTab) { + let source = null; + try { + source = createVisibleTabSource({ windowId, tabId }); + // Probe once: the grant can only be learned by trying. + const probe = await source.grab(); + probe.close?.(); + return { source, fallbackReason, reused: false }; + } catch (error) { + source?.close(); + // Sharing would not help while another tab is in front. + if (!(error instanceof TabCaptureError) || error.code === TAB_CAPTURE_ERRORS.TAB_HIDDEN) { + throw error; + } + fallbackReason = error.code; + } + } + if (keptSource?.isActive()) { + return { source: keptSource, fallbackReason, reused: true }; + } + return { source: await createDisplayMediaSource({ onEnded }), fallbackReason, reused: false }; +} + +/** + * Crops a captured frame to `rect` of the viewport. + * @param {ImageBitmap} frame - Whole-tab frame. + * @param {{left: number, top: number, width: number, height: number}} rect - CSS pixels. + * @param {object} [options] + * @param {number} [options.maxWidth] - Output width cap in pixels. + * @returns {Promise<{bitmap: ImageBitmap, scale: number}>} + */ +export async function cropCapturedFrame(frame, rect, { maxWidth } = {}) { + const crop = getCaptureCrop({ + frameWidth: frame.width, + frameHeight: frame.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + rect, + maxWidth + }); + const bitmap = await createImageBitmap(frame, crop.sx, crop.sy, crop.sw, crop.sh, { + resizeWidth: crop.width, + resizeHeight: crop.height, + resizeQuality: 'high' + }); + return { bitmap, scale: crop.scale }; +} diff --git a/multi-panel/multi-panel.css b/multi-panel/multi-panel.css index 8ea458e..11797a9 100644 --- a/multi-panel/multi-panel.css +++ b/multi-panel/multi-panel.css @@ -158,11 +158,25 @@ body { white-space: nowrap; } -#toggle-open-mode-btn .btn-text { +#toggle-open-mode-btn .btn-text, +#share-snapshot-btn .btn-text { font-size: 12px; font-weight: 500; } +/* Share Snapshot Button */ +#share-snapshot-btn { + gap: 6px; + width: auto; + padding: 0 12px; + white-space: nowrap; +} + +#share-snapshot-btn:disabled { + opacity: 0.5; + cursor: progress; +} + /* Toolbar Collapsed State */ #toolbar.collapsed { display: none; @@ -287,6 +301,7 @@ body { scroll-snap-type: x mandatory; scroll-behavior: smooth; scrollbar-width: thin; + position: relative; } /* Layout variations */ @@ -298,64 +313,75 @@ body { /* 1x2 最小宽度 */ .layout-1x2 { - grid-template-columns: repeat(2, minmax(400px, 1fr)); + grid-template-columns: repeat(2, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 400px; grid-template-rows: 1fr; } /* 1x3 最小宽度 */ .layout-1x3 { - grid-template-columns: repeat(3, minmax(400px, 1fr)); + grid-template-columns: repeat(3, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 400px; grid-template-rows: 1fr; } /* 确保 1x4 在窄屏幕也有最小宽度 */ .layout-1x4 { - grid-template-columns: repeat(4, minmax(400px, 1fr)); + grid-template-columns: repeat(4, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 400px; grid-template-rows: 1fr; } /* 1x5 五列 - 最小宽度 400px */ .layout-1x5 { - grid-template-columns: repeat(5, minmax(400px, 1fr)); + grid-template-columns: repeat(5, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 400px; grid-template-rows: 1fr; } /* 1x6 六列 - 最小宽度 400px */ .layout-1x6 { - grid-template-columns: repeat(6, minmax(400px, 1fr)); + grid-template-columns: repeat(6, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 400px; grid-template-rows: 1fr; } /* 1x7 七列 - 最小宽度 350px */ .layout-1x7 { - grid-template-columns: repeat(7, minmax(350px, 1fr)); + grid-template-columns: repeat(7, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 350px; grid-template-rows: 1fr; } /* 1x8 八列 - 最小宽度 320px */ .layout-1x8 { - grid-template-columns: repeat(8, minmax(320px, 1fr)); + grid-template-columns: repeat(8, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 320px; grid-template-rows: 1fr; } /* Keep high-density row layouts horizontally scrollable and usable. */ .layout-1x9 { - grid-template-columns: repeat(9, minmax(320px, 1fr)); + grid-template-columns: repeat(9, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 320px; grid-template-rows: 1fr; } .layout-1x10 { - grid-template-columns: repeat(10, minmax(320px, 1fr)); + grid-template-columns: repeat(10, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 320px; grid-template-rows: 1fr; } .layout-1x11 { - grid-template-columns: repeat(11, minmax(320px, 1fr)); + grid-template-columns: repeat(11, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 320px; grid-template-rows: 1fr; } .layout-1x12 { - grid-template-columns: repeat(12, minmax(320px, 1fr)); + grid-template-columns: repeat(12, minmax(var(--panel-min-width), 1fr)); + --panel-min-width: 320px; grid-template-rows: 1fr; } @@ -425,6 +451,61 @@ body { scroll-snap-align: start; } +/* Column resize handles (modules/panel-resize.js), centered on the gaps. */ +.panel-resize-handles { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 0; + z-index: 2; +} + +.panel-resize-handle { + position: absolute; + top: 0; + bottom: 0; + width: 10px; + cursor: col-resize; + touch-action: none; +} + +.panel-resize-handle::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 3px; + width: 4px; + border-radius: 2px; + background: transparent; + transition: background-color 0.15s; +} + +.panel-resize-handle:hover::after, +.panel-resize-handle:focus-visible::after, +.panel-resize-handle.active::after { + background: #1976d2; +} + +.panel-resize-handle:focus-visible { + outline: none; +} + +/* Frames would swallow the pointer while a column is dragged. */ +.panel-resizing { + cursor: col-resize; + user-select: none; +} + +.panel-resizing iframe { + pointer-events: none; +} + +.panel-resizing #panel-grid { + scroll-snap-type: none; +} + /* Panel Item */ .panel-item { display: flex; @@ -565,6 +646,18 @@ body { border: none; } +/* A page wider than its panel is laid out at the width it needs and scaled + down to fit (modules/panel-fit.js). */ +.panel-iframe-container iframe.panel-fitted { + position: absolute; + top: 0; + left: 0; + width: calc(100% / var(--panel-fit-scale)); + height: calc(100% / var(--panel-fit-scale)); + transform: scale(var(--panel-fit-scale)); + transform-origin: 0 0; +} + .panel-loading { position: absolute; top: 0; @@ -620,8 +713,97 @@ body { display: none; } +/* ===== Store Rating Banner ===== */ +.rating-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 16px; + background: #e3f2fd; + border-top: 1px solid #bbdefb; + color: #333; + font-size: 13px; + flex-shrink: 0; +} + +.rating-banner[hidden] { + display: none; +} + +.rating-banner-icon { + color: #1976d2; + font-size: 18px; +} + +.rating-banner-text { + flex: 1; + min-width: 0; +} + +.rating-banner-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.rating-banner-btn { + padding: 4px 12px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: #1976d2; + font-size: 13px; + cursor: pointer; + transition: background 0.2s; +} + +.rating-banner-btn:hover { + background: rgba(25, 118, 210, 0.08); +} + +.rating-banner-btn.primary { + background: #1976d2; + color: #fff; +} + +.rating-banner-btn.primary:hover { + background: #1565c0; +} + +[data-theme="dark"] .rating-banner { + background: #1a3a5a; + border-top-color: #444; + color: #e0e0e0; +} + +[data-theme="dark"] .rating-banner-icon, +[data-theme="dark"] .rating-banner-btn { + color: #64b5f6; +} + +[data-theme="dark"] .rating-banner-btn:hover { + background: rgba(100, 181, 246, 0.12); +} + +[data-theme="dark"] .rating-banner-btn.primary { + background: #64b5f6; + color: #1e1e1e; +} + +@media (max-width: 640px) { + .rating-banner { + flex-wrap: wrap; + } + + .rating-banner-actions { + width: 100%; + justify-content: flex-end; + } +} + /* ===== Input Bar ===== */ #input-bar { + position: relative; background: #fff; border-top: 1px solid #e0e0e0; padding: 12px 16px; @@ -1657,6 +1839,346 @@ body { background: #f5f5f5; } +/* ===== Share Snapshot Modal ===== */ +.share-modal-content { + max-height: 90vh; +} + +.share-modal-body { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.share-privacy-warning { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + padding: 10px 12px; + border: 1px solid #ffe0b2; + border-radius: 8px; + background: #fff8e1; + color: #5d4037; + font-size: 13px; + line-height: 1.45; +} + +.share-privacy-warning .material-symbols-outlined { + font-size: 18px; + color: #ef6c00; +} + +.share-preview { + display: block; + max-width: 100%; + /* Keep the whole image visible next to the header, warning and buttons. */ + max-height: calc(90vh - 280px); + min-height: 120px; + margin: 0 auto; + object-fit: contain; + border: 1px solid #e0e0e0; + border-radius: 6px; +} + +.share-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.share-action-btn .material-symbols-outlined { + font-size: 18px; +} + +[data-theme="dark"] .share-privacy-warning { + border-color: #5d4037; + background: #3e2f1c; + color: #ffe0b2; +} + +[data-theme="dark"] .share-privacy-warning .material-symbols-outlined { + color: #ffb74d; +} + +[data-theme="dark"] .share-preview { + border-color: #444; +} + +/* Mode switch and title */ +.share-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.share-mode-switch { + display: inline-flex; + flex-shrink: 0; + padding: 3px; + border-radius: 8px; + background: #eceff1; +} + +.share-mode-btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: transparent; + color: #546e7a; + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} + +.share-mode-btn[aria-checked="true"] { + background: #fff; + color: #1a1a1a; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} + +.share-mode-btn:disabled { + cursor: progress; + opacity: 0.6; +} + +.share-title-field { + display: flex; + flex: 1 1 240px; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 13px; + color: #546e7a; +} + +.share-title-field input { + flex: 1; + min-width: 0; + padding: 7px 10px; + border: 1px solid #cfd8dc; + border-radius: 6px; + background: #fff; + color: #1a1a1a; + font: inherit; + font-size: 14px; +} + +.share-title-field input:focus { + outline: 2px solid #1976d2; + outline-offset: -1px; +} + +.share-hint, +.share-notes { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + font-size: 13px; + line-height: 1.45; + color: #455a64; +} + +/* One note per line. */ +.share-notes { + white-space: pre-line; +} + +.share-hint[hidden], +.share-notes[hidden] { + display: none; +} + +.share-hint { + padding: 8px 12px; + border-radius: 8px; + background: #e3f2fd; + color: #0d47a1; +} + +.share-hint .material-symbols-outlined { + font-size: 18px; +} + +.share-stream-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + line-height: 1.45; + color: #455a64; +} + +.share-stream-status[hidden] { + display: none; +} + +.share-stream-status .material-symbols-outlined { + font-size: 18px; + color: #c62828; +} + +.share-stream-stop { + padding: 0; + border: none; + background: none; + color: #1565c0; + font: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} + +.share-stream-stop:focus-visible { + outline: 2px solid #1565c0; + outline-offset: 2px; + border-radius: 2px; +} + +/* Capture progress, laid over the input bar so the grid does not move. */ +.snapshot-progress { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 0 16px; + background: inherit; + z-index: 5; +} + +.snapshot-progress[hidden] { + display: none; +} + +.snapshot-progress-icon { + color: #1976d2; +} + +.snapshot-progress-text { + font-size: 14px; + color: #37474f; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.snapshot-progress-bar { + flex: 0 1 240px; + min-width: 60px; + height: 6px; +} + +.snapshot-cancel-btn { + padding: 6px 14px; +} + +/* Keep the share dialog and toasts out of the captured frames. */ +body.snapshot-capturing #share-modal, +body.snapshot-capturing .panelize-toast { + visibility: hidden; +} + +/* Tab sharing can draw the pointer into the frames. */ +html.snapshot-hide-cursor, +html.snapshot-hide-cursor *, +html.snapshot-hide-cursor *::before, +html.snapshot-hide-cursor *::after { + cursor: none !important; +} + +/* Laid over the panels while tab sharing captures them; see pointer-guard.js. */ +.snapshot-pointer-shield { + position: fixed; + z-index: 1000; + background: transparent; + cursor: none; +} + +[data-theme="dark"] .share-mode-switch { + background: #2d2d2d; +} + +[data-theme="dark"] .share-mode-btn { + color: #b0bec5; +} + +[data-theme="dark"] .share-mode-btn[aria-checked="true"] { + background: #424242; + color: #fff; +} + +[data-theme="dark"] .share-title-field { + color: #b0bec5; +} + +[data-theme="dark"] .share-title-field input { + border-color: #555; + background: #1e1e1e; + color: #e0e0e0; +} + +[data-theme="dark"] .share-hint { + background: #0d2a45; + color: #bbdefb; +} + +[data-theme="dark"] .share-stream-status { + color: #cfd8dc; +} + +[data-theme="dark"] .share-stream-stop { + color: #90caf9; +} + +[data-theme="dark"] .share-stream-stop:focus-visible { + outline-color: #90caf9; +} + +[data-theme="dark"] .share-notes, +[data-theme="dark"] .snapshot-progress-text { + color: #cfd8dc; +} + +@media (max-width: 640px) { + .share-controls { + flex-direction: column; + align-items: stretch; + } + + .share-mode-switch { + display: flex; + } + + .share-mode-btn { + flex: 1; + } + + .share-title-field { + flex-basis: auto; + } + + .snapshot-progress-bar { + display: none; + } + + #share-snapshot-btn { + width: 36px; + padding: 0; + } + + #share-snapshot-btn .btn-text { + display: none; + } +} + /* Dark theme for new elements */ [data-theme="dark"] .prompt-filter-buttons select { background: #2d2d2d; diff --git a/multi-panel/multi-panel.html b/multi-panel/multi-panel.html index 4ec2796..651b2d4 100644 --- a/multi-panel/multi-panel.html +++ b/multi-panel/multi-panel.html @@ -61,6 +61,10 @@ + @@ -85,6 +89,17 @@ + + +
@@ -117,6 +132,12 @@
+
@@ -361,6 +382,56 @@

Fill in Variables

+ + + diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index 64c0267..939ad2b 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -20,14 +20,47 @@ import { isProviderAllowedUrl, isProviderCurrentUrl } from '../modules/provider-open-url.js'; -import { saveSetting } from '../modules/settings.js'; +import { getSetting, saveSetting } from '../modules/settings.js'; import { LAYOUT_PANEL_COUNTS, getAutoAdjustedLayout, getAutoShrunkLayout } from '../modules/layout-auto-adjust.js'; import { applyTheme } from '../modules/theme-manager.js'; -import { t, initializeLanguage } from '../modules/i18n.js'; +import { t, initializeLanguage, translatePage, getTranslationLocale } from '../modules/i18n.js'; +import { + RATING_PROMPT_ACTIONS, + RATING_PROMPT_REVIEW_URL, + countSuccessfulSend, + loadRatingPromptState, + saveRatingPromptAction, + shouldShowRatingPrompt +} from '../modules/rating-prompt.js'; +import { + SNAPSHOT_MAX_PROMPT_LINES, + SNAPSHOT_MAX_SUBLINE_LINES, + composeSnapshot, + formatProviderList, + formatSnapshotSubline, + getSnapshotFileName, + getSnapshotFrameChromeHeight, + normalizePromptText, + toLanguageTag +} from '../modules/share-snapshot.js'; +import { + TAB_CAPTURE_ERRORS, + TAB_CAPTURE_KINDS, + TabCaptureError, + openTabCaptureSource +} from '../modules/tab-capture.js'; +import { + SNAPSHOT_MODES, + SnapshotCancelledError, + SnapshotLayoutChangedError, + captureCurrentView, + captureFullAnswers +} from '../modules/snapshot-capture.js'; +import { SHARE_SNAPSHOT_ACTION, getExecuteActionShortcut } from '../modules/share-trigger.js'; import { calculatePendingImageIds, cleanStalePanelRetryState, @@ -53,11 +86,30 @@ import { deletePrompt, getPrompt } from '../modules/prompt-manager.js'; +import { + applyPanelFitScale, + createPanelFitState, + getPanelFitScale, + updatePanelFitState +} from '../modules/panel-fit.js'; +import { setupPanelResize } from '../modules/panel-resize.js'; +import { createPointerGuard, createPointerTracker } from '../modules/pointer-guard.js'; // ===== State Management ===== let currentLayout = '1x3'; let panels = []; // Array of { id, providerId, iframe, state } +// Re-fits pages when their panel changes width (window, layout or drag). +const panelFitObserver = typeof ResizeObserver === 'function' + ? new ResizeObserver((entries) => { + entries.forEach(({ target }) => { + const panel = panels.find(candidate => candidate.iframe?.parentElement === target); + if (panel) { + updatePanelFit(panel); + } + }); + }) + : null; let uploadedImages = []; // Array of uploaded images { id, name, type, dataUrl } let failedFillPanelIds = new Set(); let pendingFillImageIdsByPanel = new Map(); @@ -153,6 +205,7 @@ const PANELIZE_PROVIDER_IDLE = 'PANELIZE_PROVIDER_IDLE'; const PANELIZE_PROVIDER_USER_INTERACTION = 'PANELIZE_PROVIDER_USER_INTERACTION'; const PANELIZE_TEMP_CHAT_ENABLED = 'PANELIZE_TEMP_CHAT_ENABLED'; const PANELIZE_PROVIDER_LOCATION = 'PANELIZE_PROVIDER_LOCATION'; +const PANELIZE_PAGE_WIDTH = 'PANELIZE_PAGE_WIDTH'; const TEMP_CHAT_RETRY_DELAYS = [1200, 2500, 4000]; const TEMP_CHAT_OPERATION_TIMEOUT_MS = 5000; const TEMP_CHAT_SUPPORTED_PROVIDERS = new Set(['chatgpt', 'gemini', 'claude', 'grok', 'yuanbao']); @@ -183,6 +236,7 @@ async function init() { document.addEventListener('panelize:themechange', refreshThemeAwareProviderIcons); await applyTheme(); await initializeLanguage(); + translatePage(); registerRuntimeMessageListener(); registerStorageChangeListener(); @@ -195,11 +249,20 @@ async function init() { // Load saved settings await loadSettings(); + await setupPanelResize({ + grid: document.getElementById('panel-grid'), + getLayout: () => currentLayout, + storage: chrome.storage.local, + label: t('panelResizeHandle') + }); + // Initialize panels await initializePanels(); // Setup event listeners setupEventListeners(); + setupRatingPrompt(); + setupShareSnapshot(); updateTemporaryChatButtonState(); focusUnifiedInput({ force: true }); @@ -225,6 +288,10 @@ function focusUnifiedInput({ force = false } = {}) { if (generation !== focusRestoreGeneration || (!force && !shouldPreserveUnifiedInputFocus())) { return; } + // The user moved on to a dialog, e.g. to edit a snapshot title. + if (isFocusInOpenModal()) { + return; + } try { inputTextarea.focus({ preventScroll: true }); } catch { @@ -233,6 +300,11 @@ function focusUnifiedInput({ force = false } = {}) { }); } +function isFocusInOpenModal() { + const modal = document.activeElement?.closest?.('.modal'); + return Boolean(modal) && modal.style.display !== 'none'; +} + function shouldPreserveUnifiedInputFocus() { return loadingPanelIds.size > 0 || isRestoringFocusAfterNewChat || isRestoringFocusAfterSend; } @@ -822,7 +894,9 @@ function handleProviderStatusMessage(event) { const isTempChatMessage = data.type === PANELIZE_TEMP_CHAT_ENABLED; const isLocationMessage = data.type === PANELIZE_PROVIDER_LOCATION; - if (data.context !== MULTI_PANEL_PROVIDER_STATUS_CONTEXT || (!data.requestId && !isTempChatMessage && !isLocationMessage)) { + const isPageWidthMessage = data.type === PANELIZE_PAGE_WIDTH; + if (data.context !== MULTI_PANEL_PROVIDER_STATUS_CONTEXT + || (!data.requestId && !isTempChatMessage && !isLocationMessage && !isPageWidthMessage)) { return; } @@ -838,6 +912,13 @@ function handleProviderStatusMessage(event) { } panel.currentUrl = isProviderCurrentUrl(panel.providerId, data.url) ? data.url : null; break; + case PANELIZE_PAGE_WIDTH: + if (!Number.isFinite(data.neededWidth) || data.neededWidth < 0) { + return; + } + panel.pageNeededWidth = data.neededWidth; + updatePanelFit(panel); + break; case PANELIZE_PROVIDER_BUSY: if (!isChatgptProvider(panel.providerId)) { return; @@ -958,6 +1039,11 @@ function registerRuntimeMessageListener() { return; } + if (message.action === SHARE_SNAPSHOT_ACTION) { + handleShareSnapshotRequest(message.payload || {}); + return; + } + handleMultiPanelAction(message.action, message.payload || {}).then((handled) => { if (handled) { clearPendingMultiPanelAction(); @@ -1270,13 +1356,17 @@ async function addPanel(providerId) { }); // Add to panels array - panels.push({ + const panel = { id: panelId, providerId, iframe, currentUrl: null, - state: 'loading' - }); + state: 'loading', + pageNeededWidth: 0, + fit: createPanelFitState() + }; + panels.push(panel); + panelFitObserver?.observe(iframe.parentElement); resetFillRetryState(); bindPanelHeaderActions(panelId); @@ -1292,6 +1382,11 @@ function removePanel(panelId) { const panelIndex = panels.findIndex(p => p.id === panelId); if (panelIndex === -1) return; + const fitContainer = panels[panelIndex].iframe?.parentElement; + if (fitContainer) { + panelFitObserver?.unobserve(fitContainer); + } + // Remove from DOM const panelEl = document.getElementById(panelId); if (panelEl) { @@ -1363,6 +1458,8 @@ async function switchPanelProvider(panelId, newProviderId) { panel.providerId = newProviderId; panel.iframe = iframe; panel.currentUrl = null; + panel.pageNeededWidth = 0; + updatePanelFit(panel); bindPanelHeaderActions(panelId); reloadPanelIframe(panel); @@ -1626,6 +1723,10 @@ export async function broadcastMessage(text, autoSubmit = true) { console.warn('[Multi-Panel] Image fill failed for panels:', failedPanels); } + if (successfulCount > 0) { + rememberSnapshotPrompt(text); + } + if (shouldClearFillPayload(summary)) { document.getElementById('unified-input').value = ''; resizeTextarea(); @@ -1667,6 +1768,14 @@ export async function broadcastMessage(text, autoSubmit = true) { statusEl.className = 'send-status'; }, 3000); + if (shouldAutoSubmit && totalSuccessful > 0) { + recordSendForRatingPrompt(); + } + + if (totalSuccessful > 0) { + rememberSnapshotPrompt(text); + } + // Clear input and save history if (totalSuccessful > 0) { document.getElementById('unified-input').value = ''; @@ -1798,6 +1907,718 @@ async function clearAllInputs() { showToast('All inputs cleared'); } +// ===== Store Rating Prompt ===== + +function setRatingBannerVisible(visible) { + const banner = document.getElementById('rating-banner'); + if (banner) { + banner.hidden = !visible; + } +} + +async function refreshRatingBanner(state) { + try { + setRatingBannerVisible(shouldShowRatingPrompt(state || await loadRatingPromptState())); + } catch (error) { + console.warn('[Multi-Panel] Failed to load rating prompt state:', error); + } +} + +// Counting is best effort: a storage failure must never affect sending. +function recordSendForRatingPrompt() { + countSuccessfulSend() + .then(refreshRatingBanner) + .catch(error => console.warn('[Multi-Panel] Failed to count send for rating prompt:', error)); +} + +async function answerRatingPrompt(action) { + setRatingBannerVisible(false); + try { + if (action === RATING_PROMPT_ACTIONS.RATE) { + await chrome.tabs.create({ url: RATING_PROMPT_REVIEW_URL }); + } + await saveRatingPromptAction(action); + } catch (error) { + console.warn('[Multi-Panel] Failed to save rating prompt answer:', error); + } +} + +function setupRatingPrompt() { + const buttons = { + 'rating-rate-btn': RATING_PROMPT_ACTIONS.RATE, + 'rating-later-btn': RATING_PROMPT_ACTIONS.LATER, + 'rating-never-btn': RATING_PROMPT_ACTIONS.NEVER + }; + Object.entries(buttons).forEach(([id, action]) => { + document.getElementById(id)?.addEventListener('click', () => answerRatingPrompt(action)); + }); + refreshRatingBanner(); +} + +// ===== Share Snapshot ===== + +// Prompt shown above the shared panels: the last one Panelize sent or filled. +let lastSnapshotPrompt = ''; +let currentSnapshot = null; +let isCapturingSnapshot = false; +// Captures behind the open dialog, by mode; title edits re-compose these. +const snapshotCaptures = new Map(); +let snapshotMode = SNAPSHOT_MODES.FULL; +// Mode the dialog opens in: the last one picked, kept across sessions. +let preferredSnapshotMode = SNAPSHOT_MODES.FULL; +let preferredSnapshotModePromise = null; +let isPreferredSnapshotModeChosen = false; +const SNAPSHOT_MODE_STORAGE_KEY = 'shareSnapshotMode'; +// Captures started before giving up on a window that keeps changing size. +const SNAPSHOT_LAYOUT_ATTEMPTS = 3; +// Tab sharing from an earlier snapshot, kept so Chrome asks only once. +let keptSnapshotSource = null; +let snapshotAbortController = null; +// Where the pointer was last seen, so a tab-sharing capture knows whether +// Chrome may draw it into the frames. +const pointerTracker = createPointerTracker(); +let lastSnapshotProgress = null; +let snapshotRenderTimer = null; +let snapshotRenderSequence = 0; +// Latest render of the share image, which exports wait for. +let snapshotRender = Promise.resolve(); +let snapshotIconPromise = null; +let snapshotHint = ''; +let currentTabIdPromise = null; +const SNAPSHOT_TITLE_RENDER_DELAY_MS = 250; +// Set once captureVisibleTab worked in this tab. The activeTab grant then +// lasts for the life of the page, so the shortcut tip is no longer needed. +const SNAPSHOT_VISIBLE_TAB_FLAG = 'panelize.snapshotVisibleTabCaptured'; + +function rememberSnapshotPrompt(text) { + if (typeof text === 'string' && text.trim()) { + lastSnapshotPrompt = text; + } +} + +function getSnapshotPrompt() { + return lastSnapshotPrompt || document.getElementById('unified-input')?.value || ''; +} + +/** Resolves with the mode picked last, read from storage the first time. */ +function loadPreferredSnapshotMode() { + if (!preferredSnapshotModePromise) { + preferredSnapshotModePromise = chrome.storage.local.get(SNAPSHOT_MODE_STORAGE_KEY) + .then(result => { + const mode = result?.[SNAPSHOT_MODE_STORAGE_KEY]; + // A mode picked on this page meanwhile wins over the stored one. + if (!isPreferredSnapshotModeChosen && Object.values(SNAPSHOT_MODES).includes(mode)) { + preferredSnapshotMode = mode; + } + }) + .catch(error => { + console.warn('[Multi-Panel] Failed to load the snapshot mode:', error); + }); + } + return preferredSnapshotModePromise.then(() => preferredSnapshotMode); +} + +function savePreferredSnapshotMode(mode) { + isPreferredSnapshotModeChosen = true; + if (mode === preferredSnapshotMode) return; + preferredSnapshotMode = mode; + chrome.storage.local.set({ [SNAPSHOT_MODE_STORAGE_KEY]: mode }).catch(error => { + console.warn('[Multi-Panel] Failed to save the snapshot mode:', error); + }); +} + +function updateShareStreamStatus() { + const status = document.getElementById('share-stream-status'); + if (status) status.hidden = !keptSnapshotSource?.isActive(); +} + +/** Sharing ended: from Stop, Chrome's own bar, or the shared tab going away. */ +function handleSnapshotSharingEnded() { + if (keptSnapshotSource && !keptSnapshotSource.isActive()) { + keptSnapshotSource = null; + } + updateShareStreamStatus(); +} + +function stopSnapshotSharing() { + keptSnapshotSource?.close(); + keptSnapshotSource = null; + updateShareStreamStatus(); +} + +/** + * Silent capture when this tab has the activeTab grant; otherwise the kept + * tab sharing, or a new one that is then kept. + */ +async function openSnapshotSource() { + const opened = await openTabCaptureSource({ + tabId: await getCurrentTabId(), + keptSource: keptSnapshotSource, + onEnded: handleSnapshotSharingEnded + }); + if (opened.source.kind === TAB_CAPTURE_KINDS.DISPLAY_MEDIA) { + keptSnapshotSource = opened.source; + } else if (keptSnapshotSource) { + // The grant lasts for the life of the page: sharing is no longer needed. + stopSnapshotSharing(); + } + updateShareStreamStatus(); + return opened; +} + +function isStaleSnapshotSourceError(error) { + return error instanceof TabCaptureError + && (error.code === TAB_CAPTURE_ERRORS.ENDED || error.code === TAB_CAPTURE_ERRORS.WRONG_SURFACE); +} + +/** Opens the capture source and gets it ready for a run. */ +async function startSnapshotSource() { + let opened = await openSnapshotSource(); + if (!opened.reused) return opened; + try { + await opened.source.start(); + } catch (error) { + if (!isStaleSnapshotSourceError(error)) throw error; + // Sharing ended since the last snapshot: ask again. + stopSnapshotSharing(); + opened = await openSnapshotSource(); + } + return opened; +} + +function getCurrentTabId() { + if (!currentTabIdPromise) { + currentTabIdPromise = typeof chrome.tabs?.getCurrent === 'function' + ? Promise.resolve(chrome.tabs.getCurrent()).then(tab => tab?.id ?? null).catch(() => null) + : Promise.resolve(null); + } + return currentTabIdPromise; +} + +async function loadSnapshotIcon() { + const icon = new Image(); + icon.src = chrome.runtime.getURL('icons/icon-128.png'); + try { + await icon.decode(); + return icon; + } catch (error) { + console.warn('[Multi-Panel] Failed to load the snapshot icon:', error); + return null; + } +} + +function getSnapshotIcon() { + if (!snapshotIconPromise) { + snapshotIconPromise = loadSnapshotIcon(); + } + return snapshotIconPromise; +} + +function canvasToPngBlob(canvas) { + return new Promise((resolve, reject) => { + canvas.toBlob(blob => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Could not encode the snapshot as PNG')); + } + }, 'image/png'); + }); +} + +function getSnapshotTheme() { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; +} + +function getSnapshotPanels() { + return panels.map(panel => ({ + id: panel.id, + name: getProviderById(panel.providerId)?.name || panel.providerId, + iframe: panel.iframe, + element: document.getElementById(panel.id) + })).filter(panel => panel.element && panel.iframe); +} + +function isShareModalOpen() { + const modal = document.getElementById('share-modal'); + return Boolean(modal) && modal.style.display !== 'none'; +} + +function releaseSnapshotCapture(capture) { + if (!capture?.content) return; + if (typeof capture.content.close === 'function') { + capture.content.close(); + } else { + // Free the stitched canvas right away; it can be large. + capture.content.width = 0; + capture.content.height = 0; + } +} + +function clearSnapshotCaptures() { + snapshotCaptures.forEach(releaseSnapshotCapture); + snapshotCaptures.clear(); +} + +function setSnapshotCapture(mode, capture, { replaceAll = false } = {}) { + if (replaceAll) { + clearSnapshotCaptures(); + } else { + releaseSnapshotCapture(snapshotCaptures.get(mode)); + } + snapshotCaptures.set(mode, capture); +} + +function updateSnapshotProgress(progress) { + const text = document.getElementById('snapshot-progress-text'); + const bar = document.getElementById('snapshot-progress-bar'); + if (!text || !bar) return; + lastSnapshotProgress = progress; + if (!progress) { + text.textContent = t('shareProgressPreparing'); + bar.removeAttribute('value'); + return; + } + const total = Math.max(progress.step, progress.total); + text.textContent = t('shareProgressStep', [String(progress.step), String(total)]); + bar.max = total; + bar.value = progress.step; +} + +/** Asks the user to keep the pointer still while a frame waits for it. */ +function showSnapshotPointerWait(waiting) { + const text = document.getElementById('snapshot-progress-text'); + if (!text) return; + if (waiting) { + text.textContent = t('shareProgressHoldPointer'); + } else { + updateSnapshotProgress(lastSnapshotProgress); + } +} + +function setSnapshotBusy(busy) { + document.body.classList.toggle('snapshot-capturing', busy); + const shareBtn = document.getElementById('share-snapshot-btn'); + if (shareBtn) shareBtn.disabled = busy; + document.querySelectorAll('.share-mode-btn, #share-download-btn, #share-copy-btn, #share-title-input') + .forEach(element => { + element.disabled = busy; + }); + const progress = document.getElementById('snapshot-progress'); + if (progress) progress.hidden = !busy; + if (busy) updateSnapshotProgress(null); +} + +function updateShareModeButtons() { + document.querySelectorAll('.share-mode-btn').forEach(button => { + button.setAttribute('aria-checked', String(button.dataset.mode === snapshotMode)); + }); +} + +async function updateSnapshotHint({ source, fallbackReason }) { + if (source.kind === TAB_CAPTURE_KINDS.VISIBLE_TAB) { + sessionStorage.setItem(SNAPSHOT_VISIBLE_TAB_FLAG, '1'); + snapshotHint = ''; + return; + } + snapshotHint = ''; + if (fallbackReason !== TAB_CAPTURE_ERRORS.PERMISSION || sessionStorage.getItem(SNAPSHOT_VISIBLE_TAB_FLAG)) { + return; + } + // The toolbar icon and its shortcut do nothing while the setting is off. + if ((await getSetting('keyboardShortcutEnabled')) === false) { + return; + } + let shortcut = ''; + try { + shortcut = getExecuteActionShortcut(await chrome.commands.getAll()); + } catch (error) { + // Without the commands API, point at the toolbar icon instead + } + snapshotHint = shortcut ? t('shareShortcutHint', [shortcut]) : t('shareToolbarHint'); +} + +// Names the panels whose answer is not shown whole, both in the dialog and in the image. +function getSnapshotCutNotes(capture) { + const locale = getSnapshotLocale(); + const notes = []; + if (capture.truncated.length) { + notes.push(t('shareTruncatedNote', [formatProviderList(capture.truncated, locale)])); + } + if (capture.interrupted.length) { + notes.push(t('shareInterruptedNote', [formatProviderList(capture.interrupted, locale)])); + } + return notes; +} + +function updateShareNotes() { + const hint = document.getElementById('share-capture-hint'); + const hintText = document.getElementById('share-capture-hint-text'); + if (hint && hintText) { + hintText.textContent = snapshotHint; + hint.hidden = !snapshotHint; + } + + const notes = document.getElementById('share-capture-notes'); + const capture = snapshotCaptures.get(snapshotMode); + if (!notes) return; + const lines = []; + if (capture?.outOfView.length) { + lines.push(t('shareOutOfViewNote', [formatProviderList(capture.outOfView, getSnapshotLocale())])); + } + if (capture?.currentViewOnly.length) { + lines.push(t('shareCurrentViewOnly', [formatProviderList(capture.currentViewOnly, getSnapshotLocale())])); + } + if (capture) { + lines.push(...getSnapshotCutNotes(capture)); + } + notes.textContent = lines.join('\n'); + notes.hidden = lines.length === 0; +} + +function getSnapshotLocale() { + return toLanguageTag(getTranslationLocale()); +} + +function setCurrentSnapshot(blob) { + const fileName = currentSnapshot?.fileName || getSnapshotFileName(new Date()); + releaseCurrentSnapshot(); + currentSnapshot = { blob, url: URL.createObjectURL(blob), fileName }; + document.getElementById('share-preview').src = currentSnapshot.url; +} + +async function renderSnapshot() { + const capture = snapshotCaptures.get(snapshotMode); + if (!capture) return; + const sequence = ++snapshotRenderSequence; + const icon = await getSnapshotIcon(); + const locale = getSnapshotLocale(); + const names = capture.names; + const canvas = document.createElement('canvas'); + composeSnapshot(canvas, { + content: capture.content, + scale: capture.scale, + title: document.getElementById('share-title-input')?.value || '', + theme: capture.theme, + showWatermark: capture.showWatermark, + icon, + labelText: t('shareSnapshotPromptLabel'), + taglineText: t('shareSnapshotTagline'), + captionText: t('shareSnapshotCaption'), + sublineText: formatSnapshotSubline({ + providersText: names.length ? t('shareSnapshotSubline', [formatProviderList(names, locale)]) : '', + date: capture.date, + locale + }), + // The image has room for one line. + noteText: getSnapshotCutNotes(capture).join(' · ') + }); + const blob = await canvasToPngBlob(canvas); + canvas.width = 0; + canvas.height = 0; + // A newer render (e.g. the next keystroke) wins. + if (sequence !== snapshotRenderSequence) return; + setCurrentSnapshot(blob); + updateShareNotes(); +} + +/** Renders the share image now, instead of any render still scheduled. */ +function startSnapshotRender() { + clearTimeout(snapshotRenderTimer); + snapshotRenderTimer = null; + snapshotRender = renderSnapshot(); + return snapshotRender; +} + +function scheduleSnapshotRender() { + clearTimeout(snapshotRenderTimer); + snapshotRenderTimer = setTimeout(() => { + startSnapshotRender().catch(error => { + console.warn('[Multi-Panel] Failed to update the snapshot:', error); + showToast(t('shareCaptureFailed')); + }); + }, SNAPSHOT_TITLE_RENDER_DELAY_MS); +} + +/** + * The share image as the dialog stands, e.g. with a title edit still waiting + * for its render; null when the dialog was closed meanwhile. Rejects when + * the latest render failed, rather than giving an older image. + */ +async function getUpToDateSnapshot() { + if (snapshotRenderTimer !== null) { + startSnapshotRender(); + } + for (;;) { + const render = snapshotRender; + try { + await render; + } catch (error) { + if (render === snapshotRender) throw error; + } + if (render === snapshotRender) return currentSnapshot; + } +} + +function releaseCurrentSnapshot() { + if (currentSnapshot) { + URL.revokeObjectURL(currentSnapshot.url); + currentSnapshot = null; + } +} + +function openShareModal() { + document.getElementById('share-modal').style.display = 'flex'; + updateShareModeButtons(); + updateShareStreamStatus(); +} + +/** Focus for the dialog once its buttons are enabled again. */ +function focusShareModal({ wasOpen }) { + if (!isShareModalOpen()) return; + const modal = document.getElementById('share-modal'); + if (!wasOpen) { + document.getElementById('share-copy-btn')?.focus(); + } else if (!modal.contains(document.activeElement)) { + // The mode button that started the capture was disabled meanwhile. + modal.querySelector('.share-mode-btn[aria-checked="true"]')?.focus(); + } +} + +function closeShareModal() { + const modal = document.getElementById('share-modal'); + if (!modal || modal.style.display === 'none') return; + if (isCapturingSnapshot) return; + modal.style.display = 'none'; + clearTimeout(snapshotRenderTimer); + snapshotRenderTimer = null; + snapshotRenderSequence += 1; + document.getElementById('share-preview').removeAttribute('src'); + releaseCurrentSnapshot(); + clearSnapshotCaptures(); + snapshotHint = ''; + snapshotMode = preferredSnapshotMode; +} + +function getCaptureErrorMessage(error) { + if (error instanceof SnapshotCancelledError) return t('shareCaptureCancelled'); + if (error instanceof SnapshotLayoutChangedError) return t('shareCaptureLayoutChanged'); + if (error instanceof TabCaptureError) { + if (error.code === TAB_CAPTURE_ERRORS.CANCELLED) return t('shareCaptureCancelled'); + if (error.code === TAB_CAPTURE_ERRORS.WRONG_SURFACE) return t('shareCaptureWrongSurface'); + if (error.code === TAB_CAPTURE_ERRORS.TAB_HIDDEN) return t('shareCaptureTabHidden'); + } + return t('shareCaptureFailed'); +} + +function isSnapshotCancellation(error) { + return error instanceof SnapshotCancelledError || error?.code === TAB_CAPTURE_ERRORS.CANCELLED; +} + +/** + * Captures the comparison in `mode` and shows it in the share dialog. + * Must run straight from a user gesture: without the activeTab grant the + * capture falls back to the Screen Capture API, which needs that gesture. + * @param {string} mode - A SNAPSHOT_MODES value. + * @param {object} [options] + * @param {boolean} [options.refresh] - Drop the captures of other modes. + */ +async function captureSnapshot(mode, { refresh = false } = {}) { + if (isCapturingSnapshot) return; + isCapturingSnapshot = true; + const controller = new AbortController(); + snapshotAbortController = controller; + const wasOpen = isShareModalOpen(); + setSnapshotBusy(true); + let source = null; + let pointerGuard = null; + try { + const opened = await startSnapshotSource(); + source = opened.source; + await updateSnapshotHint(opened); + const hideCursor = source.kind === TAB_CAPTURE_KINDS.DISPLAY_MEDIA; + document.documentElement.classList.toggle('snapshot-hide-cursor', hideCursor); + const grid = document.getElementById('panel-grid'); + // CSS cannot hide the pointer from tab sharing: shield the panels and + // take each frame once Chrome has stopped drawing it. + pointerGuard = hideCursor + ? createPointerGuard({ area: grid, tracker: pointerTracker, onWaitChange: showSnapshotPointerWait }) + : null; + + const theme = getSnapshotTheme(); + const showWatermark = (await getSetting('shareWatermarkEnabled')) !== false; + const options = { + source, + grid, + panels: getSnapshotPanels(), + prompt: getSnapshotPrompt(), + hideCursor, + beforeGrab: (signal) => pointerGuard?.waitUntilStill(signal), + signal: controller.signal, + onProgress: updateSnapshotProgress + }; + const capture = () => (mode === SNAPSHOT_MODES.FULL + ? captureFullAnswers({ + ...options, + // Room for the frame, with the longest headline the title can take. + getChromeHeight: (scale) => getSnapshotFrameChromeHeight({ + contentWidth: Math.round(grid.getBoundingClientRect().width * scale), + scale, + theme, + showWatermark, + titleLineCount: SNAPSHOT_MAX_PROMPT_LINES, + hasSubline: true, + sublineLineCount: SNAPSHOT_MAX_SUBLINE_LINES, + hasNote: true + }) + }) + : captureCurrentView(options)); + let result = null; + for (let attempt = 1; !result; attempt += 1) { + try { + result = await capture(); + } catch (error) { + if (!(error instanceof SnapshotLayoutChangedError) || attempt >= SNAPSHOT_LAYOUT_ATTEMPTS) { + throw error; + } + // The panels moved or changed size under the capture: measure them again. + console.info('[Multi-Panel] Layout changed during the snapshot, starting over'); + } + } + console.info('[Multi-Panel] Snapshot alignment:', JSON.stringify(result.alignment)); + + setSnapshotCapture(mode, { ...result, theme, showWatermark, date: new Date() }, { replaceAll: refresh || !wasOpen }); + snapshotMode = mode; + savePreferredSnapshotMode(mode); + if (!wasOpen) { + document.getElementById('share-title-input').value = normalizePromptText(getSnapshotPrompt()); + } + await startSnapshotRender(); + openShareModal(); + } catch (error) { + if (isSnapshotCancellation(error)) { + console.info('[Multi-Panel] Snapshot cancelled'); + } else { + console.warn('[Multi-Panel] Failed to create snapshot:', error); + } + showToast(getCaptureErrorMessage(error)); + } finally { + document.documentElement.classList.remove('snapshot-hide-cursor'); + pointerGuard?.dispose(); + if (source && source === keptSnapshotSource) { + // Paused until the next snapshot. + source.stop().catch(error => console.warn('[Multi-Panel] Failed to pause tab sharing:', error)); + } else { + source?.close(); + } + updateShareStreamStatus(); + snapshotAbortController = null; + isCapturingSnapshot = false; + setSnapshotBusy(false); + updateShareModeButtons(); + focusShareModal({ wasOpen }); + } +} + +function cancelSnapshotCapture() { + snapshotAbortController?.abort(); +} + +async function shareComparisonSnapshot() { + return captureSnapshot(await loadPreferredSnapshotMode(), { refresh: true }); +} + +function selectSnapshotMode(mode) { + if (isCapturingSnapshot || !Object.values(SNAPSHOT_MODES).includes(mode)) return; + if (snapshotCaptures.has(mode)) { + if (mode !== snapshotMode) { + snapshotMode = mode; + savePreferredSnapshotMode(mode); + updateShareModeButtons(); + startSnapshotRender().catch(error => console.warn('[Multi-Panel] Failed to show the snapshot:', error)); + } + return; + } + captureSnapshot(mode); +} + +/** The toolbar icon, its shortcut or the context menu was used on this page. */ +async function handleShareSnapshotRequest(payload = {}) { + if (payload.tabId !== await getCurrentTabId() || isCapturingSnapshot) { + return; + } + captureSnapshot(isShareModalOpen() ? snapshotMode : await loadPreferredSnapshotMode(), { refresh: true }); +} + +async function downloadCurrentSnapshot() { + if (!currentSnapshot) return; + let snapshot; + try { + snapshot = await getUpToDateSnapshot(); + } catch (error) { + console.warn('[Multi-Panel] Failed to update the snapshot:', error); + showToast(t('shareCaptureFailed')); + return; + } + if (!snapshot) return; + const link = document.createElement('a'); + link.href = snapshot.url; + link.download = snapshot.fileName; + link.click(); +} + +async function copyCurrentSnapshot() { + if (!currentSnapshot) return; + try { + // Written right away, while the click still allows it; the image follows + // once a pending title edit is drawn. + const blob = getUpToDateSnapshot().then((snapshot) => { + if (!snapshot) throw new Error('The share dialog was closed'); + return snapshot.blob; + }); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + showToast(t('shareCopied')); + } catch (error) { + console.warn('[Multi-Panel] Failed to copy snapshot:', error); + showToast(t('shareCopyFailed')); + } +} + +function setupShareSnapshot() { + const modal = document.getElementById('share-modal'); + document.getElementById('share-snapshot-btn')?.addEventListener('click', shareComparisonSnapshot); + document.getElementById('close-share-modal')?.addEventListener('click', closeShareModal); + document.getElementById('share-download-btn')?.addEventListener('click', downloadCurrentSnapshot); + document.getElementById('share-copy-btn')?.addEventListener('click', copyCurrentSnapshot); + document.getElementById('snapshot-cancel-btn')?.addEventListener('click', cancelSnapshotCapture); + document.getElementById('share-stream-stop')?.addEventListener('click', () => { + stopSnapshotSharing(); + document.getElementById('share-copy-btn')?.focus(); + }); + // Tab sharing ends with the page. + window.addEventListener('pagehide', stopSnapshotSharing); + document.getElementById('share-title-input')?.addEventListener('input', scheduleSnapshotRender); + document.querySelectorAll('.share-mode-btn').forEach(button => { + button.addEventListener('click', () => selectSnapshotMode(button.dataset.mode)); + }); + modal?.addEventListener('click', (event) => { + if (event.target === modal) closeShareModal(); + }); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + if (isCapturingSnapshot) { + cancelSnapshotCapture(); + } else { + closeShareModal(); + } + }); + // Resolve early so the first shortcut press is matched without delay. + getCurrentTabId(); + loadPreferredSnapshotMode().then(mode => { + if (!isShareModalOpen()) snapshotMode = mode; + }); +} + // ===== Image Management ===== const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB per image const MAX_IMAGE_COUNT = 10; @@ -2014,6 +2835,20 @@ async function triggerSendButtons() { } } +// ===== Panel Fit ===== +function updatePanelFit(panel) { + const container = panel.iframe?.parentElement; + if (!container) { + return; + } + panel.fit = updatePanelFitState(panel.fit || createPanelFitState(), { + available: container.clientWidth, + needed: panel.pageNeededWidth || 0, + now: performance.now() + }); + applyPanelFitScale(panel.iframe, getPanelFitScale(panel.fit)); +} + // ===== Layout Management ===== function setLayout(layout) { if (!LAYOUT_PANEL_COUNTS[layout]) return; @@ -2726,6 +3561,7 @@ function escapeHtml(text) { function showToast(message) { const toast = document.createElement('div'); + toast.className = 'panelize-toast'; toast.textContent = message; toast.style.cssText = ` position: fixed; diff --git a/options/options.html b/options/options.html index 33f25a2..9b24072 100644 --- a/options/options.html +++ b/options/options.html @@ -114,6 +114,21 @@

Multi-Panel Comparison + +
+
+
Show watermark on shared images
+
+ Add a "Compared with Panelize · panelize.dev" strip to snapshots taken with the Share button +
+
+
+ +
+
diff --git a/options/options.js b/options/options.js index ecb381a..aa8d166 100644 --- a/options/options.js +++ b/options/options.js @@ -288,6 +288,12 @@ async function loadSettings() { openModeSelect.value = settings.openMode || 'tab'; } + // Share snapshot watermark setting + const shareWatermarkToggle = document.getElementById('share-watermark-toggle'); + if (shareWatermarkToggle) { + shareWatermarkToggle.checked = settings.shareWatermarkEnabled !== false; + } + // Enter key behavior settings const enterBehavior = settings.enterKeyBehavior || { enabled: true, @@ -753,6 +759,16 @@ function setupEventListeners() { }); } + // Multi-Panel: Share snapshot watermark + const shareWatermarkToggle = document.getElementById('share-watermark-toggle'); + if (shareWatermarkToggle) { + shareWatermarkToggle.addEventListener('change', async (e) => { + const enabled = e.target.checked; + await saveSetting('shareWatermarkEnabled', enabled); + showStatus('success', enabled ? t('msgShareWatermarkEnabled') : t('msgShareWatermarkDisabled')); + }); + } + // Multi-Panel: Layout selection const multiPanelLayoutSelect = document.getElementById('multi-panel-layout-select'); if (multiPanelLayoutSelect) { diff --git a/package-lock.json b/package-lock.json index efc58df..01d7dd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "panelize", "version": "1.3.3", "devDependencies": { - "@playwright/test": "^1.58.1", + "@playwright/test": "^1.62.1", "@vitest/ui": "^3.2.4", "happy-dom": "^20.14.5", "vitest": "^3.2.4", @@ -790,19 +790,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", - "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@pnpm/config.env-replace": { @@ -4350,35 +4350,35 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", - "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", - "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/playwright/node_modules/fsevents": { diff --git a/package.json b/package.json index ea76293..3da1239 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test:live:login": "node tests/live/login.js" }, "devDependencies": { - "@playwright/test": "^1.58.1", + "@playwright/test": "^1.62.1", "@vitest/ui": "^3.2.4", "happy-dom": "^20.14.5", "vitest": "^3.2.4", diff --git a/tests/e2e/extension-harness.js b/tests/e2e/extension-harness.js index f940bd6..8b0b28b 100644 --- a/tests/e2e/extension-harness.js +++ b/tests/e2e/extension-harness.js @@ -8,7 +8,11 @@ import { getBrowserLaunchOptions } from './browser-launch-options.js'; export const REPO_EXTENSION_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const COPY_EXCLUDED_SEGMENTS = ['.git', 'node_modules', 'test-results', 'playwright-report', 'dist']; +// `_metadata` is generated by Chrome while it loads the unpacked repo and can +// vanish mid-copy; each copied extension gets its own. +const COPY_EXCLUDED_SEGMENTS = [ + '.git', 'node_modules', 'test-results', 'playwright-report', 'dist', 'artifacts', '_metadata' +]; /** * Copies the extension and lets the caller patch its manifest, e.g. to map diff --git a/tests/e2e/fixtures/conversation-fixture.js b/tests/e2e/fixtures/conversation-fixture.js new file mode 100644 index 0000000..45a870f --- /dev/null +++ b/tests/e2e/fixtures/conversation-fixture.js @@ -0,0 +1,130 @@ +/** + * Long chat page served in place of a provider inside multi-panel iframes, + * for "Full answers" snapshots. It is laid out like ChatGPT: the + * conversation scrolls in its own container under a sticky header, and the + * composer floats over the bottom of it, with a "Scroll to bottom" button + * floating over the middle of the conversation and, optionally, a notice + * stacked on the composer. Optional parts mimic what Gemini draws: a + * section heading that sticks under the header with a shadow and a fade + * below it, a wide table with a sticky header row in a horizontally + * scrolling box, and a fade drawn by the composer's ::before over the + * bottom of the conversation. Every part has a solid color, so a + * column of the stitched image tells which parts were captured, in which + * order and how often. + * + * On load the conversation is scrolled to the bottom, as chat apps do. + * + * @param {object} options + * @param {string} options.prompt - Text of the user message. + * @param {number[][]} options.blocks - RGB color of each answer block. + * @param {number} [options.blockHeight] - Height of each answer block. + * @param {boolean} [options.notice] - Show a text notice right above the + * composer, like Claude's "You've used 90% of your session limit". + * @param {boolean} [options.stickyHeading] - Sticky heading after the first block. + * @param {boolean} [options.wideTable] - Wide table after the second block. + * @param {boolean} [options.fade] - Composer ::before fade over the conversation. + * @param {boolean} [options.userRole] - Mark the user message with + * data-message-author-role="user", as ChatGPT does. + * @param {number} [options.minWidth] - Width the page needs, like an app + * shell with a min-width (Kimi, ChatGLM); narrower panels scale it down. + * @returns {string} HTML document. + */ +export function renderConversationFixture({ + prompt, + blocks, + blockHeight = CONVERSATION_COLORS.blockHeight, + notice = false, + stickyHeading = false, + wideTable = false, + fade = false, + userRole = false, + minWidth = 0, +}) { + const rgb = (color) => `rgb(${color.join(', ')})`; + const escape = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`); + const table = `
+ ${''.repeat(8)} + ${`${''.repeat(8)}`.repeat(CONVERSATION_COLORS.tableRows)} +
Field
Value
`; + const answer = blocks + .map((color, index) => [ + `
`, + index === 0 && stickyHeading ? '

Section

' : '', + index === 1 && wideTable ? table : '', + ].join('')) + .join(''); + return ` + + + +Conversation fixture + + + + ${minWidth ? `
` : ''} +
+
+
+
${escape(prompt)}
+
${answer}
+
+
+ + ${notice ? '
You\'ve used 90% of your session limit
' : ''} +
+ + + + + +`; +} + +/** Colors and sizes of the fixture parts, for reading the image back. */ +export const CONVERSATION_COLORS = Object.freeze({ + page: [255, 255, 255], + header: [200, 0, 0], + composer: [0, 0, 200], + earlier: [150, 150, 150], + user: [60, 60, 60], + floating: [255, 0, 255], + notice: [0, 160, 0], + headerHeight: 48, + composerHeight: 80, + blockHeight: 300, + floatingSize: 36, + noticeHeight: 40, + heading: [255, 128, 0], + headingHeight: 40, + tableHeader: [0, 80, 160], + tableHeaderHeight: 36, + tableRow: [90, 0, 140], + tableRowHeight: 48, + tableRows: 4, + fade: [255, 255, 0], + // Taller than the part of the conversation left out of each stitched screen. + fadeHeight: '35vh', +}); diff --git a/tests/e2e/focus-protection.e2e.test.js b/tests/e2e/focus-protection.e2e.test.js index 68ed225..1f9162d 100644 --- a/tests/e2e/focus-protection.e2e.test.js +++ b/tests/e2e/focus-protection.e2e.test.js @@ -54,7 +54,8 @@ test.describe('Focus protection E2E', () => { function providerFrame(providerId) { const host = PROVIDER_HOSTS[providerId]; - return page.frames().find((frame) => new URL(frame.url()).host === host); + // A frame that has not started loading yet has no URL. + return page.frames().find((frame) => URL.canParse(frame.url()) && new URL(frame.url()).host === host); } async function waitForFixtureReady(providerId) { diff --git a/tests/e2e/panel-fit-resize.e2e.test.js b/tests/e2e/panel-fit-resize.e2e.test.js new file mode 100644 index 0000000..a6f7221 --- /dev/null +++ b/tests/e2e/panel-fit-resize.e2e.test.js @@ -0,0 +1,255 @@ +import { test, expect } from '@playwright/test'; +import { launchExtension, openMultiPanel } from './extension-harness.js'; +import { PANEL_COLUMN_WEIGHTS_KEY } from '../../modules/panel-resize.js'; + +const EDGE_COLOR = [255, 0, 255]; + +/** + * App shell like ChatGLM's: full-screen scrolling wrappers around a sidebar + * and a main area held open by a min-width, with a colored strip at its far + * right edge. + */ +function renderShellPage(minWidth) { + return ` + +
chat
`; +} + +/** + * App shell like Qwen's when signed in: a narrow rail of icons beside a + * scrolling content box that holds the app, held open by a min-width, with a + * colored strip at its far right edge. + */ +function renderRailShellPage(railWidth, minWidth) { + return ` + +
chat
`; +} + +/** + * Responsive page with things wider than the panel that are meant to be: + * a table scrolling sideways, an open menu and hidden or off-screen layers. + */ +const RESPONSIVE_PAGE = ` +
wide table
+ + +
+`; + +function providerFrame(page, origin) { + return page.frames().find(frame => frame.url().startsWith(origin)); +} + +function getColumnWidths(page) { + return page.evaluate(() => Array.from( + document.querySelectorAll('#panel-grid .panel-item'), + panel => Math.round(panel.getBoundingClientRect().width) + )); +} + +/** RGB of one CSS pixel of the page as it is on screen, frames included. */ +async function readScreenPixel(page, x, y) { + const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 } }); + return page.evaluate(async (src) => { + const image = new Image(); + image.src = src; + await image.decode(); + const canvas = new OffscreenCanvas(1, 1); + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0); + return Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)); + }, `data:image/png;base64,${png.toString('base64')}`); +} + +async function dragHandle(page, index, deltaX) { + const box = await page.locator('.panel-resize-handle').nth(index).boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + deltaX / 2, y, { steps: 4 }); + await page.mouse.move(x + deltaX, y, { steps: 4 }); + await page.mouse.up(); +} + +test('a page wider than its panel is laid out at its width and scaled to fit', async ({}, testInfo) => { + test.setTimeout(90_000); + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + await extension.context.route('https://www.kimi.com/**', route => route.fulfill({ + contentType: 'text/html', body: renderShellPage(600), + })); + await extension.context.route('https://chatgpt.com/**', route => route.fulfill({ + contentType: 'text/html', body: RESPONSIVE_PAGE, + })); + // Two panels in 320px columns, like a full 1x12 grid. + const page = await openMultiPanel(extension, { providers: ['kimi', 'chatgpt'], layout: '1x12' }); + const [kimi, chatgpt] = [0, 1].map(index => page.locator('#panel-grid iframe').nth(index)); + await kimi.evaluate((frame) => { + window.panelFitChanges = []; + new MutationObserver(() => window.panelFitChanges.push(frame.classList.contains('panel-fitted'))) + .observe(frame, { attributes: true, attributeFilter: ['class'] }); + }); + + await expect(kimi).toHaveClass(/panel-fitted/); + const kimiFrame = providerFrame(page, 'https://www.kimi.com'); + // The cross-process frame takes its new viewport a moment after the iframe resizes. + await expect.poll(() => kimiFrame.evaluate(() => window.innerWidth)).toBe(600); + // Laid out at its width, the page still needs it: the fit holds, it does not flicker. + await kimiFrame.evaluate(() => document.body.classList.add('relaid')); + // Two report periods (PAGE_WIDTH_REPORT_THROTTLE_MS) for the page to measure again. + await page.waitForTimeout(1000); + expect(await page.evaluate(() => window.panelFitChanges)).not.toContain(false); + const fit = await kimi.evaluate(frame => ({ + frame: frame.getBoundingClientRect().width, + panel: frame.parentElement.clientWidth, + })); + expect(Math.abs(fit.frame - fit.panel)).toBeLessThanOrEqual(1); + // The right edge of the app is on screen, not cut off. + const kimiBox = await kimi.boundingBox(); + const edgePixel = await readScreenPixel(page, kimiBox.x + kimiBox.width - 4, kimiBox.y + 100); + edgePixel.forEach((channel, index) => expect(Math.abs(channel - EDGE_COLOR[index])).toBeLessThanOrEqual(12)); + await page.screenshot({ path: testInfo.outputPath('fitted-1x12.png') }); + + // Scrolling tables, menus, hidden and off-screen layers do not count. + await expect(chatgpt).not.toHaveClass(/panel-fitted/); + expect(await providerFrame(page, 'https://chatgpt.com').evaluate(() => window.innerWidth)).toBe(fit.panel); + + // Once the page fits again (e.g. its sidebar collapsed), it is shown at full size. + await kimiFrame.evaluate(() => { document.querySelector('main').style.minWidth = '0px'; }); + await expect(kimi).not.toHaveClass(/panel-fitted/); + await expect.poll(() => kimiFrame.evaluate(() => window.innerWidth)).toBe(fit.panel); + } finally { + await extension.close(); + } +}); + +test('a page whose app sits beside a side rail is scaled to fit too', async () => { + test.setTimeout(90_000); + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + await extension.context.route('https://www.kimi.com/**', route => route.fulfill({ + contentType: 'text/html', body: renderRailShellPage(60, 440), + })); + const page = await openMultiPanel(extension, { providers: ['kimi'], layout: '1x12' }); + const kimi = page.locator('#panel-grid iframe').first(); + + await expect(kimi).toHaveClass(/panel-fitted/); + const kimiFrame = providerFrame(page, 'https://www.kimi.com'); + await expect.poll(() => kimiFrame.evaluate(() => window.innerWidth)).toBe(500); + const kimiBox = await kimi.boundingBox(); + const edgePixel = await readScreenPixel(page, kimiBox.x + kimiBox.width - 4, kimiBox.y + 100); + edgePixel.forEach((channel, index) => expect(Math.abs(channel - EDGE_COLOR[index])).toBeLessThanOrEqual(12)); + } finally { + await extension.close(); + } +}); + +test('panel edges drag to resize columns, are remembered per layout and reset on double-click', async () => { + test.setTimeout(90_000); + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + await extension.context.route('https://www.kimi.com/**', route => route.fulfill({ + contentType: 'text/html', body: renderShellPage(600), + })); + await extension.context.route(url => url.protocol === 'https:' && url.hostname !== 'www.kimi.com', + route => route.fulfill({ contentType: 'text/html', body: 'provider' })); + const page = await openMultiPanel(extension, { providers: ['chatgpt', 'kimi', 'deepseek'], layout: '1x3' }); + await expect(page.locator('.panel-resize-handle')).toHaveCount(2); + const handle = page.locator('.panel-resize-handle').first(); + await expect(handle).toHaveAttribute('role', 'separator'); + await expect(handle).toHaveAttribute('aria-label', 'Drag to resize panels. Double-click to reset.'); + + // Columns that fit the window trade width with their neighbor. + const start = await getColumnWidths(page); + await dragHandle(page, 0, -150); + const dragged = await getColumnWidths(page); + expect(Math.abs(dragged[0] - (start[0] - 150))).toBeLessThanOrEqual(2); + expect(Math.abs(dragged[1] - (start[1] + 150))).toBeLessThanOrEqual(2); + expect(Math.abs(dragged[2] - start[2])).toBeLessThanOrEqual(2); + await expect(page.locator('body')).not.toHaveClass(/panel-resizing/); + + // Widening Kimi's panel to the width its page needs shows it at full size. + const kimi = page.locator('#panel-grid iframe').nth(1); + await expect(kimi).toHaveClass(/panel-fitted/); + await dragHandle(page, 1, 620 - dragged[1]); + await expect(kimi).not.toHaveClass(/panel-fitted/); + const widened = await getColumnWidths(page); + + // Remembered for this layout across reloads. + const stored = await page.evaluate(key => chrome.storage.local.get(key), PANEL_COLUMN_WEIGHTS_KEY); + expect(stored[PANEL_COLUMN_WEIGHTS_KEY]['1x3']).toHaveLength(3); + await page.reload(); + await expect(page.locator('#panel-grid iframe')).toHaveCount(3); + await expect.poll(() => getColumnWidths(page)).toEqual(widened); + + // Keyboard: arrow keys move a focused edge. While panels are still + // loading, focus protection pulls focus back to the unified input, so + // focus the handle until it keeps focus past the protection's frame. + const firstHandle = page.locator('.panel-resize-handle').first(); + await expect.poll(async () => { + await firstHandle.focus(); + return firstHandle.evaluate(el => new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve(document.activeElement === el))); + })); + }, { timeout: 15_000 }).toBe(true); + await page.keyboard.press('ArrowRight'); + const keyed = await getColumnWidths(page); + expect(keyed[0] - widened[0]).toBe(16); + + // Double-click puts the columns back to equal widths. + await page.locator('.panel-resize-handle').first().dblclick(); + await expect.poll(() => getColumnWidths(page)).toEqual(start); + } finally { + await extension.close(); + } +}); + +test('in a grid that scrolls sideways a dragged column grows alone', async () => { + test.setTimeout(90_000); + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + await extension.context.route(url => url.protocol === 'https:', route => route.fulfill({ + contentType: 'text/html', body: 'provider', + })); + const page = await openMultiPanel(extension, { + providers: ['chatgpt', 'kimi', 'deepseek', 'gemini', 'grok'], + layout: '1x5', + }); + await expect(page.locator('.panel-resize-handle')).toHaveCount(4); + const start = await getColumnWidths(page); + expect(start).toEqual([400, 400, 400, 400, 400]); + await dragHandle(page, 0, 120); + const dragged = await getColumnWidths(page); + expect(Math.abs(dragged[0] - 520)).toBeLessThanOrEqual(2); + expect(dragged.slice(1)).toEqual([400, 400, 400, 400]); + // Handles follow the columns. + const handleBox = await page.locator('.panel-resize-handle').nth(1).boundingBox(); + const secondBox = await page.locator('#panel-grid .panel-item').nth(1).boundingBox(); + expect(Math.abs(handleBox.x + handleBox.width / 2 - (secondBox.x + secondBox.width + 2))).toBeLessThanOrEqual(1); + } finally { + await extension.close(); + } +}); diff --git a/tests/e2e/rating-prompt.e2e.test.js b/tests/e2e/rating-prompt.e2e.test.js new file mode 100644 index 0000000..b55cee4 --- /dev/null +++ b/tests/e2e/rating-prompt.e2e.test.js @@ -0,0 +1,104 @@ +import { test, expect } from '@playwright/test'; +import { launchExtension, openMultiPanel } from './extension-harness.js'; +import { renderChatgptFixture } from './fixtures/chatgpt-fixture.js'; +import { + RATING_PROMPT_REVIEW_URL, + RATING_PROMPT_STORAGE_KEY +} from '../../modules/rating-prompt.js'; + +const readRatingState = (extension) => extension.serviceWorker.evaluate( + async (key) => (await chrome.storage.local.get(key))[key], + RATING_PROMPT_STORAGE_KEY +); + +async function openWithRatingState(extension, state) { + await extension.context.route('https://chatgpt.com/**', route => route.fulfill({ + contentType: 'text/html', body: renderChatgptFixture({ composer: 'current' }), + })); + if (state) { + await extension.serviceWorker.evaluate( + (items) => chrome.storage.local.set(items), + { [RATING_PROMPT_STORAGE_KEY]: state } + ); + } + const page = await openMultiPanel(extension, { providers: ['chatgpt'] }); + await expect(page.frameLocator('iframe') + .locator('[data-composer-placement="thread"] [data-composer-markdown]')).toBeVisible(); + return page; +} + +async function sendAll(page, text) { + await page.locator('#unified-input').fill(text); + await page.locator('#send-all-btn').click(); + await expect(page.locator('#send-status')).toHaveText(/Sent to 1 AI/); +} + +test('the rating banner appears on the 20th Send All and "Later" snoozes it for 50 sends', async ({}, testInfo) => { + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + const page = await openWithRatingState(extension, { sendCount: 18, nextPromptAt: 20, dismissed: false }); + const banner = page.locator('#rating-banner'); + await expect(banner).toBeHidden(); + + await sendAll(page, 'Rating prompt send 19'); + await expect.poll(async () => (await readRatingState(extension))?.sendCount).toBe(19); + await expect(banner).toBeHidden(); + + await sendAll(page, 'Rating prompt send 20'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText('Enjoying Panelize? Rate it on the Chrome Web Store'); + await page.screenshot({ path: testInfo.outputPath('rating-banner.png') }); + + await page.locator('#rating-later-btn').click(); + await expect(banner).toBeHidden(); + await expect.poll(() => readRatingState(extension)) + .toEqual({ sendCount: 20, nextPromptAt: 70, dismissed: false }); + + await page.reload(); + await expect(page.locator('#panel-grid iframe')).toHaveCount(1); + await expect(banner).toBeHidden(); + } finally { + await extension.close(); + } +}); + +test('"Rate" opens the store reviews page and never shows the banner again', async () => { + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + const page = await openWithRatingState(extension, { sendCount: 20, nextPromptAt: 20, dismissed: false }); + const banner = page.locator('#rating-banner'); + await expect(banner).toBeVisible(); + + // The store page is never loaded; only the requested URL is checked. + await extension.context.route('https://chromewebstore.google.com/**', route => route.fulfill({ + contentType: 'text/html', body: 'store', + })); + const storePage = extension.context.waitForEvent('page'); + await page.locator('#rating-rate-btn').click(); + await (await storePage).waitForURL(RATING_PROMPT_REVIEW_URL); + + await expect(banner).toBeHidden(); + await expect.poll(async () => (await readRatingState(extension))?.dismissed).toBe(true); + } finally { + await extension.close(); + } +}); + +test('"Don\'t ask again" hides the banner for good', async () => { + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + const page = await openWithRatingState(extension, { sendCount: 25, nextPromptAt: 20, dismissed: false }); + const banner = page.locator('#rating-banner'); + await expect(banner).toBeVisible(); + + await page.locator('#rating-never-btn').click(); + await expect(banner).toBeHidden(); + await expect.poll(async () => (await readRatingState(extension))?.dismissed).toBe(true); + + await sendAll(page, 'Rating prompt after opting out'); + await expect.poll(async () => (await readRatingState(extension))?.sendCount).toBe(26); + await expect(banner).toBeHidden(); + } finally { + await extension.close(); + } +}); diff --git a/tests/e2e/share-snapshot.e2e.test.js b/tests/e2e/share-snapshot.e2e.test.js new file mode 100644 index 0000000..27f3145 --- /dev/null +++ b/tests/e2e/share-snapshot.e2e.test.js @@ -0,0 +1,1614 @@ +import { readFile } from 'node:fs/promises'; +import { test, expect } from '@playwright/test'; +import { launchExtension, openMultiPanel } from './extension-harness.js'; +import { renderChatgptFixture } from './fixtures/chatgpt-fixture.js'; +import { CONVERSATION_COLORS, renderConversationFixture } from './fixtures/conversation-fixture.js'; +import { + SNAPSHOT_FRAME_STYLES, + SNAPSHOT_MAX_OUTPUT_HEIGHT, + getSnapshotFrameLayout, +} from '../../modules/share-snapshot.js'; + +// Chrome normally asks the user to confirm sharing the tab; this switch +// answers that dialog so the real getDisplayMedia path runs unattended. +const ACCEPT_TAB_CAPTURE = '--auto-accept-this-tab-capture'; +// Lets the test click the toolbar icon through CDP (Extensions.triggerAction), +// which grants activeTab like a real click. +const UNSAFE_EXTENSION_DEBUGGING = '--enable-unsafe-extension-debugging'; + +// Solid provider background, so pixels from the cross-origin iframe are +// recognizable in the composed image. +const PROVIDER_BACKGROUND = [0, 160, 80]; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const VISIBLE_TAB_FLAG = 'panelize.snapshotVisibleTabCaptured'; + +// Distinct answer block colors, far from the header, composer and page. +const BLOCK_COLORS = [ + [255, 200, 0], [0, 200, 200], [200, 0, 200], [120, 60, 0], + [0, 120, 0], [255, 140, 140], [100, 100, 255], [180, 255, 100], +]; +const CONVERSATION_PROMPT = 'Explain how HTTPS works in detail'; +// Column inside the user bubble padding and the answer blocks of the fixture. +const FIXTURE_COLUMN_X = 30; + +// Chrome refuses permission grants for chrome-extension:// origins, so the +// system clipboard is read back from an ordinary page instead. +const CLIPBOARD_CHECK_ORIGIN = 'https://clipboard-check.test'; + +async function readClipboardImage(context) { + await context.route(`${CLIPBOARD_CHECK_ORIGIN}/**`, route => route.fulfill({ + contentType: 'text/html', body: 'clipboard check', + })); + await context.grantPermissions(['clipboard-read'], { origin: CLIPBOARD_CHECK_ORIGIN }); + const page = await context.newPage(); + try { + await page.goto(`${CLIPBOARD_CHECK_ORIGIN}/`); + await page.bringToFront(); + return await page.evaluate(async () => { + const [item] = await navigator.clipboard.read(); + const bitmap = await createImageBitmap(await item.getType('image/png')); + return { types: item.types, width: bitmap.width, height: bitmap.height }; + }); + } finally { + await page.close(); + } +} + +async function openChatgptPanel(extension) { + const fixture = renderChatgptFixture({ composer: 'current' }).replace( + '', + `` + ); + await extension.context.route('https://chatgpt.com/**', route => route.fulfill({ + contentType: 'text/html', body: fixture, + })); + const page = await openMultiPanel(extension, { providers: ['chatgpt'] }); + await expect(page.frameLocator('iframe') + .locator('[data-composer-placement="thread"] [data-composer-markdown]')).toBeVisible(); + return page; +} + +const PROVIDER_ORIGINS = { + chatgpt: 'https://chatgpt.com', + deepseek: 'https://chat.deepseek.com', + gemini: 'https://gemini.google.com', + grok: 'https://grok.com', + kimi: 'https://www.kimi.com', +}; + +/** + * Opens panels showing long conversations. + * @param {object} extension + * @param {({provider: string} & object)[]} conversations - Provider and + * renderConversationFixture options. + * @param {string} layout + */ +async function openConversationPanels(extension, conversations, layout = '1x1') { + for (const { provider, ...fixture } of conversations) { + const body = renderConversationFixture({ prompt: CONVERSATION_PROMPT, ...fixture }); + await extension.context.route(`${PROVIDER_ORIGINS[provider]}/**`, route => route.fulfill({ + contentType: 'text/html', body, + })); + } + const page = await openMultiPanel(extension, { + providers: conversations.map(({ provider }) => provider), + layout, + }); + for (let index = 0; index < conversations.length; index += 1) { + await expect(page.frameLocator('#panel-grid iframe').nth(index).locator('.user')).toBeVisible(); + } + // The prompt the snapshot aligns to, as if it was about to be sent again. + await page.locator('#unified-input').fill(CONVERSATION_PROMPT); + return page; +} + +function getThreadScrollTops(page) { + return Promise.all(page.frames() + .filter(frame => frame.url().startsWith('https://')) + .map(frame => frame.evaluate(() => { + const thread = document.getElementById('thread'); + return thread ? { top: thread.scrollTop, max: thread.scrollHeight - thread.clientHeight } : null; + }))); +} + +function scrollThreadsToBottom(page) { + return Promise.all(page.frames() + .filter(frame => frame.url().startsWith('https://')) + .map(frame => frame.evaluate(() => { + const thread = document.getElementById('thread'); + if (thread) thread.scrollTop = thread.scrollHeight; + }))); +} + +/** Scroll positions once the layout stops moving, e.g. after the input grew. */ +async function getSettledThreadScrollTops(page) { + let last = null; + await expect.poll(async () => { + const now = JSON.stringify(await getThreadScrollTops(page)); + const settled = now === last; + last = now; + return settled; + }, { intervals: [250] }).toBe(true); + return JSON.parse(last); +} + +async function sendAll(page, text) { + await page.locator('#unified-input').fill(text); + await page.locator('#send-all-btn').click(); + await expect(page.locator('#send-status')).toHaveText(/Sent to 1 AI/); +} + +/** Wraps getDisplayMedia so tests can tell which capture path ran. */ +function countDisplayMedia(page) { + return page.evaluate(() => { + const original = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); + window.__displayMediaCalls = 0; + navigator.mediaDevices.getDisplayMedia = (...args) => { + window.__displayMediaCalls += 1; + return original(...args); + }; + }); +} + +const getDisplayMediaCalls = page => page.evaluate(() => window.__displayMediaCalls); + +/** Clicks the Panelize toolbar icon on `page`, as the user would. */ +async function clickToolbarIcon(extension, page) { + const session = await extension.context.browser().newBrowserCDPSession(); + try { + const url = page.url(); + const { targetInfos } = await session.send('Target.getTargets', { filter: [{ type: 'tab' }] }); + const tab = targetInfos.find(info => info.url === url); + expect(tab, `tab target for ${url}`).toBeTruthy(); + await session.send('Extensions.triggerAction', { id: extension.extensionId, targetId: tab.targetId }); + } finally { + await session.detach(); + } +} + +/** Size of the preview and where the grid sits in it. */ +function inspectPreview(page) { + return page.evaluate(async () => { + const image = document.getElementById('share-preview'); + await image.decode(); + const grid = document.getElementById('panel-grid').getBoundingClientRect(); + const frames = Array.from(document.querySelectorAll('#panel-grid iframe'), frame => { + const rect = frame.getBoundingClientRect(); + return { left: rect.left - grid.left, top: rect.top - grid.top, width: rect.width, height: rect.height }; + }); + return { + src: image.src, + width: image.naturalWidth, + height: image.naturalHeight, + grid: { width: grid.width, height: grid.height }, + frames, + }; + }); +} + +/** Output scale of the preview: its width is the grid plus the frame padding. */ +function getPreviewScale(preview, theme = 'light') { + const padding = SNAPSHOT_FRAME_STYLES[theme].paddingX; + return preview.width / (preview.grid.width + padding * 2); +} + +function readPreviewPixels(page, points) { + return page.evaluate(async (points) => { + const image = document.getElementById('share-preview'); + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0); + return points.map(({ x, y }) => Array.from(ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data.slice(0, 3))); + }, points); +} + +/** RGB values of one column of the preview, top to bottom. */ +function readPreviewColumn(page, x) { + return page.evaluate(async (x) => { + const image = document.getElementById('share-preview'); + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0); + const data = ctx.getImageData(Math.round(x), 0, 1, image.naturalHeight).data; + const column = []; + for (let index = 0; index < data.length; index += 4) { + column.push([data[index], data[index + 1], data[index + 2]]); + } + return column; + }, x); +} + +const isNear = (actual, expected, tolerance) => actual.every((channel, index) => Math.abs(channel - expected[index]) <= tolerance); + +/** + * Splits a column into runs of named colors; anything else is 'other'. + * @param {number[][]} column + * @param {Record} palette + */ +function getColorRuns(column, palette, tolerance = 24) { + const entries = Object.entries(palette); + const runs = []; + column.forEach((pixel, y) => { + const name = entries.find(([, color]) => isNear(pixel, color, tolerance))?.[0] || 'other'; + const last = runs[runs.length - 1]; + if (last?.name === name) { + last.length += 1; + } else { + runs.push({ name, start: y, length: 1 }); + } + }); + return runs; +} + +const CONVERSATION_PALETTE = { + page: CONVERSATION_COLORS.page, + header: CONVERSATION_COLORS.header, + composer: CONVERSATION_COLORS.composer, + earlier: CONVERSATION_COLORS.earlier, + user: CONVERSATION_COLORS.user, + floating: CONVERSATION_COLORS.floating, + notice: CONVERSATION_COLORS.notice, + heading: CONVERSATION_COLORS.heading, + tableHeader: CONVERSATION_COLORS.tableHeader, + tableRow: CONVERSATION_COLORS.tableRow, + fade: CONVERSATION_COLORS.fade, + ...Object.fromEntries(BLOCK_COLORS.map((color, index) => [`block${index}`, color])), +}; + +/** + * Color runs down the fixture column of panel `index`, inside the card. + * @param {object} frame - getSnapshotFrameLayout options of the preview, + * without sizes. + */ +async function readConversationRuns(page, preview, index, frame = {}) { + const scale = getPreviewScale(preview); + const empty = getSnapshotFrameLayout({ + contentWidth: preview.width - Math.round(SNAPSHOT_FRAME_STYLES.light.paddingX * scale) * 2, + contentHeight: 0, + scale, + titleLineCount: 1, + hasSubline: true, + ...frame, + }); + const top = empty.content.y; + const bottom = preview.height - (empty.height - empty.content.y); + const x = empty.content.x + (preview.frames[index].left + FIXTURE_COLUMN_X) * scale; + const column = (await readPreviewColumn(page, x)).slice(top, bottom); + // Runs shorter than a few pixels are antialiased edges. + return getColorRuns(column, CONVERSATION_PALETTE).filter(run => run.length > 3 * scale); +} + +const hexToRgb = (hex) => [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16)); + +function expectColorNear(actual, expected, tolerance = 12) { + actual.forEach((channel, index) => { + expect(Math.abs(channel - expected[index]), `channel ${index} of ${actual} vs ${expected}`) + .toBeLessThanOrEqual(tolerance); + }); +} + +/** + * Preview height for polling. Every render replaces the preview, and decoding + * an image whose source was just replaced fails; the next poll reads the new one. + */ +function pollPreviewHeight(page) { + return inspectPreview(page).then(preview => preview.height, () => null); +} + +async function waitForNewPreview(page, previousSrc) { + await expect.poll(() => page.locator('#share-preview').getAttribute('src')).not.toBe(previousSrc); + return inspectPreview(page); +} + +async function getExpectedHint(extension) { + const shortcut = await extension.serviceWorker.evaluate(async () => ( + (await chrome.commands.getAll()).find(command => command.name === '_execute_action')?.shortcut || '' + )); + return shortcut + ? `Tip: press ${shortcut} on this page to skip Chrome's confirmation` + : 'Tip: click the Panelize toolbar icon on this page to skip Chrome\'s confirmation'; +} + +test('Share falls back to tab sharing with a shortcut hint, then copies and downloads the framed PNG', async ({}, testInfo) => { + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [ACCEPT_TAB_CAPTURE], + }); + try { + const page = await openChatgptPanel(extension); + const consoleErrors = []; + page.on('console', message => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + await countDisplayMedia(page); + + await sendAll(page, 'Which answer explains snapshots best?'); + await page.locator('#share-snapshot-btn').click(); + + const modal = page.locator('#share-modal'); + await expect(modal).toBeVisible(); + await expect(modal).toContainText('it may show chat history in sidebars, account names or avatars'); + // Without an activeTab grant the page asked Chrome to share the tab. + expect(await getDisplayMediaCalls(page)).toBe(1); + await expect(page.locator('#share-capture-hint-text')).toHaveText(await getExpectedHint(extension)); + await expect(page.locator('#share-capture-hint')).toBeVisible(); + expect(await page.evaluate(flag => sessionStorage.getItem(flag), VISIBLE_TAB_FLAG)).toBeNull(); + // Full answers is the default; the shared tab is kept for the next snapshot. + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#share-stream-status')).toBeVisible(); + await expect(page.locator('#share-stream-status')).toContainText('Sharing this tab for snapshots'); + await expect(page.locator('#share-stream-stop')).toHaveText('Stop'); + await expect(page.locator('#share-title-input')).toHaveValue('Which answer explains snapshots best?'); + await expect(page.locator('#share-snapshot-btn')).toBeEnabled(); + await page.screenshot({ path: testInfo.outputPath('share-modal.png') }); + // The pointer is shown again once the capture is done. + expect(await page.evaluate(() => document.documentElement.classList.contains('snapshot-hide-cursor'))).toBe(false); + + // The current view comes from the same shared tab, without asking again. + await page.locator('.share-mode-btn[data-mode="current"]').click(); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + expect(await getDisplayMediaCalls(page)).toBe(1); + + // Layout A: brand row with the site link pill, "PROMPT" label, one + // headline line, the providers and date subline, the capture. + const preview = await inspectPreview(page); + const scale = getPreviewScale(preview); + const layout = getSnapshotFrameLayout({ + contentWidth: preview.width - Math.round(SNAPSHOT_FRAME_STYLES.light.paddingX * scale) * 2, + contentHeight: Math.round(preview.grid.height * scale), + scale, + theme: 'light', + titleLineCount: 1, + showWatermark: true, + hasSubline: true, + }); + expect(Math.abs(preview.height - layout.height)).toBeLessThanOrEqual(2); + const [corner, pill, providerPixel] = await readPreviewPixels(page, [ + { x: 2, y: 2 }, + { x: layout.brand.right - layout.brand.pillPaddingX / 2, y: layout.brand.centerY }, + { + x: layout.content.x + (preview.frames[0].left + preview.frames[0].width / 2) * scale, + y: layout.content.y + (preview.frames[0].top + preview.frames[0].height / 2) * scale, + }, + ]); + expectColorNear(corner, hexToRgb(SNAPSHOT_FRAME_STYLES.light.background)); + expectColorNear(pill, hexToRgb(SNAPSHOT_FRAME_STYLES.light.pillBackground)); + // Pixels from the cross-origin provider iframe made it into the image. + expectColorNear(providerPixel, PROVIDER_BACKGROUND, 24); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#share-download-btn').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^panelize-comparison-\d{8}-\d{6}\.png$/); + const downloadPath = testInfo.outputPath(download.suggestedFilename()); + await download.saveAs(downloadPath); + const png = await readFile(downloadPath); + expect(Array.from(png.subarray(0, 8))).toEqual(PNG_SIGNATURE); + // PNG IHDR stores width and height as big-endian integers at bytes 16-23. + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([preview.width, preview.height]); + + await page.locator('#share-copy-btn').click(); + await expect(page.getByText('Image copied to clipboard')).toBeVisible(); + expect(await readClipboardImage(extension.context)) + .toEqual({ types: ['image/png'], width: preview.width, height: preview.height }); + + await page.bringToFront(); + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + await expect(page.locator('#share-preview')).not.toHaveAttribute('src'); + expect(consoleErrors).toEqual([]); + } finally { + await extension.close(); + } +}); + +test('editing the title re-renders the headline, and an empty title removes it', async () => { + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [ACCEPT_TAB_CAPTURE], + }); + try { + const page = await openChatgptPanel(extension); + // Right after sending, the page keeps pulling focus back to the prompt + // box; typing a title must not be interrupted by that. + await sendAll(page, 'Short prompt'); + await page.locator('#share-snapshot-btn').click(); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('#share-copy-btn')).toBeFocused(); + const title = page.locator('#share-title-input'); + await expect(title).toHaveValue('Short prompt'); + + const oneLine = await inspectPreview(page); + const scale = getPreviewScale(oneLine); + const layoutFor = (titleLineCount) => getSnapshotFrameLayout({ + contentWidth: oneLine.width - Math.round(SNAPSHOT_FRAME_STYLES.light.paddingX * scale) * 2, + contentHeight: Math.round(oneLine.grid.height * scale), + scale, + titleLineCount, + hasSubline: true, + }).height; + + await title.fill(''); + const noTitle = await waitForNewPreview(page, oneLine.src); + expect(noTitle.width).toBe(oneLine.width); + expect(oneLine.height - noTitle.height).toBe(layoutFor(1) - layoutFor(0)); + + await title.click(); + await page.keyboard.type('A much longer headline that keeps going '.repeat(8).trim(), { delay: 5 }); + await expect(title).toBeFocused(); + await expect(title).toHaveValue('A much longer headline that keeps going '.repeat(8).trim()); + const longTitle = await waitForNewPreview(page, noTitle.src); + // Keystrokes are debounced into one render after typing stops. + await expect.poll(() => pollPreviewHeight(page)).toBe(oneLine.height + layoutFor(3) - layoutFor(1)); + expect(longTitle.width).toBe(oneLine.width); + await expect(page.locator('#unified-input')).toHaveValue(''); + } finally { + await extension.close(); + } +}); + +test('downloading or copying right after editing the title exports the edited title', async ({}, testInfo) => { + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [ACCEPT_TAB_CAPTURE], + }); + try { + const page = await openChatgptPanel(extension); + await sendAll(page, 'Private title to remove before sharing'); + await page.locator('#share-snapshot-btn').click(); + await expect(page.locator('#share-modal')).toBeVisible(); + const title = page.locator('#share-title-input'); + await expect(title).toHaveValue('Private title to remove before sharing'); + + const oneLine = await inspectPreview(page); + const scale = getPreviewScale(oneLine); + const layoutFor = (titleLineCount) => getSnapshotFrameLayout({ + contentWidth: oneLine.width - Math.round(SNAPSHOT_FRAME_STYLES.light.paddingX * scale) * 2, + contentHeight: Math.round(oneLine.grid.height * scale), + scale, + titleLineCount, + hasSubline: true, + }).height; + const pngSize = (png) => [png.readUInt32BE(16), png.readUInt32BE(20)]; + + // Cleared, then exported before the preview had time to catch up. + await title.fill(''); + const downloadPromise = page.waitForEvent('download'); + await page.locator('#share-download-btn').click(); + const download = await downloadPromise; + const downloadPath = testInfo.outputPath(download.suggestedFilename()); + await download.saveAs(downloadPath); + expect(pngSize(await readFile(downloadPath))) + .toEqual([oneLine.width, oneLine.height - layoutFor(1) + layoutFor(0)]); + + await title.fill('A new headline'); + await page.locator('#share-copy-btn').click(); + await expect(page.getByText('Image copied to clipboard')).toBeVisible(); + expect(await readClipboardImage(extension.context)) + .toEqual({ types: ['image/png'], width: oneLine.width, height: oneLine.height }); + } finally { + await extension.close(); + } +}); + +test('the options toggle removes the brand row and the dark theme uses layout B', async ({}, testInfo) => { + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [ACCEPT_TAB_CAPTURE], + }); + try { + const options = await extension.context.newPage(); + await options.goto(extension.extensionUrl('options/options.html')); + // Settings listeners are attached right after the provider list renders. + await expect(options.locator('.provider-item').first()).toBeVisible(); + const toggle = options.locator('#share-watermark-toggle'); + await expect(toggle).toBeChecked(); + await options.locator('#share-watermark-toggle + .slider').click(); + await expect(toggle).not.toBeChecked(); + await expect(options.locator('#status-success')) + .toContainText('Watermark on shared images disabled'); + await toggle.scrollIntoViewIfNeeded(); + await options.screenshot({ path: testInfo.outputPath('options-share-watermark.png') }); + await expect.poll(() => extension.serviceWorker.evaluate( + async () => (await chrome.storage.sync.get('shareWatermarkEnabled')).shareWatermarkEnabled + )).toBe(false); + await options.close(); + + await extension.serviceWorker.evaluate(() => chrome.storage.sync.set({ theme: 'dark' })); + const page = await openChatgptPanel(extension); + await sendAll(page, 'Dark snapshot'); + await page.locator('#share-snapshot-btn').click(); + await expect(page.locator('#share-modal')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('share-modal-dark.png') }); + + // Layout B without the brand row: quoted headline, provider subline, + // window card. + const preview = await inspectPreview(page); + const scale = getPreviewScale(preview, 'dark'); + const layoutOptions = { + contentWidth: preview.width - Math.round(SNAPSHOT_FRAME_STYLES.dark.paddingX * scale) * 2, + contentHeight: Math.round(preview.grid.height * scale), + scale, + theme: 'dark', + titleLineCount: 1, + hasSubline: true, + }; + const layout = getSnapshotFrameLayout({ ...layoutOptions, showWatermark: false }); + expect(Math.abs(preview.height - layout.height)).toBeLessThanOrEqual(2); + expect(layout.height).toBeLessThan(getSnapshotFrameLayout({ ...layoutOptions, showWatermark: true }).height); + const [titleBar, providerPixel] = await readPreviewPixels(page, [ + { x: layout.card.x + layout.card.width / 2, y: layout.card.y + layout.card.titleBarHeight / 2 }, + { x: layout.content.x + preview.frames[0].width * scale / 2, y: layout.content.y + preview.frames[0].height * scale / 2 }, + ]); + expectColorNear(titleBar, hexToRgb(SNAPSHOT_FRAME_STYLES.dark.titleBarColor)); + expectColorNear(providerPixel, PROVIDER_BACKGROUND, 24); + } finally { + await extension.close(); + } +}); + +test('the toolbar icon on the Panelize tab captures silently and the hint never shows', async ({}, testInfo) => { + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const page = await openConversationPanels(extension, [{ provider: 'chatgpt', blocks: BLOCK_COLORS.slice(0, 3) }]); + const consoleErrors = []; + page.on('console', message => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + await countDisplayMedia(page); + const [before] = await getThreadScrollTops(page); + expect(before.top).toBe(before.max); + const pagesBefore = extension.context.pages().length; + + await clickToolbarIcon(extension, page); + await expect(page.locator('#share-modal')).toBeVisible(); + // A snapshot of this page, not a new Panelize tab. + expect(extension.context.pages().length).toBe(pagesBefore); + expect(await getDisplayMediaCalls(page)).toBe(0); + await expect(page.locator('#share-capture-hint')).toBeHidden(); + expect(await page.evaluate(flag => sessionStorage.getItem(flag), VISIBLE_TAB_FLAG)).toBe('1'); + await page.screenshot({ path: testInfo.outputPath('share-modal-visible-tab.png') }); + // Full answers is the default. + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#share-stream-status')).toBeHidden(); + + await page.locator('.share-mode-btn[data-mode="current"]').click(); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + expect(await getDisplayMediaCalls(page)).toBe(0); + + // Current view: the user message sits right under the provider header. + const preview = await inspectPreview(page); + const scale = getPreviewScale(preview); + const runs = await readConversationRuns(page, preview, 0); + const names = runs.map(run => run.name); + expect(names).not.toContain('earlier'); + const header = names.indexOf('header'); + expect(names.slice(header, header + 3)).toEqual(['header', 'page', 'user']); + expect(Math.abs(runs[header + 1].length - 16 * scale)).toBeLessThanOrEqual(3 * scale); + // What is on screen with the composer, but without the floating button. + expect(names).toContain('composer'); + expect(names).not.toContain('floating'); + // The conversation is scrolled back once the capture is done, and the + // button shows again. + expect(await getThreadScrollTops(page)).toEqual([before]); + expect(await page.frames().find(frame => frame.url().startsWith('https://')).evaluate(() => ( + document.getElementById('scroll-down').style.visibility + ))).toBe(''); + + // The grant lasts for this page: the Share button stays silent too, and + // opens in the mode picked last. + await page.keyboard.press('Escape'); + await expect(page.locator('#share-modal')).toBeHidden(); + await page.locator('#share-snapshot-btn').click(); + await expect(page.locator('#share-modal')).toBeVisible(); + expect(await getDisplayMediaCalls(page)).toBe(0); + await expect(page.locator('#share-capture-hint')).toBeHidden(); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toHaveAttribute('aria-checked', 'true'); + expect(await page.evaluate(() => chrome.storage.local.get('shareSnapshotMode'))) + .toEqual({ shareSnapshotMode: 'current' }); + expect(consoleErrors).toEqual([]); + } finally { + await extension.close(); + } +}); + +test('Full answers stitches every panel in lock-step, without headers, composer or repeats', async ({}, testInfo) => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: BLOCK_COLORS.slice(0, 6), notice: true }, + { provider: 'deepseek', blocks: BLOCK_COLORS.slice(6, 8) }, + ], '1x2'); + const consoleErrors = []; + page.on('console', message => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + const before = await getThreadScrollTops(page); + + // Full answers is the default. + await clickToolbarIcon(extension, page); + await expect(page.locator('#snapshot-progress')).toBeVisible(); + await expect(page.locator('#snapshot-progress-text')).toHaveText(/Capturing screen \d+ of \d+/); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toBeDisabled(); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#share-capture-notes')).toBeHidden(); + const full = await inspectPreview(page); + expect(full.height).toBeLessThanOrEqual(SNAPSHOT_MAX_OUTPUT_HEIGHT); + await page.screenshot({ path: testInfo.outputPath('share-modal-full.png') }); + await page.locator('#share-preview').screenshot({ path: testInfo.outputPath('share-preview-full.png') }); + + const scale = getPreviewScale(full); + const blockHeight = CONVERSATION_COLORS.blockHeight * scale; + for (const [index, blocks] of [[0, BLOCK_COLORS.slice(0, 6)], [1, BLOCK_COLORS.slice(6, 8)]]) { + const runs = await readConversationRuns(page, full, index, { hasNote: false }); + const names = runs.map(run => run.name); + // The provider header shows once, at the top; the composer never. + expect(names.filter(name => name === 'header'), `panel ${index}`).toHaveLength(1); + expect(names, `panel ${index}`).not.toContain('composer'); + expect(names, `panel ${index}`).not.toContain('earlier'); + // Floating buttons and notices would repeat on every screen; they are left out. + expect(names, `panel ${index}`).not.toContain('floating'); + expect(names, `panel ${index}`).not.toContain('notice'); + const blockRuns = runs.filter(run => run.name.startsWith('block')); + expect(blockRuns.map(run => run.name), `panel ${index}`) + .toEqual(blocks.map(color => `block${BLOCK_COLORS.indexOf(color)}`)); + // No overlap between screens: every block keeps its height. + blockRuns.forEach(run => { + expect(Math.abs(run.length - blockHeight), `${run.name} of panel ${index}`).toBeLessThanOrEqual(3 * scale); + }); + if (index === 1) { + // The shorter answer is filled to the row height with its background. + const last = blockRuns[blockRuns.length - 1]; + const fill = runs[runs.indexOf(last) + 1]; + expect(fill.name).toBe('page'); + expect(fill.length).toBeGreaterThan(3 * blockHeight); + } + } + // Panels are back where they were, floating buttons and notices included. + expect(await getThreadScrollTops(page)).toEqual(before); + for (let index = 0; index < 2; index += 1) { + await expect(page.frameLocator('#panel-grid iframe').nth(index).locator('#scroll-down')).toBeVisible(); + } + await expect(page.frameLocator('#panel-grid iframe').first().locator('.notice')).toBeVisible(); + + // The current view leaves floating buttons and notices out too. + await page.locator('.share-mode-btn[data-mode="current"]').click(); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + const current = await waitForNewPreview(page, full.src); + expect(full.width).toBe(current.width); + expect(full.height).toBeGreaterThan(current.height + 1000); + for (const index of [0, 1]) { + const names = (await readConversationRuns(page, current, index)).map(run => run.name); + expect(names, `panel ${index}`).toContain('composer'); + expect(names, `panel ${index}`).not.toContain('floating'); + expect(names, `panel ${index}`).not.toContain('notice'); + } + await expect(page.frameLocator('#panel-grid iframe').first().locator('.notice')).toBeVisible(); + + // Full answers is kept; switching back does not capture again. + await page.locator('.share-mode-btn[data-mode="full"]').click(); + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + await expect.poll(() => pollPreviewHeight(page)).toBe(full.height); + expect(consoleErrors).toEqual([]); + } finally { + await extension.close(); + } +}); + +test('Full answers of a page scaled to fit its panel are captured as shown', async ({}, testInfo) => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const columns = [BLOCK_COLORS.slice(0, 2), BLOCK_COLORS.slice(2, 6)]; + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: columns[0] }, + { provider: 'deepseek', blocks: columns[1], minWidth: 1000 }, + ], '1x2'); + const fitted = page.locator('#panel-grid iframe').nth(1); + await expect(fitted).toHaveClass(/panel-fitted/); + const zoom = await fitted.evaluate(frame => frame.getBoundingClientRect().width / 1000); + expect(zoom).toBeLessThan(0.7); + const fittedFrame = page.frames().find(frame => frame.url().startsWith(PROVIDER_ORIGINS.deepseek)); + // The cross-process frame takes its new viewport a moment after the iframe resizes. + await expect.poll(() => fittedFrame.evaluate(() => window.innerWidth)).toBe(1000); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#share-modal')).toBeVisible(); + const full = await showFullAnswers(page); + await page.locator('#share-preview').screenshot({ path: testInfo.outputPath('share-preview-fitted.png') }); + const scale = getPreviewScale(full); + for (const [index, blocks] of columns.entries()) { + const runs = await readConversationRuns(page, full, index, { hasNote: false }); + const names = runs.map(run => run.name); + expect(names.filter(name => name === 'header'), `panel ${index}`).toHaveLength(1); + expect(names, `panel ${index}`).not.toContain('composer'); + const blockRuns = runs.filter(run => run.name.startsWith('block')); + expect(blockRuns.map(run => run.name), `panel ${index}`) + .toEqual(blocks.map(color => `block${BLOCK_COLORS.indexOf(color)}`)); + // The scaled page keeps its proportions across stitched screens. + const blockHeight = CONVERSATION_COLORS.blockHeight * scale * (index === 1 ? zoom : 1); + blockRuns.forEach(run => { + expect(Math.abs(run.length - blockHeight), `${run.name} of panel ${index}`).toBeLessThanOrEqual(3 * scale); + }); + } + } finally { + await extension.close(); + } +}); + +/** Switches the open dialog to Full answers, unless it shows it already. */ +async function showFullAnswers(page) { + const full = page.locator('.share-mode-btn[data-mode="full"]'); + if (await full.getAttribute('aria-checked') !== 'true') { + await full.click(); + } + await expect(full).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + return inspectPreview(page); +} + +test('Full answers starts over when the window changes size mid-capture', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const columns = [BLOCK_COLORS.slice(0, 6), BLOCK_COLORS.slice(6, 8)]; + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: columns[0] }, + { provider: 'deepseek', blocks: columns[1] }, + ], '1x2'); + const restarts = []; + page.on('console', message => { + if (message.text().includes('Layout changed during the snapshot')) restarts.push(message.text()); + }); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#snapshot-progress-text')).toHaveText(/Capturing screen [2-9]\d* of \d+/); + // A tiling window manager re-tiles the window: every panel moves. + await page.setViewportSize({ width: 1000, height: 800 }); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + expect(restarts.length).toBeGreaterThan(0); + + // The image is the new layout, with nothing cropped from the old one. + const full = await inspectPreview(page); + const scale = getPreviewScale(full); + for (const [index, blocks] of columns.entries()) { + const runs = await readConversationRuns(page, full, index, { hasNote: false }); + const names = runs.map(run => run.name); + expect(names.filter(name => name === 'header'), `panel ${index}`).toHaveLength(1); + const blockRuns = runs.filter(run => run.name.startsWith('block')); + expect(blockRuns.map(run => run.name), `panel ${index}`) + .toEqual(blocks.map(color => `block${BLOCK_COLORS.indexOf(color)}`)); + blockRuns.forEach(run => { + expect(Math.abs(run.length - CONVERSATION_COLORS.blockHeight * scale), `${run.name} of panel ${index}`) + .toBeLessThanOrEqual(3 * scale); + }); + } + } finally { + await extension.close(); + } +}); + +test('Full answers un-sticks headings and table headers and leaves fades out of every screen', async ({}, testInfo) => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const blocks = BLOCK_COLORS.slice(0, 5); + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks, stickyHeading: true, wideTable: true, fade: true }, + ]); + const before = await getThreadScrollTops(page); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#share-modal')).toBeVisible(); + const full = await showFullAnswers(page); + await page.locator('#share-preview').screenshot({ path: testInfo.outputPath('share-preview-sticky-fade.png') }); + + const scale = getPreviewScale(full); + const runs = await readConversationRuns(page, full, 0, { hasNote: false }); + const names = runs.map(run => run.name); + const expectHeight = (name, height) => { + const matching = runs.filter(run => run.name === name); + expect(matching, name).toHaveLength(1); + expect(Math.abs(matching[0].length - height * scale), name).toBeLessThanOrEqual(3 * scale); + }; + // Each part once, at its place in the conversation, at its own height. + expect(names.filter(name => name !== 'page' && name !== 'other')).toEqual([ + 'header', 'user', 'block0', 'heading', 'block1', 'tableHeader', 'tableRow', 'block2', 'block3', 'block4', + ]); + expectHeight('heading', CONVERSATION_COLORS.headingHeight); + expectHeight('tableHeader', CONVERSATION_COLORS.tableHeaderHeight); + expectHeight('tableRow', CONVERSATION_COLORS.tableRowHeight * CONVERSATION_COLORS.tableRows); + // Neither the heading's shadow nor the composer's fade covers a block. + runs.filter(run => run.name.startsWith('block')).forEach(run => { + expect(Math.abs(run.length - CONVERSATION_COLORS.blockHeight * scale), run.name).toBeLessThanOrEqual(3 * scale); + }); + expect(names).not.toContain('fade'); + + // Everything is back as it was. + expect(await getThreadScrollTops(page)).toEqual(before); + const styles = await page.frames().find(frame => frame.url().startsWith('https://chatgpt.com')).evaluate(() => ({ + heading: getComputedStyle(document.querySelector('.sticky-heading')).position, + th: getComputedStyle(document.querySelector('th')).position, + fade: getComputedStyle(document.querySelector('form'), '::before').visibility, + marked: document.querySelectorAll('[data-panelize-snapshot-hide]').length, + })); + expect(styles).toEqual({ heading: 'sticky', th: 'sticky', fade: 'visible', marked: 0 }); + } finally { + await extension.close(); + } +}); + +test('Full answers starts at the latest user message, or at the top, when the prompt is not found', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: BLOCK_COLORS.slice(0, 2), userRole: true }, + { provider: 'deepseek', blocks: BLOCK_COLORS.slice(2, 4) }, + ], '1x2'); + // A prompt neither page shows, e.g. one typed into a provider directly. + await page.locator('#unified-input').fill('A prompt that was never sent'); + // At the bottom, as chat apps are: the fixture scrolls there while loading, + // and the panels may change height after that. + await getSettledThreadScrollTops(page); + await scrollThreadsToBottom(page); + const before = await getThreadScrollTops(page); + expect(before, 'scrolled to the bottom').toEqual(before.map(({ max }) => ({ top: max, max }))); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#share-modal')).toBeVisible(); + const full = await showFullAnswers(page); + + const contentNames = async (index) => (await readConversationRuns(page, full, index, { hasNote: false })) + .map(run => run.name) + .filter(name => name !== 'page' && name !== 'other'); + // ChatGPT marks the user message: it starts there. + expect(await contentNames(0)).toEqual(['header', 'user', 'block0', 'block1']); + // Nothing to go by: it starts at the top, not where it was scrolled. + expect(await contentNames(1)).toEqual(['header', 'earlier', 'user', 'block2', 'block3']); + expect(await getThreadScrollTops(page)).toEqual(before); + } finally { + await extension.close(); + } +}); + +test('Full answers keeps a panel whose page does not answer as on screen and says so', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + // DeepSeek fails to load: Chrome's error page does not answer. + await extension.context.route(`${PROVIDER_ORIGINS.deepseek}/**`, route => route.abort('connectionrefused')); + const body = renderConversationFixture({ prompt: CONVERSATION_PROMPT, blocks: BLOCK_COLORS.slice(0, 2) }); + await extension.context.route(`${PROVIDER_ORIGINS.chatgpt}/**`, route => route.fulfill({ + contentType: 'text/html', body, + })); + const page = await openMultiPanel(extension, { providers: ['chatgpt', 'deepseek'], layout: '1x2' }); + await expect(page.frameLocator('#panel-grid iframe').nth(0).locator('.user')).toBeVisible(); + await expect.poll(() => page.frames().some(frame => frame.url().startsWith('chrome-error://'))).toBe(true); + await page.locator('#unified-input').fill(CONVERSATION_PROMPT); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#share-capture-notes')).toBeVisible(); + await expect(page.locator('#share-capture-notes')).toHaveText('Current view only: DeepSeek'); + } finally { + await extension.close(); + } +}); + +/** Scrolls the grid sideways and waits until it settles. */ +async function scrollGridTo(page, left) { + await page.evaluate((left) => { + document.getElementById('panel-grid').scrollTo({ left, behavior: 'instant' }); + }, left); + let last = null; + await expect.poll(async () => { + const now = await page.evaluate(() => document.getElementById('panel-grid').scrollLeft); + const settled = now === last; + last = now; + return settled; + }, { intervals: [250] }).toBe(true); +} + +/** + * Checks the preview holds the 400px panels of `blocksByColumn`, in order, + * with `paddingX` CSS pixels of grid on either side and nothing more. + */ +async function expectPreviewColumns(page, preview, blocksByColumn, paddingX) { + const scale = await page.evaluate(() => window.devicePixelRatio); + const frameX = Math.round(SNAPSHOT_FRAME_STYLES.light.paddingX * scale); + const columns = blocksByColumn.length; + const contentWidth = Math.round((columns * 400 + (columns - 1) * 4 + paddingX * 2) * scale); + expect(Math.abs(preview.width - (contentWidth + frameX * 2))).toBeLessThanOrEqual(2); + for (const [index, blocks] of blocksByColumn.entries()) { + const x = frameX + (paddingX + index * 404 + FIXTURE_COLUMN_X) * scale; + const runs = getColorRuns(await readPreviewColumn(page, x), CONVERSATION_PALETTE) + .filter(run => run.length > 3 * scale && run.name.startsWith('block')); + expect(runs.map(run => run.name), `column ${index}`) + .toEqual(blocks.map(color => `block${BLOCK_COLORS.indexOf(color)}`)); + } + if (paddingX > 0) { + const [left, right] = await readPreviewPixels(page, [ + { x: frameX + scale, y: preview.height / 2 }, + { x: frameX + contentWidth - 2 * scale, y: preview.height / 2 }, + ]); + expectColorNear(left, [224, 224, 224]); + expectColorNear(right, [224, 224, 224]); + } +} + +test('panels not fully on screen are left out of the snapshot and named in a note', async ({}, testInfo) => { + test.setTimeout(150_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + // Five 400px columns: three fit, the fourth shows a sliver. + const blocks = [0, 1, 2, 3, 4].map(index => BLOCK_COLORS.slice(index * 2 % 8, index * 2 % 8 + 2)); + const page = await openConversationPanels(extension, ['chatgpt', 'deepseek', 'gemini', 'grok', 'kimi'].map( + (provider, index) => ({ provider, blocks: blocks[index] }) + ), '1x5'); + const notes = page.locator('#share-capture-notes'); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#share-modal')).toBeVisible(); + const full = await showFullAnswers(page); + await expect(notes).toHaveText( + 'Not fully on screen, left out: Grok and Kimi — scroll sideways and share again to include them' + ); + await expectPreviewColumns(page, full, blocks.slice(0, 3), 0); + await page.locator('#share-preview').screenshot({ path: testInfo.outputPath('share-preview-start-full.png') }); + + await page.locator('.share-mode-btn[data-mode="current"]').click(); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + const current = await waitForNewPreview(page, full.src); + await expect(notes).toHaveText( + 'Not fully on screen, left out: Grok and Kimi — scroll sideways and share again to include them' + ); + await expectPreviewColumns(page, current, blocks.slice(0, 3), 0); + + // Scrolled to the end: what is fully on screen now. + await page.keyboard.press('Escape'); + await expect(page.locator('#share-modal')).toBeHidden(); + await scrollGridTo(page, 10_000); + await clickToolbarIcon(extension, page); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + const scrolled = await waitForNewPreview(page, current.src); + await expect(notes).toHaveText( + 'Not fully on screen, left out: ChatGPT and DeepSeek — scroll sideways and share again to include them' + ); + await expectPreviewColumns(page, scrolled, blocks.slice(2), 4); + await page.screenshot({ path: testInfo.outputPath('share-modal-end-current.png') }); + await page.locator('#share-preview').screenshot({ path: testInfo.outputPath('share-preview-end-current.png') }); + } finally { + await extension.close(); + } +}); + +test('a window narrower than one column captures the most visible panel', async ({}, testInfo) => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: BLOCK_COLORS.slice(0, 2) }, + { provider: 'deepseek', blocks: BLOCK_COLORS.slice(2, 4) }, + ], '1x2'); + await page.setViewportSize({ width: 360, height: 800 }); + await scrollGridTo(page, 0); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#share-modal')).toBeVisible(); + const full = await showFullAnswers(page); + await expect(page.locator('#share-capture-notes')).toHaveText( + 'Not fully on screen, left out: DeepSeek — scroll sideways and share again to include them' + ); + // The visible part of ChatGPT, up to the window edge. + const scale = await page.evaluate(() => window.devicePixelRatio); + const paddingX = Math.round(SNAPSHOT_FRAME_STYLES.light.paddingX * scale); + expect(Math.abs(full.width - (Math.round(360 * scale) + paddingX * 2))).toBeLessThanOrEqual(2); + await page.screenshot({ path: testInfo.outputPath('share-modal-narrow.png') }); + } finally { + await extension.close(); + } +}); + +test('Full answers over one tab-sharing stream truncates long answers with a note, and Cancel restores the panels', async ({}, testInfo) => { + test.setTimeout(150_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [ACCEPT_TAB_CAPTURE], + }); + try { + // About 9000 px of answer, more than fits under the height cap. + const blocks = Array.from({ length: 30 }, (_, index) => BLOCK_COLORS[index % BLOCK_COLORS.length]); + const page = await openConversationPanels(extension, [{ provider: 'chatgpt', blocks }]); + await countDisplayMedia(page); + const before = await getThreadScrollTops(page); + + await page.locator('#share-snapshot-btn').click(); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 90_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toHaveAttribute('aria-checked', 'true'); + // One shared stream for the whole run. + expect(await getDisplayMediaCalls(page)).toBe(1); + const notes = page.locator('#share-capture-notes'); + await expect(notes).toBeVisible(); + await expect(notes).toHaveText('Too long for one image, only the start is shown: ChatGPT'); + const full = await inspectPreview(page); + expect(full.height).toBeLessThanOrEqual(SNAPSHOT_MAX_OUTPUT_HEIGHT); + expect(full.height).toBeGreaterThan(SNAPSHOT_MAX_OUTPUT_HEIGHT - 400); + await page.locator('#share-preview').screenshot({ path: testInfo.outputPath('share-preview-truncated.png') }); + expect(await getThreadScrollTops(page)).toEqual(before); + + // The current view, then a second Share: Chrome asked only once. + await page.locator('.share-mode-btn[data-mode="current"]').click(); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + await page.keyboard.press('Escape'); + await expect(page.locator('#share-modal')).toBeHidden(); + await page.locator('#share-snapshot-btn').click(); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toHaveAttribute('aria-checked', 'true'); + expect(await getDisplayMediaCalls(page)).toBe(1); + + // Cancel a new run halfway: the dialog keeps the current view. + await page.locator('.share-mode-btn[data-mode="full"]').click(); + // The pointer rests on the dialog, over the panels, so the run first waits + // for it to keep still; poll often enough to catch the early screens. + await expect.poll(() => page.locator('#snapshot-progress-text').textContent(), { intervals: [50] }) + .toMatch(/Capturing screen [3-9] of/); + await page.locator('#snapshot-cancel-btn').click(); + await expect(page.getByText('Snapshot cancelled')).toBeVisible(); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('.share-mode-btn[data-mode="current"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toBeEnabled(); + await expect.poll(() => getThreadScrollTops(page)).toEqual(before); + } finally { + await extension.close(); + } +}); + +test('tab sharing shields the panels and waits for a moving pointer to keep still', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [ACCEPT_TAB_CAPTURE], + }); + try { + const blocks = Array.from({ length: 8 }, (_, index) => BLOCK_COLORS[index % BLOCK_COLORS.length]); + const page = await openConversationPanels(extension, [{ provider: 'chatgpt', blocks }]); + await countDisplayMedia(page); + // Every text the progress shows, however briefly. + await page.evaluate(() => { + window.__progressTexts = []; + const text = document.getElementById('snapshot-progress-text'); + new MutationObserver(() => window.__progressTexts.push(text.textContent)) + .observe(text, { childList: true, characterData: true, subtree: true }); + }); + const progressTexts = () => page.evaluate(() => window.__progressTexts.splice(0)); + const holdText = 'Keep the mouse still for a moment…'; + + // The first run needs a click for Chrome; later ones reuse the stream. + await page.locator('#share-snapshot-btn').click(); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + await page.keyboard.press('Escape'); + await expect(page.locator('#share-modal')).toBeHidden(); + const share = () => page.evaluate(() => document.getElementById('share-snapshot-btn').click()); + + // A pointer resting away from the panels is not waited for. + const grid = await page.locator('#panel-grid').boundingBox(); + await page.mouse.move(grid.x + grid.width / 2, grid.y + grid.height + 40); + await progressTexts(); + await share(); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + const texts = await progressTexts(); + expect(texts.some(text => text.startsWith('Capturing screen 2 of'))).toBe(true); + expect(texts).not.toContain(holdText); + await expect(page.locator('.snapshot-pointer-shield')).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(page.locator('#share-modal')).toBeHidden(); + + // The pointer keeps moving over the panels while the next run starts. + await share(); + await expect(page.locator('#snapshot-progress')).toBeVisible(); + const shield = page.locator('.snapshot-pointer-shield'); + for (let step = 0; step < 12; step += 1) { + await page.mouse.move(grid.x + grid.width / 2 + (step % 2) * 40, grid.y + grid.height / 2); + await page.waitForTimeout(250); + } + await expect(page.locator('#snapshot-progress-text')).toHaveText(holdText); + // The shield covers the panels, so the provider pages get no hover. + const box = await shield.boundingBox(); + expect(box).toEqual(grid); + expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.className, { + x: grid.x + grid.width / 2, + y: grid.y + grid.height / 2, + })).toBe('snapshot-pointer-shield'); + + // Once it keeps still the capture goes on and finishes. + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 30_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(shield).toHaveCount(0); + expect(await getDisplayMediaCalls(page)).toBe(1); + } finally { + await extension.close(); + } +}); + +/** + * Replaces the browser's screen capture with a stream that passes as this + * tab, so tests can follow its life. window.__fakeTracks lists the tracks. + */ +function fakeDisplayMedia(page) { + return page.evaluate(() => { + let handle = null; + const setCaptureHandleConfig = navigator.mediaDevices.setCaptureHandleConfig.bind(navigator.mediaDevices); + navigator.mediaDevices.setCaptureHandleConfig = (config) => { + handle = config.handle; + setCaptureHandleConfig(config); + }; + window.__fakeTracks = []; + window.__displayMediaCalls = 0; + navigator.mediaDevices.getDisplayMedia = async () => { + window.__displayMediaCalls += 1; + const canvas = document.createElement('canvas'); + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + const context = canvas.getContext('2d'); + const stream = canvas.captureStream(30); + const [track] = stream.getVideoTracks(); + track.getSettings = () => ({ displaySurface: 'browser', frameRate: 30 }); + track.getCaptureHandle = () => ({ handle }); + const draw = () => { + if (track.readyState !== 'live') return; + context.fillStyle = `hsl(${Date.now() % 360}, 50%, 90%)`; + context.fillRect(0, 0, canvas.width, canvas.height); + requestAnimationFrame(draw); + }; + draw(); + window.__fakeTracks.push(track); + return stream; + }; + }); +} + +const getFakeTrackStates = page => page.evaluate(() => window.__fakeTracks.map(track => track.readyState)); + +test('the shared tab serves later snapshots until Stop, Chrome or leaving the page ends it', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + const page = await openConversationPanels(extension, [{ provider: 'chatgpt', blocks: BLOCK_COLORS.slice(0, 2) }]); + await fakeDisplayMedia(page); + const modal = page.locator('#share-modal'); + const status = page.locator('#share-stream-status'); + const share = async () => { + await page.locator('#share-snapshot-btn').click(); + await expect(modal).toBeVisible({ timeout: 60_000 }); + }; + const close = async () => { + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + }; + + // Two shares, one request. + await share(); + await expect(status).toBeVisible(); + await close(); + await share(); + expect(await getDisplayMediaCalls(page)).toBe(1); + expect(await getFakeTrackStates(page)).toEqual(['live']); + + // Stop ends the sharing; the next share asks again. + await page.locator('#share-stream-stop').click(); + await expect(status).toBeHidden(); + expect(await getFakeTrackStates(page)).toEqual(['ended']); + await close(); + await share(); + expect(await getDisplayMediaCalls(page)).toBe(2); + await expect(status).toBeVisible(); + + // Chrome's "Stop sharing" bar ends the track on its own. + await page.evaluate(() => { + const track = window.__fakeTracks.at(-1); + track.stop(); + track.dispatchEvent(new Event('ended')); + }); + await expect(status).toBeHidden(); + await close(); + await share(); + expect(await getDisplayMediaCalls(page)).toBe(3); + + // Leaving the page ends it too. + await page.evaluate(() => window.dispatchEvent(new Event('pagehide'))); + expect(await getFakeTrackStates(page)).toEqual(['ended', 'ended', 'ended']); + await expect(status).toBeHidden(); + } finally { + await extension.close(); + } +}); + +/** Replaces the browser's screen capture with a stub for failure paths. */ +function stubDisplayMedia(page, behavior) { + return page.evaluate((mode) => { + navigator.mediaDevices.getDisplayMedia = async () => { + if (mode === 'cancel') { + throw new DOMException('Permission denied by user', 'NotAllowedError'); + } + // A stream that is not this tab: it carries no capture handle. + const canvas = document.createElement('canvas'); + canvas.width = 320; + canvas.height = 200; + canvas.getContext('2d').fillRect(0, 0, 320, 200); + return canvas.captureStream(10); + }; + }, behavior); +} + +test('cancelling the tab sharing dialog shows a notice and no preview', async () => { + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + const page = await openChatgptPanel(extension); + await stubDisplayMedia(page, 'cancel'); + await page.locator('#share-snapshot-btn').click(); + await expect(page.getByText('Snapshot cancelled')).toBeVisible(); + await expect(page.locator('#share-modal')).toBeHidden(); + await expect(page.locator('#share-snapshot-btn')).toBeEnabled(); + await expect(page.locator('#snapshot-progress')).toBeHidden(); + } finally { + await extension.close(); + } +}); + +test('a stream that is not this tab is refused instead of cropped', async () => { + const extension = await launchExtension({ viewport: { width: 1280, height: 800 } }); + try { + const page = await openChatgptPanel(extension); + await stubDisplayMedia(page, 'other-surface'); + await page.locator('#share-snapshot-btn').click(); + await expect(page.getByText('To take a snapshot, share this Panelize tab')).toBeVisible(); + await expect(page.locator('#share-modal')).toBeHidden(); + } finally { + await extension.close(); + } +}); + +test('Full answers of a long page scaled to fit its panel fits it by its size on screen', async () => { + test.setTimeout(150_000); + const extension = await launchExtension({ + viewport: { width: 600, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + // 10000px laid out at 1000px wide and drawn at about 0.6: room to spare under the height cap. + const blocks = Array.from({ length: 25 }, (_, index) => BLOCK_COLORS[index % BLOCK_COLORS.length]); + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks, blockHeight: 400, minWidth: 1000 }, + ]); + await expect(page.locator('#panel-grid iframe')).toHaveClass(/panel-fitted/); + const fittedFrame = page.frames().find(frame => frame.url().startsWith(PROVIDER_ORIGINS.chatgpt)); + await expect.poll(() => fittedFrame.evaluate(() => window.innerWidth)).toBe(1000); + + await clickToolbarIcon(extension, page); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 90_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + const full = await showFullAnswers(page); + await expect(page.locator('#share-capture-notes')).toBeHidden(); + const runs = await readConversationRuns(page, full, 0, { hasNote: false }); + expect(runs.filter(run => run.name.startsWith('block'))).toHaveLength(blocks.length); + } finally { + await extension.close(); + } +}); + +test('Full answers says which page stopped answering partway', async () => { + test.setTimeout(150_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const blocks = Array.from({ length: 30 }, (_, index) => BLOCK_COLORS[index % BLOCK_COLORS.length]); + const page = await openConversationPanels(extension, [{ provider: 'chatgpt', blocks }]); + // The session expires mid-capture and the page goes to a sign-in page. + await extension.context.route('https://auth.openai.com/**', route => route.fulfill({ + contentType: 'text/html', body: 'Sign in

Please sign in

', + })); + + await clickToolbarIcon(extension, page); + await expect.poll(() => page.locator('#snapshot-progress-text').textContent(), { intervals: [50] }) + .toMatch(/Capturing screen [2-9] of/); + await page.locator('#panel-grid iframe').evaluate((frame) => { frame.src = 'https://auth.openai.com/log-in'; }); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('.share-mode-btn[data-mode="full"]')).toHaveAttribute('aria-checked', 'true'); + await expect(page.locator('#share-capture-notes')) + .toHaveText('Could not be captured to the end, only the start is shown: ChatGPT'); + } finally { + await extension.close(); + } +}); + +test('switching tabs during a silent capture stops it instead of taking the other tab', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const page = await openConversationPanels(extension, [{ provider: 'chatgpt', blocks: BLOCK_COLORS.slice(0, 2) }]); + // Another tab the extension may capture too. + const other = await extension.context.newPage(); + await other.goto(extension.extensionUrl('multi-panel/multi-panel.html#other')); + await clickToolbarIcon(extension, other); + await expect(other.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(other.locator('#share-modal')).toBeVisible(); + + // Hold the first frame after the grant probe, until the user switched tabs. + await page.evaluate(() => { + const capture = chrome.tabs.captureVisibleTab.bind(chrome.tabs); + let calls = 0; + chrome.tabs.captureVisibleTab = async (...args) => { + calls += 1; + if (calls === 2) { + window.heldFrame = true; + await new Promise(resolve => { window.releaseFrame = resolve; }); + } + return capture(...args); + }; + }); + await page.bringToFront(); + await clickToolbarIcon(extension, page); + await page.waitForFunction(() => window.heldFrame); + await other.bringToFront(); + await page.evaluate(() => window.releaseFrame()); + + await expect(page.getByText('Snapshot stopped: keep this Panelize tab in front until it is done')).toBeVisible(); + await expect(page.locator('#share-preview')).not.toHaveAttribute('src', /./); + } finally { + await extension.close(); + } +}); + +test('Full answers starts over when a panel divider is dragged mid-capture', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const columns = [BLOCK_COLORS.slice(0, 6), BLOCK_COLORS.slice(6, 8)]; + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: columns[0] }, + { provider: 'deepseek', blocks: columns[1] }, + ], '1x2'); + const restarts = []; + page.on('console', message => { + if (message.text().includes('Layout changed during the snapshot')) restarts.push(message.text()); + }); + // Hold the second screen (after the grant probe and the first screen) + // until the divider has been dragged. + await page.evaluate(() => { + const capture = chrome.tabs.captureVisibleTab.bind(chrome.tabs); + let calls = 0; + chrome.tabs.captureVisibleTab = async (...args) => { + calls += 1; + if (calls === 3) { + window.heldFrame = true; + await new Promise(resolve => { window.releaseFrame = resolve; }); + } + return capture(...args); + }; + }); + + await clickToolbarIcon(extension, page); + await page.waitForFunction(() => window.heldFrame); + const widthsBefore = await page.locator('.panel-item').evaluateAll(items => items.map(item => item.offsetWidth)); + const handle = await page.locator('.panel-resize-handle').first().boundingBox(); + await page.mouse.move(handle.x + handle.width / 2, handle.y + 200); + await page.mouse.down(); + await page.mouse.move(handle.x + handle.width / 2 + 180, handle.y + 200, { steps: 5 }); + await page.mouse.up(); + const widthsAfter = await page.locator('.panel-item').evaluateAll(items => items.map(item => item.offsetWidth)); + expect(widthsAfter[0]).toBeGreaterThan(widthsBefore[0] + 100); + await page.evaluate(() => window.releaseFrame()); + + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + expect(restarts.length).toBeGreaterThan(0); + + // Each column holds its own panel's answer, cropped from the new layout. + const full = await inspectPreview(page); + for (const [index, blocks] of columns.entries()) { + const runs = await readConversationRuns(page, full, index, { hasNote: false }); + expect(runs.filter(run => run.name === 'header'), `panel ${index}`).toHaveLength(1); + expect(runs.filter(run => run.name.startsWith('block')).map(run => run.name), `panel ${index}`) + .toEqual(blocks.map(color => `block${BLOCK_COLORS.indexOf(color)}`)); + } + } finally { + await extension.close(); + } +}); + +test('Full answers names a page that keeps putting back its scroll position', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const columns = [BLOCK_COLORS.slice(0, 8), BLOCK_COLORS.slice(0, 8)]; + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: columns[0] }, + { provider: 'deepseek', blocks: columns[1] }, + ], '1x2'); + // ChatGPT puts its position back on every scroll; DeepSeek only once. + for (const [origin, resets] of [[PROVIDER_ORIGINS.chatgpt, Infinity], [PROVIDER_ORIGINS.deepseek, 1]]) { + const frame = page.frames().find(candidate => candidate.url().startsWith(origin)); + await frame.evaluate((resets) => { + const thread = document.getElementById('thread'); + let preparedTop = null; + let left = resets; + window.addEventListener('message', (event) => { + if (event.data?.type === 'PANELIZE_SNAPSHOT_PREPARE') { + preparedTop = thread.scrollTop; + } else if (event.data?.type === 'PANELIZE_SNAPSHOT_SCROLL' && left > 0) { + left -= 1; + thread.scrollTop = preparedTop; + } + }); + }, resets); + } + + await clickToolbarIcon(extension, page); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('#share-capture-notes')) + .toHaveText('Could not be captured to the end, only the start is shown: ChatGPT'); + + const full = await inspectPreview(page); + const runs = await readConversationRuns(page, full, 1); + expect(runs.filter(run => run.name.startsWith('block')).map(run => run.name)) + .toEqual(columns[1].map(color => `block${BLOCK_COLORS.indexOf(color)}`)); + } finally { + await extension.close(); + } +}); + +test('Full answers keeps only what joins up when a page jumps to its bottom', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const columns = [BLOCK_COLORS.slice(0, 8), BLOCK_COLORS.slice(0, 8)]; + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: columns[0] }, + { provider: 'deepseek', blocks: columns[1] }, + ], '1x2'); + // ChatGPT jumps to its bottom on every scroll; DeepSeek only once. + for (const [origin, jumps] of [[PROVIDER_ORIGINS.chatgpt, Infinity], [PROVIDER_ORIGINS.deepseek, 1]]) { + const frame = page.frames().find(candidate => candidate.url().startsWith(origin)); + await frame.evaluate((jumps) => { + const thread = document.getElementById('thread'); + let left = jumps; + window.addEventListener('message', (event) => { + if (event.data?.type === 'PANELIZE_SNAPSHOT_SCROLL' && event.data.scrollTop > 0 && left > 0) { + left -= 1; + thread.scrollTop = thread.scrollHeight; + } + }); + }, jumps); + } + + await clickToolbarIcon(extension, page); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('#share-capture-notes')) + .toHaveText('Could not be captured to the end, only the start is shown: ChatGPT'); + + const full = await inspectPreview(page); + const blockNames = (runs) => runs.filter(run => run.name.startsWith('block')).map(run => run.name); + const expected = columns[0].map(color => `block${BLOCK_COLORS.indexOf(color)}`); + // No hole: ChatGPT shows its first blocks, in order, and stops. + const chatgpt = blockNames(await readConversationRuns(page, full, 0)); + expect(chatgpt.length).toBeGreaterThan(0); + expect(chatgpt.length).toBeLessThan(expected.length); + expect(chatgpt).toEqual(expected.slice(0, chatgpt.length)); + expect(blockNames(await readConversationRuns(page, full, 1))).toEqual(expected); + } finally { + await extension.close(); + } +}); + +test('Full answers shows the current view of a page that will not leave its bottom', async () => { + test.setTimeout(120_000); + const extension = await launchExtension({ + viewport: { width: 1280, height: 800 }, + args: [UNSAFE_EXTENSION_DEBUGGING], + }); + try { + const columns = [BLOCK_COLORS.slice(0, 8), BLOCK_COLORS.slice(0, 8)]; + const page = await openConversationPanels(extension, [ + { provider: 'chatgpt', blocks: columns[0] }, + { provider: 'deepseek', blocks: columns[1] }, + ], '1x2'); + // ChatGPT goes back to its bottom whenever scrolled up; DeepSeek once. + for (const [origin, times] of [[PROVIDER_ORIGINS.chatgpt, Infinity], [PROVIDER_ORIGINS.deepseek, 1]]) { + const frame = page.frames().find(candidate => candidate.url().startsWith(origin)); + await frame.evaluate((times) => { + const thread = document.getElementById('thread'); + thread.scrollTop = thread.scrollHeight; + let left = times; + thread.addEventListener('scroll', () => { + const bottom = thread.scrollHeight - thread.clientHeight; + if (thread.scrollTop < bottom - 1 && left > 0) { + left -= 1; + thread.scrollTop = bottom; + } + }); + }, times); + } + + await clickToolbarIcon(extension, page); + await expect(page.locator('#snapshot-progress')).toBeHidden({ timeout: 60_000 }); + await expect(page.locator('#share-modal')).toBeVisible(); + await expect(page.locator('#share-capture-notes')).toHaveText('Current view only: ChatGPT'); + + const full = await inspectPreview(page); + const blockNames = (runs) => runs.filter(run => run.name.startsWith('block')).map(run => run.name); + const expected = columns[0].map(color => `block${BLOCK_COLORS.indexOf(color)}`); + // ChatGPT as on screen: its last blocks, never the start. + const chatgpt = blockNames(await readConversationRuns(page, full, 0)); + expect(chatgpt.at(-1)).toBe(expected.at(-1)); + expect(chatgpt).not.toContain(expected[0]); + expect(blockNames(await readConversationRuns(page, full, 1))).toEqual(expected); + } finally { + await extension.close(); + } +}); diff --git a/tests/live/live-harness.js b/tests/live/live-harness.js index e739be3..b523c78 100644 --- a/tests/live/live-harness.js +++ b/tests/live/live-harness.js @@ -54,15 +54,26 @@ export function getSelectedProviders() { return PROVIDERS.filter((provider) => requested.includes(provider.id)); } +/** Version parts are at most 65535; minutes since 2026 change every run. */ +function getCopyBuildNumber() { + return Math.floor((Date.now() - Date.UTC(2026, 0, 1)) / 60000) % 65535 + 1; +} + /** * Refreshes the fixed-path extension copy from the working tree. Optional * provider host permissions are granted in the manifest because granting them * at runtime needs a user gesture on the options page. + * + * The copy also gets a fresh fourth version part: Chrome keeps the registered + * service worker of an unpacked extension while its version stays the same, + * so the live profile would otherwise run an old background script. * @returns {Promise} Extension path. */ export async function prepareLiveExtension() { const { extensionPath } = await createPatchedExtensionCopy((manifest) => { manifest.host_permissions.push(...(manifest.optional_host_permissions || [])); + const [major = 0, minor = 0, patch = 0] = manifest.version.split('.'); + manifest.version = [major, minor, patch, getCopyBuildNumber()].join('.'); }, { destination: LIVE_EXTENSION_PATH }); return extensionPath; } diff --git a/tests/live/share-snapshot.live.test.js b/tests/live/share-snapshot.live.test.js new file mode 100644 index 0000000..53d7020 --- /dev/null +++ b/tests/live/share-snapshot.live.test.js @@ -0,0 +1,794 @@ +import { test, expect } from '@playwright/test'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { launchExtension } from '../e2e/extension-harness.js'; +import { + LIVE_HEADLESS, + LIVE_IGNORED_DEFAULT_ARGS, + LIVE_SEND_ENABLED, + LIVE_USER_DATA_DIR, + configureLiveProviders, + prepareLiveExtension, +} from './live-harness.js'; + +/** + * PANELIZE_SHARE_PROVIDERS=chatgpt,gemini,deepseek leaves a provider out, + * e.g. when its account is close to a usage limit. + */ +const SHARE_PROVIDERS = process.env.PANELIZE_SHARE_PROVIDERS + ? process.env.PANELIZE_SHARE_PROVIDERS.split(',').map((id) => id.trim()).filter(Boolean) + : ['chatgpt', 'claude', 'gemini', 'deepseek']; +const SHARE_LAYOUT = SHARE_PROVIDERS.length === 4 ? '2x2' : `1x${SHARE_PROVIDERS.length}`; + +/** Short question so each provider answers within the wait below. */ +const LIVE_PROMPT = 'In one sentence: why compare answers from several AI assistants?'; + +/** Time for the four providers to stream their answers before the snapshot. */ +const ANSWER_WAIT_MS = 30000; + +/** Evidence goes next to the Phase 0 spike output; artifacts/ is gitignored. */ +const EVIDENCE_DIR = path.resolve(process.env.PANELIZE_SHARE_EVIDENCE_DIR || 'artifacts/share-snapshot/live'); + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** + * Real 2x2 comparison on the logged-in live profile: send one prompt, take a + * snapshot through Chrome's tab sharing (auto-accepted by a switch), then copy + * and download it. Sending uses provider quota, so it needs PANELIZE_LIVE_SEND=1. + * Temporary chat keeps the prompt out of history where the provider supports it. + */ +test('Share snapshot of a live 2x2 comparison', async () => { + test.skip(!LIVE_SEND_ENABLED, 'Set PANELIZE_LIVE_SEND=1: the snapshot needs real answers.'); + test.setTimeout(240000); + await mkdir(EVIDENCE_DIR, { recursive: true }); + + const extensionPath = await prepareLiveExtension(); + const extension = await launchExtension({ + extensionPath, + userDataDir: LIVE_USER_DATA_DIR, + headless: LIVE_HEADLESS, + ignoreDefaultArgs: LIVE_IGNORED_DEFAULT_ARGS, + viewport: { width: 1400, height: 900 }, + args: ['--disable-blink-features=AutomationControlled', '--auto-accept-this-tab-capture'], + }); + const consoleMessages = []; + try { + await configureLiveProviders(extension.serviceWorker, SHARE_PROVIDERS, { layout: SHARE_LAYOUT }); + const page = await extension.context.newPage(); + page.on('console', (message) => { + if (['error', 'warning'].includes(message.type())) { + consoleMessages.push({ type: message.type(), text: message.text(), url: message.location().url }); + } + }); + page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message })); + + await page.goto(extension.extensionUrl('multi-panel/multi-panel.html')); + await expect(page.locator('#panel-grid iframe')).toHaveCount(SHARE_PROVIDERS.length); + for (const frame of await page.locator('#panel-grid iframe').elementHandles()) { + await (await frame.contentFrame()).waitForLoadState('load', { timeout: 45000 }); + } + + const temporaryChat = page.locator('#temporary-chat-btn'); + await temporaryChat.click(); + await expect(temporaryChat).toHaveAttribute('aria-pressed', 'true', { timeout: 15000 }); + // Providers reload into temporary mode. + await page.waitForTimeout(8000); + + await page.locator('#unified-input').fill(LIVE_PROMPT); + await page.locator('#send-all-btn').click(); + const sendStatus = await expect(page.locator('#send-status')) + .toHaveText(/Sent to/, { timeout: 60000 }) + .then(() => page.locator('#send-status').textContent()); + await page.waitForTimeout(ANSWER_WAIT_MS); + await page.screenshot({ path: path.join(EVIDENCE_DIR, 'comparison-before-share.png') }); + + await page.locator('#share-snapshot-btn').click(); + const modal = page.locator('#share-modal'); + await expect(modal).toBeVisible({ timeout: 30000 }); + await page.screenshot({ path: path.join(EVIDENCE_DIR, 'share-modal.png') }); + + const preview = await page.evaluate(async () => { + const image = document.getElementById('share-preview'); + await image.decode(); + const main = document.getElementById('main-content').getBoundingClientRect(); + return { + width: image.naturalWidth, + height: image.naturalHeight, + mainContent: { width: main.width, height: main.height }, + viewport: { width: innerWidth, height: innerHeight, devicePixelRatio }, + }; + }); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#share-download-btn').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^panelize-comparison-\d{8}-\d{6}\.png$/); + const downloadPath = path.join(EVIDENCE_DIR, download.suggestedFilename()); + await download.saveAs(downloadPath); + const png = await readFile(downloadPath); + expect(Array.from(png.subarray(0, 8))).toEqual(PNG_SIGNATURE); + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([preview.width, preview.height]); + + await page.locator('#share-copy-btn').click(); + await expect(page.getByText('Image copied to clipboard')).toBeVisible(); + await page.screenshot({ path: path.join(EVIDENCE_DIR, 'share-copied.png') }); + + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + + await writeFile(path.join(EVIDENCE_DIR, 'summary.json'), `${JSON.stringify({ + providers: SHARE_PROVIDERS, + prompt: LIVE_PROMPT, + sendStatus, + preview, + download: path.basename(downloadPath), + pageErrors: consoleMessages.filter((message) => message.type === 'pageerror'), + consoleMessages, + }, null, 2)}\n`); + expect(consoleMessages.filter((message) => message.type === 'pageerror')).toEqual([]); + } finally { + await extension.close(); + } +}); + +/** Long answer, so "Full answers" has to scroll and stitch every panel. */ +const LONG_PROMPT = 'Explain how HTTPS works in detail, at least 800 words'; + +/** v2 evidence: both layouts, both modes, the dialog on desktop and phone. */ +const V2_EVIDENCE_DIR = path.resolve(process.env.PANELIZE_SHARE_V2_EVIDENCE_DIR || 'artifacts/share-snapshot/v2'); + +/** Answers count as finished once no panel's text grew for this long. */ +const ANSWER_STABLE_MS = 20000; +const ANSWER_POLL_MS = 5000; +const ANSWER_MAX_WAIT_MS = 240000; + +const PROVIDER_HOSTS = { + chatgpt: 'chatgpt.com', + claude: 'claude.ai', + gemini: 'gemini.google.com', + grok: 'grok.com', + deepseek: 'chat.deepseek.com', +}; + +function findProviderFrame(page, providerId) { + return page.frames().find((frame) => { + try { + return new URL(frame.url()).hostname === PROVIDER_HOSTS[providerId]; + } catch { + return false; + } + }); +} + +/** Text length of each provider page; cross-origin frames are read through Playwright. */ +async function readAnswerLengths(page, providers = SHARE_PROVIDERS) { + const lengths = {}; + for (const id of providers) { + const frame = findProviderFrame(page, id); + lengths[id] = frame + ? await frame.evaluate(() => document.body?.innerText.length || 0).catch(() => -1) + : -1; + } + return lengths; +} + +/** + * Providers whose page shows the prompt, polled for a while after Send All. + * @returns {Promise} Providers still without it. + */ +async function waitForPromptDelivery(page, prompt, timeoutMs = 30000, providers = SHARE_PROVIDERS) { + const deadline = Date.now() + timeoutMs; + let missing = providers; + while (Date.now() < deadline) { + const results = await Promise.all(providers.map(async (id) => { + const frame = findProviderFrame(page, id); + const shown = frame + ? await frame.evaluate((text) => { + // Sent, not just typed: text still in a composer does not count. + const count = (element) => element.innerText.split(text).length - 1; + const typed = [...document.querySelectorAll('[contenteditable="true"]')] + .reduce((total, element) => total + count(element), 0); + return Boolean(document.body) && count(document.body) > typed; + }, prompt).catch(() => false) + : false; + return shown ? null : id; + })); + missing = results.filter(Boolean); + if (missing.length === 0) break; + await page.waitForTimeout(2000); + } + return missing; +} + +const DIRECT_SEND_TIMEOUT_MS = 45000; + +/** Where a panel that landed on a page without a composer can chat. */ +const PROVIDER_APP_URLS = { + gemini: 'https://gemini.google.com/app', +}; + +/** + * Types the prompt straight into a provider page. Only used when Send All did + * not reach it, so the snapshot still has an answer there; the summary says so. + */ +async function sendDirectly(page, providerId, prompt) { + let frame = findProviderFrame(page, providerId); + let editor = frame.locator(EDITOR_SELECTORS[providerId]).first(); + if (PROVIDER_APP_URLS[providerId] && await editor.count() === 0) { + // e.g. Gemini sometimes opens its marketing page instead of the app. + await frame.goto(PROVIDER_APP_URLS[providerId], { timeout: DIRECT_SEND_TIMEOUT_MS }); + frame = findProviderFrame(page, providerId); + editor = frame.locator(EDITOR_SELECTORS[providerId]).first(); + } + // Fail with a reason rather than wait out the whole test. + await editor.click({ timeout: DIRECT_SEND_TIMEOUT_MS }); + // Send All may have typed it without sending. + await editor.fill(''); + await editor.pressSequentially(prompt); + await editor.press('Enter'); +} + +const EDITOR_SELECTORS = { + gemini: '.ql-editor', + chatgpt: '#prompt-textarea', + claude: '[contenteditable="true"]', + grok: '[contenteditable="true"], textarea', + deepseek: 'textarea', +}; + +async function waitForAnswersToSettle(page, providers = SHARE_PROVIDERS) { + const started = Date.now(); + let last = null; + let stableSince = Date.now(); + const history = []; + while (Date.now() - started < ANSWER_MAX_WAIT_MS) { + await page.waitForTimeout(ANSWER_POLL_MS); + const lengths = await readAnswerLengths(page, providers); + history.push({ seconds: Math.round((Date.now() - started) / 1000), ...lengths }); + if (last && JSON.stringify(lengths) === JSON.stringify(last)) { + if (Date.now() - stableSince >= ANSWER_STABLE_MS) { + return { settled: true, history }; + } + } else { + stableSince = Date.now(); + } + last = lengths; + } + return { settled: false, history }; +} + +/** Applies a theme the way the options page does: save it, then apply it. */ +function setTheme(page, theme) { + return page.evaluate(async (value) => { + const { saveSetting } = await import(chrome.runtime.getURL('modules/settings.js')); + const { applyTheme } = await import(chrome.runtime.getURL('modules/theme-manager.js')); + await saveSetting('theme', value); + return applyTheme(); + }, theme); +} + +/** + * Asks every panel what the snapshot would see (prompt found, conversation + * clip, scroll range), then puts it back. Same messages the capture uses. + */ +function probePanels(page, prompt) { + return page.evaluate(async (text) => { + const { createSnapshotBridge, SNAPSHOT_MESSAGES } = await import(chrome.runtime.getURL('modules/snapshot-capture.js')); + const bridge = createSnapshotBridge(); + try { + const frames = Array.from(document.querySelectorAll('#panel-grid iframe')); + const states = await Promise.all(frames.map((iframe) => bridge.request( + iframe, SNAPSHOT_MESSAGES.PREPARE, { align: true, prompt: text }, 2500 + ))); + await Promise.all(frames.map((iframe) => bridge.request(iframe, SNAPSHOT_MESSAGES.RESTORE, {}, 1500))); + return states.map((state, index) => ({ + src: new URL(frames[index].src).hostname, + answered: Boolean(state), + provider: state?.provider ?? null, + matched: state?.matched ?? null, + clip: state?.clip ?? null, + scrollTop: state?.scrollTop ?? null, + maxScrollTop: state?.maxScrollTop ?? null, + error: state?.error ?? null, + })); + } finally { + bridge.dispose(); + } + }, prompt); +} + +async function clickToolbarIcon(extension, page) { + const session = await extension.context.browser().newBrowserCDPSession(); + try { + const { targetInfos } = await session.send('Target.getTargets', { filter: [{ type: 'tab' }] }); + const tab = targetInfos.find((info) => info.url === page.url()); + expect(tab, 'tab target of the multi-panel page').toBeTruthy(); + await session.send('Extensions.triggerAction', { id: extension.extensionId, targetId: tab.targetId }); + } finally { + await session.detach(); + } +} + +async function waitForCaptureToFinish(page, mode) { + await expect(page.locator('body')).not.toHaveClass(/snapshot-capturing/, { timeout: 180000 }); + await expect(page.locator(`.share-mode-btn[data-mode="${mode}"]`)).toHaveAttribute('aria-checked', 'true'); +} + +async function readModalState(page) { + return page.evaluate(async () => { + const image = document.getElementById('share-preview'); + await image.decode(); + const notes = document.getElementById('share-capture-notes'); + const hint = document.getElementById('share-capture-hint'); + return { + width: image.naturalWidth, + height: image.naturalHeight, + src: image.src, + notes: notes.hidden ? '' : notes.textContent, + hint: hint.hidden ? '' : hint.textContent.trim(), + title: document.getElementById('share-title-input').value, + theme: document.documentElement.getAttribute('data-theme'), + displayMediaCalls: window.__displayMediaCalls, + visibleTabFlag: sessionStorage.getItem('panelize.snapshotVisibleTabCaptured'), + window: { innerWidth, innerHeight, devicePixelRatio }, + }; + }); +} + +async function savePreview(page, fileName, directory = V2_EVIDENCE_DIR) { + const downloadPromise = page.waitForEvent('download'); + await page.locator('#share-download-btn').click(); + const download = await downloadPromise; + const target = path.join(directory, fileName); + await download.saveAs(target); + const png = await readFile(target); + expect(Array.from(png.subarray(0, 8))).toEqual(PNG_SIGNATURE); + return { file: fileName, width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +async function captureFull(page) { + const previousSrc = await page.locator('#share-preview').getAttribute('src'); + await page.locator('.share-mode-btn[data-mode="full"]').click(); + // The progress strip shows while the panels scroll. + await expect(page.locator('#snapshot-progress')).toBeVisible({ timeout: 10000 }).catch(() => {}); + const progress = await page.locator('#snapshot-progress-text').textContent().catch(() => ''); + await waitForCaptureToFinish(page, 'full'); + await expect.poll(() => page.locator('#share-preview').getAttribute('src')).not.toBe(previousSrc); + return progress; +} + +/** + * v2 on the logged-in live profile: a long answer from all four providers, + * snapshotted silently from the toolbar icon in both modes and both layouts + * (A light, B dark). Records what each panel reported, so a provider whose + * page cannot be stitched shows up in the summary. Needs PANELIZE_LIVE_SEND=1. + */ +test('Share snapshot v2: layouts and full answers on a live 2x2', async () => { + test.skip(!LIVE_SEND_ENABLED, 'Set PANELIZE_LIVE_SEND=1: the snapshot needs real answers.'); + test.setTimeout(900000); + await mkdir(V2_EVIDENCE_DIR, { recursive: true }); + + const extensionPath = await prepareLiveExtension(); + const extension = await launchExtension({ + extensionPath, + userDataDir: LIVE_USER_DATA_DIR, + headless: LIVE_HEADLESS, + ignoreDefaultArgs: LIVE_IGNORED_DEFAULT_ARGS, + // No viewport emulation: the toolbar capture takes the real window, so + // the page must be laid out at the real window size. + viewport: null, + args: [ + '--window-size=1440,1000', + '--disable-blink-features=AutomationControlled', + '--auto-accept-this-tab-capture', + '--enable-unsafe-extension-debugging', + ], + }); + const consoleMessages = []; + const summary = { providers: SHARE_PROVIDERS, prompt: LONG_PROMPT, steps: {} }; + let page = null; + let originalTheme = null; + try { + originalTheme = await extension.serviceWorker.evaluate(async () => ( + (await chrome.storage.sync.get({ theme: 'auto' })).theme + )); + await configureLiveProviders(extension.serviceWorker, SHARE_PROVIDERS, { layout: SHARE_LAYOUT }); + page = await extension.context.newPage(); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleMessages.push({ type: 'error', text: message.text(), url: message.location().url }); + } + }); + page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message })); + await page.addInitScript(() => { + // Provider frames get this script too; only the Panelize page captures. + if (location.protocol !== 'chrome-extension:') return; + const original = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); + window.__displayMediaCalls = 0; + navigator.mediaDevices.getDisplayMedia = (...args) => { + window.__displayMediaCalls += 1; + return original(...args); + }; + }); + + await page.goto(extension.extensionUrl('multi-panel/multi-panel.html')); + await expect(page.locator('#panel-grid iframe')).toHaveCount(SHARE_PROVIDERS.length); + for (const frame of await page.locator('#panel-grid iframe').elementHandles()) { + await (await frame.contentFrame()).waitForLoadState('load', { timeout: 45000 }); + } + await setTheme(page, 'light'); + + const temporaryChat = page.locator('#temporary-chat-btn'); + await temporaryChat.click(); + await expect(temporaryChat).toHaveAttribute('aria-pressed', 'true', { timeout: 15000 }); + // Providers reload into temporary mode; give the slowest time to settle. + await page.waitForTimeout(15000); + + await page.locator('#unified-input').fill(LONG_PROMPT); + await page.locator('#send-all-btn').click(); + await expect(page.locator('#send-status')).toHaveText(/Sent to/, { timeout: 60000 }); + summary.window = await page.evaluate(() => ({ + innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio, + })); + summary.sendStatus = await page.locator('#send-status').textContent(); + summary.notReachedBySendAll = await waitForPromptDelivery(page, LONG_PROMPT); + for (const id of summary.notReachedBySendAll) { + await sendDirectly(page, id, LONG_PROMPT); + } + summary.notReachedAfterRetry = summary.notReachedBySendAll.length + ? await waitForPromptDelivery(page, LONG_PROMPT) + : []; + summary.answers = await waitForAnswersToSettle(page); + await page.screenshot({ path: path.join(V2_EVIDENCE_DIR, 'comparison.png') }); + summary.probe = await probePanels(page, LONG_PROMPT); + + // Layout A: toolbar icon, so the capture is silent. + const modal = page.locator('#share-modal'); + await clickToolbarIcon(extension, page); + await expect(modal).toBeVisible({ timeout: 30000 }); + await waitForCaptureToFinish(page, 'current'); + summary.steps.aCurrent = { ...(await readModalState(page)), ...(await savePreview(page, 'A-current.png')) }; + await page.screenshot({ path: path.join(V2_EVIDENCE_DIR, 'modal-desktop-A-current.png') }); + + summary.steps.aFull = { progress: await captureFull(page) }; + Object.assign(summary.steps.aFull, await readModalState(page), await savePreview(page, 'A-full.png')); + await page.screenshot({ path: path.join(V2_EVIDENCE_DIR, 'modal-desktop-A-full.png') }); + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + + // Layout B: the page follows the theme; the Share button reuses the grant. + summary.theme = await setTheme(page, 'dark'); + await page.locator('#share-snapshot-btn').click(); + await expect(modal).toBeVisible({ timeout: 30000 }); + await waitForCaptureToFinish(page, 'current'); + summary.steps.bCurrent = { ...(await readModalState(page)), ...(await savePreview(page, 'B-current.png')) }; + summary.steps.bFull = { progress: await captureFull(page) }; + Object.assign(summary.steps.bFull, await readModalState(page), await savePreview(page, 'B-full.png')); + await page.screenshot({ path: path.join(V2_EVIDENCE_DIR, 'modal-desktop-B-full.png') }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(500); + await page.screenshot({ path: path.join(V2_EVIDENCE_DIR, 'modal-390-B-full.png') }); + await setTheme(page, 'light'); + await page.locator('.share-mode-btn[data-mode="current"]').click(); + await page.waitForTimeout(800); + await page.screenshot({ path: path.join(V2_EVIDENCE_DIR, 'modal-390-light.png') }); + summary.mobileLayout = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + modal: document.querySelector('.share-modal-content').getBoundingClientRect().toJSON(), + footer: document.querySelector('#share-modal .modal-footer').getBoundingClientRect().toJSON(), + })); + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + + summary.steps.allSilent = summary.steps.bFull.displayMediaCalls === 0; + summary.consoleMessages = consoleMessages; + summary.extensionErrors = consoleMessages.filter((message) => ( + message.type === 'pageerror' || (message.url || '').startsWith('chrome-extension://') + )); + expect(summary.steps.aCurrent.hint).toBe(''); + expect(summary.steps.bFull.displayMediaCalls).toBe(0); + expect(summary.extensionErrors).toEqual([]); + } finally { + await writeFile(path.join(V2_EVIDENCE_DIR, 'summary.json'), `${JSON.stringify({ ...summary, consoleMessages }, null, 2)}\n`); + if (page && originalTheme) { + await setTheme(page, originalTheme).catch(() => {}); + } + await extension.close(); + } +}); + +/** v3 runs both prompts Denji tried: PANELIZE_SHARE_PROMPT picks one. */ +const V3_PROVIDERS = process.env.PANELIZE_SHARE_PROVIDERS + ? SHARE_PROVIDERS + : ['chatgpt', 'gemini', 'grok', 'deepseek']; +const V3_PROMPT = process.env.PANELIZE_SHARE_PROMPT || LONG_PROMPT; +const V3_EVIDENCE_DIR = path.resolve( + process.env.PANELIZE_SHARE_V3_EVIDENCE_DIR || 'artifacts/share-snapshot/v3', + process.env.PANELIZE_SHARE_RUN || 'en' +); +const ALIGNMENT_LOG_PREFIX = '[Multi-Panel] Snapshot alignment: '; +/** Wall time of each idle CPU sample. */ +const IDLE_CPU_SAMPLE_MS = 20000; + +/** + * Scrolls the conversation (the tallest scrollable box) to `fraction` of its + * range and returns its position; without a fraction it only reads it. + */ +function scrollPanel(page, providerId, fraction = null) { + return findProviderFrame(page, providerId).evaluate((value) => { + const scroller = Array.from(document.querySelectorAll('*')) + .filter((element) => /(auto|scroll)/.test(getComputedStyle(element).overflowY) + && element.scrollHeight > element.clientHeight + 200) + .sort((a, b) => b.clientHeight - a.clientHeight)[0]; + if (!scroller) return null; + if (value !== null) { + scroller.scrollTop = Math.round((scroller.scrollHeight - scroller.clientHeight) * value); + } + return { scrollTop: scroller.scrollTop, maxScrollTop: scroller.scrollHeight - scroller.clientHeight }; + }, fraction); +} + +/** + * Moves the real pointer over `locator`, so a stream that drew the cursor + * would show it. macOS only; Playwright's mouse never moves the real one. + */ +async function movePointerOver(page, locator) { + const box = await locator.boundingBox(); + const origin = await page.evaluate(() => ({ + x: window.screenX + (window.outerWidth - window.innerWidth) / 2, + y: window.screenY + window.outerHeight - window.innerHeight, + })); + const x = Math.round(origin.x + box.x + box.width / 2); + const y = Math.round(origin.y + box.y + box.height / 2); + const { execFileSync } = await import('node:child_process'); + execFileSync('swift', ['-e', `import CoreGraphics; CGWarpMouseCursorPosition(CGPoint(x: ${x}, y: ${y}))`]); + return { x, y }; +} + +/** CPU seconds of a process and all its descendants. */ +async function readProcessTreeCpuSeconds(rootPid) { + const { execFileSync } = await import('node:child_process'); + const rows = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,time='], { encoding: 'utf8' }) + .trim().split('\n').map((line) => line.trim().split(/\s+/)); + const children = new Map(); + const cpu = new Map(); + for (const [pid, ppid, time] of rows) { + if (!children.has(ppid)) children.set(ppid, []); + children.get(ppid).push(pid); + const parts = time.replace('-', ':').split(':').map(Number); + cpu.set(pid, parts.reduce((total, part) => total * 60 + part, 0)); + } + let total = 0; + const pending = [String(rootPid)]; + while (pending.length) { + const pid = pending.pop(); + total += cpu.get(pid) || 0; + pending.push(...(children.get(pid) || [])); + } + return total; +} + +/** Average CPU use of the browser, in percent of one core, while nothing happens. */ +async function sampleIdleCpu(page, rootPid) { + const start = await readProcessTreeCpuSeconds(rootPid); + const started = Date.now(); + await page.waitForTimeout(IDLE_CPU_SAMPLE_MS); + const used = await readProcessTreeCpuSeconds(rootPid) - start; + return Math.round(used / ((Date.now() - started) / 1000) * 1000) / 10; +} + +/** The live browser's main process: the one without a --type switch. */ +async function findBrowserPid() { + const { execFileSync } = await import('node:child_process'); + const browser = execFileSync('ps', ['-A', '-o', 'pid=,command='], { encoding: 'utf8' }) + .trim().split('\n').map((row) => row.trim()) + .find((row) => row.includes(`--user-data-dir=${LIVE_USER_DATA_DIR}`) && !row.includes('--type=')); + return browser ? Number(browser.split(/\s+/)[0]) : null; +} + +/** + * v3 on the logged-in live profile with ChatGPT, Gemini, Grok and DeepSeek: + * Grok scrolled to the bottom and ChatGPT to the middle, so every Full answers + * capture must start at the latest turn. Layout A goes through Chrome's tab + * sharing (pointer over a panel), and the second Share must reuse it; layout B + * goes through the toolbar icon. Records where each panel started, and the + * browser's idle CPU with the shared tab kept and after Stop. + * Needs PANELIZE_LIVE_SEND=1; PANELIZE_LIVE_MOVE_POINTER=1 moves the real pointer. + */ +test('Share snapshot v3: latest turn, one tab-sharing grant and clean answers on a live 2x2', async () => { + test.skip(!LIVE_SEND_ENABLED, 'Set PANELIZE_LIVE_SEND=1: the snapshot needs real answers.'); + test.setTimeout(1200000); + await mkdir(V3_EVIDENCE_DIR, { recursive: true }); + + const extensionPath = await prepareLiveExtension(); + const extension = await launchExtension({ + extensionPath, + userDataDir: LIVE_USER_DATA_DIR, + headless: LIVE_HEADLESS, + ignoreDefaultArgs: LIVE_IGNORED_DEFAULT_ARGS, + viewport: null, + args: [ + '--window-size=1440,1000', + '--disable-blink-features=AutomationControlled', + '--auto-accept-this-tab-capture', + '--enable-unsafe-extension-debugging', + ], + }); + const consoleMessages = []; + const alignments = []; + const summary = { providers: V3_PROVIDERS, prompt: V3_PROMPT, steps: {} }; + const layout = V3_PROVIDERS.length === 4 ? '2x2' : `1x${V3_PROVIDERS.length}`; + let page = null; + let originalTheme = null; + const takeAlignment = () => alignments.splice(0).at(-1) ?? null; + try { + originalTheme = await extension.serviceWorker.evaluate(async () => ( + (await chrome.storage.sync.get({ theme: 'auto' })).theme + )); + await configureLiveProviders(extension.serviceWorker, V3_PROVIDERS, { layout }); + page = await extension.context.newPage(); + page.on('console', (message) => { + const text = message.text(); + if (text.startsWith(ALIGNMENT_LOG_PREFIX)) { + alignments.push(JSON.parse(text.slice(ALIGNMENT_LOG_PREFIX.length))); + } else if (text.includes('Layout changed during the snapshot')) { + // The window was re-tiled or resized under a capture, which started over. + summary.layoutRestarts = (summary.layoutRestarts ?? 0) + 1; + } else if (message.type() === 'error') { + consoleMessages.push({ type: 'error', text, url: message.location().url }); + } + }); + page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message })); + await page.addInitScript(() => { + if (location.protocol !== 'chrome-extension:') return; + const original = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); + window.__displayMediaCalls = 0; + window.__displayMediaConstraints = []; + navigator.mediaDevices.getDisplayMedia = (constraints) => { + window.__displayMediaCalls += 1; + window.__displayMediaConstraints.push(constraints); + return original(constraints); + }; + }); + + await page.goto(extension.extensionUrl('multi-panel/multi-panel.html')); + await expect(page.locator('#panel-grid iframe')).toHaveCount(V3_PROVIDERS.length); + for (const frame of await page.locator('#panel-grid iframe').elementHandles()) { + await (await frame.contentFrame()).waitForLoadState('load', { timeout: 45000 }); + } + await setTheme(page, 'light'); + // Starts in Full answers, as a new install would. + await page.evaluate(() => chrome.storage.local.remove('shareSnapshotMode')); + + const temporaryChat = page.locator('#temporary-chat-btn'); + await temporaryChat.click(); + await expect(temporaryChat).toHaveAttribute('aria-pressed', 'true', { timeout: 15000 }); + await page.waitForTimeout(15000); + + // Where each panel is and whether its composer is there, for a panel Send All misses. + summary.beforeSend = {}; + for (const id of V3_PROVIDERS) { + const frame = findProviderFrame(page, id); + summary.beforeSend[id] = frame + ? { url: frame.url(), editors: await frame.locator(EDITOR_SELECTORS[id]).count() } + : null; + } + + await page.locator('#unified-input').fill(V3_PROMPT); + await page.locator('#send-all-btn').click(); + await expect(page.locator('#send-status')).toHaveText(/Sent to/, { timeout: 60000 }); + summary.window = await page.evaluate(() => ({ innerWidth, innerHeight, devicePixelRatio })); + summary.sendStatus = await page.locator('#send-status').textContent(); + summary.notReachedBySendAll = await waitForPromptDelivery(page, V3_PROMPT, 30000, V3_PROVIDERS); + for (const id of summary.notReachedBySendAll) { + await sendDirectly(page, id, V3_PROMPT); + } + summary.notReachedAfterRetry = summary.notReachedBySendAll.length + ? await waitForPromptDelivery(page, V3_PROMPT, 30000, V3_PROVIDERS) + : []; + summary.answers = await waitForAnswersToSettle(page, V3_PROVIDERS); + + // Where Denji had them: Grok at the bottom, ChatGPT in the middle. + summary.positions = { + grok: V3_PROVIDERS.includes('grok') ? await scrollPanel(page, 'grok', 1) : null, + chatgpt: V3_PROVIDERS.includes('chatgpt') ? await scrollPanel(page, 'chatgpt', 0.5) : null, + }; + await page.waitForTimeout(1000); + await page.screenshot({ path: path.join(V3_EVIDENCE_DIR, 'comparison.png') }); + const positionsBefore = await Promise.all(V3_PROVIDERS.map((id) => scrollPanel(page, id).catch(() => null))); + + // Layout A: the Share button, through tab sharing, pointer over a panel. + const modal = page.locator('#share-modal'); + if (process.env.PANELIZE_LIVE_MOVE_POINTER === '1') { + summary.pointer = await movePointerOver(page, page.locator('#panel-grid iframe').first()); + } + await page.locator('#share-snapshot-btn').click(); + await expect(modal).toBeVisible({ timeout: 300000 }); + await waitForCaptureToFinish(page, 'full'); + summary.steps.aFull = { + ...(await readModalState(page)), + ...(await savePreview(page, 'A-full.png', V3_EVIDENCE_DIR)), + alignment: takeAlignment(), + constraints: await page.evaluate(() => window.__displayMediaConstraints), + streamStatus: await page.locator('#share-stream-status').isVisible() + ? (await page.locator('#share-stream-status').innerText()).replace(/\s+/g, ' ').trim() + : '', + }; + await page.screenshot({ path: path.join(V3_EVIDENCE_DIR, 'modal-desktop-A-full.png') }); + await page.locator('.share-mode-btn[data-mode="current"]').click(); + await waitForCaptureToFinish(page, 'current'); + summary.steps.aCurrent = { + ...(await readModalState(page)), + ...(await savePreview(page, 'A-current.png', V3_EVIDENCE_DIR)), + alignment: takeAlignment(), + }; + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + summary.restored = { + before: positionsBefore, + after: await Promise.all(V3_PROVIDERS.map((id) => scrollPanel(page, id).catch(() => null))), + }; + + // The shared tab is kept: idle CPU with it, then a second Share. + const browserPid = await findBrowserPid(); + summary.idleCpu = { browserPid, sampleMs: IDLE_CPU_SAMPLE_MS }; + if (browserPid) summary.idleCpu.kept = await sampleIdleCpu(page, browserPid); + await page.locator('#share-snapshot-btn').click(); + await expect(modal).toBeVisible({ timeout: 300000 }); + await waitForCaptureToFinish(page, 'current'); + summary.steps.secondShare = { ...(await readModalState(page)), alignment: takeAlignment() }; + await page.locator('#share-stream-stop').click(); + await expect(page.locator('#share-stream-status')).toBeHidden(); + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + if (browserPid) summary.idleCpu.stopped = await sampleIdleCpu(page, browserPid); + + // Layout B: dark theme, the toolbar icon, opening in the mode used last. + summary.theme = await setTheme(page, 'dark'); + await clickToolbarIcon(extension, page); + await expect(modal).toBeVisible({ timeout: 300000 }); + await waitForCaptureToFinish(page, 'current'); + summary.steps.bCurrent = { + ...(await readModalState(page)), + ...(await savePreview(page, 'B-current.png', V3_EVIDENCE_DIR)), + alignment: takeAlignment(), + }; + summary.steps.bFull = { progress: await captureFull(page) }; + Object.assign(summary.steps.bFull, await readModalState(page), + await savePreview(page, 'B-full.png', V3_EVIDENCE_DIR), { alignment: takeAlignment() }); + await page.screenshot({ path: path.join(V3_EVIDENCE_DIR, 'modal-desktop-B-full.png') }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(500); + await page.screenshot({ path: path.join(V3_EVIDENCE_DIR, 'modal-390-B-full.png') }); + summary.mobileLayout = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + modal: document.querySelector('.share-modal-content').getBoundingClientRect().toJSON(), + footer: document.querySelector('#share-modal .modal-footer').getBoundingClientRect().toJSON(), + })); + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + + summary.extensionErrors = consoleMessages.filter((message) => ( + message.type === 'pageerror' || (message.url || '').startsWith('chrome-extension://') + )); + expect(summary.steps.aFull.constraints).toEqual([expect.objectContaining({ + video: expect.objectContaining({ cursor: 'never' }), + })]); + expect(summary.steps.aFull.streamStatus).toContain('Sharing this tab for snapshots'); + // Two Shares, one prompt from Chrome. + expect(summary.steps.secondShare.displayMediaCalls).toBe(1); + expect(summary.steps.aFull.alignment?.map((entry) => entry.name)).toHaveLength(V3_PROVIDERS.length); + // Every panel was stitched, none left as on screen. + expect(summary.steps.aFull.alignment.filter((entry) => !entry.alignedBy)).toEqual([]); + expect(summary.steps.bFull.alignment?.filter((entry) => !entry.alignedBy)).toEqual([]); + expect(summary.extensionErrors).toEqual([]); + } finally { + await writeFile(path.join(V3_EVIDENCE_DIR, 'summary.json'), `${JSON.stringify({ ...summary, consoleMessages }, null, 2)}\n`); + if (page && originalTheme) { + await setTheme(page, originalTheme).catch(() => {}); + } + await extension.close(); + } +}); diff --git a/tests/panel-fit.test.js b/tests/panel-fit.test.js new file mode 100644 index 0000000..11b9c29 --- /dev/null +++ b/tests/panel-fit.test.js @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + PANEL_FIT_BOUNCE_MS, + PANEL_FIT_MIN_SCALE, + PANEL_FIT_TOLERANCE_PX, + applyPanelFitScale, + createPanelFitState, + getPanelFitScale, + getPanelPageWidth, + updatePanelFitState +} from '../modules/panel-fit.js'; + +function fit(steps) { + return steps.reduce((state, step) => updatePanelFitState(state, step), createPanelFitState()); +} + +describe('getPanelPageWidth', () => { + it('keeps the panel width when the page fits or reports nothing', () => { + expect(getPanelPageWidth(320, 0)).toBe(320); + expect(getPanelPageWidth(320, 300)).toBe(320); + expect(getPanelPageWidth(320, 320 + PANEL_FIT_TOLERANCE_PX)).toBe(320); + }); + + it('widens the page to what it needs', () => { + expect(getPanelPageWidth(320, 375)).toBe(375); + expect(getPanelPageWidth(320, 400.2)).toBe(401); + }); + + it('stops at the smallest scale', () => { + expect(getPanelPageWidth(320, 1400)).toBe(320 / PANEL_FIT_MIN_SCALE); + }); + + it('ignores a panel with no width', () => { + expect(getPanelPageWidth(0, 728)).toBe(0); + }); +}); + +describe('updatePanelFitState', () => { + it('scales a page that needs more room down to the panel', () => { + const state = fit([{ available: 320, needed: 375, now: 0 }]); + expect(state.pageWidth).toBe(375); + expect(getPanelFitScale(state)).toBeCloseTo(320 / 375); + }); + + it('gives the room back once the page no longer needs it', () => { + const state = fit([ + { available: 320, needed: 728, now: 0 }, + { available: 320, needed: 468, now: 5000 } + ]); + expect(state.pageWidth).toBe(468); + expect(fit([ + { available: 320, needed: 728, now: 0 }, + { available: 320, needed: 0, now: 5000 } + ]).pageWidth).toBe(320); + }); + + it('holds the wider width for a page that flips between layouts', () => { + const flipped = fit([ + { available: 320, needed: 400, now: 0 }, + { available: 320, needed: 0, now: 5000 }, + { available: 320, needed: 400, now: 5000 + PANEL_FIT_BOUNCE_MS - 1 } + ]); + expect(flipped.locked).toBe(true); + const held = updatePanelFitState(flipped, { available: 320, needed: 0, now: 9000 }); + expect(held.pageWidth).toBe(400); + expect(getPanelFitScale(held)).toBeCloseTo(0.8); + }); + + it('starts over when the panel width changes', () => { + const locked = fit([ + { available: 320, needed: 400, now: 0 }, + { available: 320, needed: 0, now: 100 }, + { available: 320, needed: 400, now: 200 } + ]); + const resized = updatePanelFitState(locked, { available: 500, needed: 0, now: 300 }); + expect(resized.locked).toBe(false); + expect(resized.pageWidth).toBe(500); + expect(getPanelFitScale(resized)).toBe(1); + }); + + it('lets a slow change back to a wider layout through without locking', () => { + const state = fit([ + { available: 320, needed: 400, now: 0 }, + { available: 320, needed: 0, now: 100 }, + { available: 320, needed: 400, now: 100 + PANEL_FIT_BOUNCE_MS } + ]); + expect(state.locked).toBe(false); + expect(state.pageWidth).toBe(400); + }); +}); + +describe('applyPanelFitScale', () => { + it('scales the frame only when the page is wider than the panel', () => { + const iframe = document.createElement('iframe'); + applyPanelFitScale(iframe, 0.8); + expect(iframe.classList.contains('panel-fitted')).toBe(true); + expect(iframe.style.getPropertyValue('--panel-fit-scale')).toBe('0.8'); + applyPanelFitScale(iframe, 1); + expect(iframe.classList.contains('panel-fitted')).toBe(false); + expect(iframe.style.getPropertyValue('--panel-fit-scale')).toBe(''); + }); +}); diff --git a/tests/panel-resize.test.js b/tests/panel-resize.test.js new file mode 100644 index 0000000..a08a0f5 --- /dev/null +++ b/tests/panel-resize.test.js @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + PANEL_RESIZE_MIN_WIDTH, + getColumnTemplate, + getColumnWeights, + isValidColumnWeights, + resizeColumns, + setupPanelResize +} from '../modules/panel-resize.js'; + +describe('resizeColumns', () => { + it('trades width with the neighbor when the columns fit the window', () => { + expect(resizeColumns([600, 600, 600], 0, 150, { scrolls: false, available: 1800 })) + .toEqual([750, 450, 600]); + expect(resizeColumns([600, 600, 600], 1, -100, { scrolls: false, available: 1800 })) + .toEqual([600, 500, 700]); + }); + + it('keeps both neighbors at least the minimum width', () => { + expect(resizeColumns([600, 600], 0, 900, { scrolls: false, available: 1200 })) + .toEqual([1200 - PANEL_RESIZE_MIN_WIDTH, PANEL_RESIZE_MIN_WIDTH]); + expect(resizeColumns([600, 600], 0, -900, { scrolls: false, available: 1200 })) + .toEqual([PANEL_RESIZE_MIN_WIDTH, 1200 - PANEL_RESIZE_MIN_WIDTH]); + }); + + it('does not force columns already narrower than the minimum wider', () => { + expect(resizeColumns([150, 150], 0, 10, { scrolls: false, available: 300 })).toEqual([150, 150]); + }); + + it('grows a column alone when the columns scroll sideways', () => { + expect(resizeColumns([320, 320, 320, 320], 1, 100, { scrolls: true, available: 1000 })) + .toEqual([320, 420, 320, 320]); + }); + + it('shrinks a scrolling column no further than fitting the window again', () => { + expect(resizeColumns([320, 320, 320, 320], 1, -100, { scrolls: true, available: 1000 })) + .toEqual([320, 220, 320, 320]); + expect(resizeColumns([320, 320, 320, 320], 1, -300, { scrolls: true, available: 1240 })) + .toEqual([320, 280, 320, 320]); + }); +}); + +describe('getColumnWeights', () => { + it('weights columns that fit the window by their share of it', () => { + expect(getColumnWeights([750, 450, 600], 1800, 400)).toEqual([1.25, 0.75, 1]); + }); + + it('weights scrolling columns against the layout minimum', () => { + expect(getColumnWeights([480, 320, 320], 900, 320)).toEqual([1.5, 1, 1]); + }); + + it('works for layouts without a minimum', () => { + expect(getColumnWeights([300, 500], 800, 0)).toEqual([0.75, 1.25]); + }); +}); + +describe('getColumnTemplate', () => { + it('gives each column a minimum and a share in proportion to its weight', () => { + expect(getColumnTemplate([1.5, 1], 320)).toBe('minmax(480px, 1.5fr) minmax(320px, 1fr)'); + expect(getColumnTemplate([0.75, 1.25], 0)).toBe('minmax(0px, 0.75fr) minmax(0px, 1.25fr)'); + }); + + it('reproduces the dragged widths', () => { + const widths = [750, 450, 600]; + const weights = getColumnWeights(widths, 1800, 400); + // 1fr resolves to available / sum(weights) when no minimum binds. + const fr = 1800 / weights.reduce((total, weight) => total + weight, 0); + expect(weights.map((weight) => weight * fr)).toEqual(widths); + }); +}); + +describe('isValidColumnWeights', () => { + it('accepts positive weights for every column only', () => { + expect(isValidColumnWeights([1, 1.5], 2)).toBe(true); + expect(isValidColumnWeights([1, 1.5], 3)).toBe(false); + expect(isValidColumnWeights([1, 0], 2)).toBe(false); + expect(isValidColumnWeights([1, 'x'], 2)).toBe(false); + expect(isValidColumnWeights(null, 2)).toBe(false); + }); +}); + +describe('setupPanelResize', () => { + it('does nothing on a page without a panel grid', async () => { + const storage = { get: vi.fn(), set: vi.fn() }; + await expect(setupPanelResize({ grid: null, getLayout: () => '1x3', storage, label: '' })) + .resolves.toBeUndefined(); + expect(storage.get).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/pointer-guard.test.js b/tests/pointer-guard.test.js new file mode 100644 index 0000000..b318710 --- /dev/null +++ b/tests/pointer-guard.test.js @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + POINTER_IDLE_MS, + POINTER_MAX_WAIT_MS, + canPointerReach, + createPointerGuard, + createPointerTracker +} from '../modules/pointer-guard.js'; + +const AREA = { left: 0, top: 100, right: 1200, bottom: 800, width: 1200, height: 700 }; + +function pointer(type, x, y, relatedTarget = null) { + const event = new Event(type, { bubbles: true }); + Object.assign(event, { clientX: x, clientY: y }); + Object.defineProperty(event, 'relatedTarget', { value: relatedTarget }); + return event; +} + +describe('canPointerReach', () => { + it('counts the pointer drawn just outside the area', () => { + expect(canPointerReach({ x: 600, y: 400 }, AREA)).toBe(true); + expect(canPointerReach({ x: 600, y: 80 }, AREA)).toBe(true); + expect(canPointerReach({ x: 600, y: 20 }, AREA)).toBe(false); + expect(canPointerReach({ x: -Infinity, y: -Infinity }, AREA)).toBe(false); + }); +}); + +describe('createPointerGuard', () => { + let area; + let tracker; + let guard; + + beforeEach(() => { + vi.useFakeTimers(); + area = document.createElement('div'); + area.getBoundingClientRect = () => AREA; + document.body.appendChild(area); + tracker = createPointerTracker({ target: window, now: () => Date.now() }); + }); + + afterEach(() => { + guard?.dispose(); + guard = null; + tracker.dispose(); + area.remove(); + vi.useRealTimers(); + }); + + const startGuard = (options = {}) => { + guard = createPointerGuard({ area, tracker, now: () => Date.now(), ...options }); + return guard; + }; + + async function settle(promise) { + let done = false; + promise.then(() => { done = true; }, () => { done = true; }); + await vi.advanceTimersByTimeAsync(0); + return () => done; + } + + it('shields the captured area until disposed', () => { + startGuard(); + const shield = document.querySelector('.snapshot-pointer-shield'); + expect(shield).not.toBeNull(); + expect(shield.style.top).toBe('100px'); + expect(shield.style.height).toBe('700px'); + guard.dispose(); + expect(document.querySelector('.snapshot-pointer-shield')).toBeNull(); + }); + + it('does not wait for a pointer that last moved away from the panels', async () => { + window.dispatchEvent(pointer('pointerdown', 600, 20)); + const onWaitChange = vi.fn(); + startGuard({ onWaitChange }); + const isDone = await settle(guard.waitUntilStill()); + expect(isDone()).toBe(true); + expect(onWaitChange).not.toHaveBeenCalled(); + }); + + it('does not wait for a pointer that left the page', async () => { + window.dispatchEvent(pointer('pointermove', 600, 400)); + document.body.dispatchEvent(pointer('pointerout', 600, 400, null)); + startGuard(); + const isDone = await settle(guard.waitUntilStill()); + expect(isDone()).toBe(true); + }); + + it('waits until the pointer over the panels has been still long enough', async () => { + const onWaitChange = vi.fn(); + startGuard({ onWaitChange }); + window.dispatchEvent(pointer('pointermove', 600, 400)); + const isDone = await settle(guard.waitUntilStill()); + expect(onWaitChange).toHaveBeenLastCalledWith(true); + + await vi.advanceTimersByTimeAsync(POINTER_IDLE_MS - 500); + // Moving again restarts the wait. + window.dispatchEvent(pointer('pointermove', 610, 400)); + await vi.advanceTimersByTimeAsync(POINTER_IDLE_MS - 1); + expect(isDone()).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(isDone()).toBe(true); + expect(onWaitChange).toHaveBeenLastCalledWith(false); + + // Still since then: the next frame does not wait. + expect(await settle(guard.waitUntilStill()).then(isNextDone => isNextDone())).toBe(true); + }); + + it('waits one idle period for a pointer last seen inside a panel', async () => { + const iframe = document.createElement('iframe'); + window.dispatchEvent(pointer('pointermove', 600, 400)); + document.body.dispatchEvent(pointer('pointerout', 600, 400, iframe)); + await vi.advanceTimersByTimeAsync(60_000); + startGuard(); + const isDone = await settle(guard.waitUntilStill()); + await vi.advanceTimersByTimeAsync(POINTER_IDLE_MS - 1); + expect(isDone()).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(isDone()).toBe(true); + }); + + it('gives up on a pointer that keeps moving', async () => { + startGuard(); + const isDone = await settle(guard.waitUntilStill()); + for (let elapsed = 0; elapsed < POINTER_MAX_WAIT_MS; elapsed += 500) { + window.dispatchEvent(pointer('pointermove', 600, 400 + (elapsed % 2))); + await vi.advanceTimersByTimeAsync(500); + } + expect(isDone()).toBe(true); + }); + + it('stops waiting when the capture is cancelled', async () => { + const onWaitChange = vi.fn(); + startGuard({ onWaitChange }); + const controller = new AbortController(); + const waiting = guard.waitUntilStill(controller.signal); + controller.abort(); + await expect(waiting).rejects.toMatchObject({ name: 'AbortError' }); + expect(onWaitChange).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/tests/rating-prompt.test.js b/tests/rating-prompt.test.js new file mode 100644 index 0000000..a7d6a4d --- /dev/null +++ b/tests/rating-prompt.test.js @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { + RATING_PROMPT_ACTIONS, + RATING_PROMPT_FIRST_THRESHOLD, + RATING_PROMPT_LATER_INTERVAL, + RATING_PROMPT_REVIEW_URL, + RATING_PROMPT_STORAGE_KEY, + applyRatingPromptAction, + countSuccessfulSend, + createInitialRatingPromptState, + loadRatingPromptState, + normalizeRatingPromptState, + recordSuccessfulSend, + saveRatingPromptAction, + shouldShowRatingPrompt +} from '../modules/rating-prompt.js'; + +function createMemoryStorage(initial = {}) { + const data = { ...initial }; + return { + data, + async get(key) { + return key in data ? { [key]: structuredClone(data[key]) } : {}; + }, + async set(items) { + Object.assign(data, structuredClone(items)); + } + }; +} + +function sendTimes(state, count) { + let next = state; + for (let index = 0; index < count; index += 1) { + next = recordSuccessfulSend(next); + } + return next; +} + +describe('rating prompt thresholds', () => { + it('uses the agreed thresholds and the Chrome Web Store reviews page', () => { + expect(RATING_PROMPT_FIRST_THRESHOLD).toBe(20); + expect(RATING_PROMPT_LATER_INTERVAL).toBe(50); + expect(RATING_PROMPT_REVIEW_URL).toBe( + 'https://chromewebstore.google.com/detail/panelize/iokalaafkmjffolodkkgbbccmofbglii/reviews' + ); + }); + + it('stays hidden until the 20th successful send', () => { + const before = sendTimes(createInitialRatingPromptState(), 19); + expect(before.sendCount).toBe(19); + expect(shouldShowRatingPrompt(before)).toBe(false); + + const at = recordSuccessfulSend(before); + expect(at.sendCount).toBe(20); + expect(shouldShowRatingPrompt(at)).toBe(true); + }); + + it('keeps showing after the threshold until the user answers', () => { + const state = sendTimes(createInitialRatingPromptState(), 23); + expect(shouldShowRatingPrompt(state)).toBe(true); + }); + + it('repairs missing or corrupt stored values', () => { + expect(normalizeRatingPromptState(undefined)).toEqual(createInitialRatingPromptState()); + expect(normalizeRatingPromptState({ sendCount: 'x', nextPromptAt: -3, dismissed: 'yes' })) + .toEqual(createInitialRatingPromptState()); + expect(normalizeRatingPromptState({ sendCount: 7.9 })).toEqual({ + sendCount: 7, + nextPromptAt: RATING_PROMPT_FIRST_THRESHOLD, + dismissed: false + }); + }); +}); + +describe('rating prompt dismiss paths', () => { + const shown = sendTimes(createInitialRatingPromptState(), 20); + + it('"Rate" never shows the banner again', () => { + const rated = applyRatingPromptAction(shown, RATING_PROMPT_ACTIONS.RATE); + expect(rated.dismissed).toBe(true); + expect(shouldShowRatingPrompt(sendTimes(rated, 500))).toBe(false); + }); + + it('"Don\'t ask again" never shows the banner again', () => { + const declined = applyRatingPromptAction(shown, RATING_PROMPT_ACTIONS.NEVER); + expect(declined.dismissed).toBe(true); + expect(shouldShowRatingPrompt(sendTimes(declined, 500))).toBe(false); + }); + + it('"Later" shows the banner again after 50 more sends', () => { + const later = applyRatingPromptAction(shown, RATING_PROMPT_ACTIONS.LATER); + expect(later).toEqual({ sendCount: 20, nextPromptAt: 70, dismissed: false }); + expect(shouldShowRatingPrompt(later)).toBe(false); + expect(shouldShowRatingPrompt(sendTimes(later, 49))).toBe(false); + expect(shouldShowRatingPrompt(sendTimes(later, 50))).toBe(true); + + const laterAgain = applyRatingPromptAction(sendTimes(later, 55), RATING_PROMPT_ACTIONS.LATER); + expect(laterAgain.nextPromptAt).toBe(125); + }); + + it('rejects unknown actions', () => { + expect(() => applyRatingPromptAction(shown, 'maybe')).toThrow('Unknown rating prompt action'); + }); +}); + +describe('rating prompt storage', () => { + it('counts sends in chrome.storage.local under one key', async () => { + const storage = createMemoryStorage(); + expect(await loadRatingPromptState(storage)).toEqual(createInitialRatingPromptState()); + + for (let index = 0; index < RATING_PROMPT_FIRST_THRESHOLD; index += 1) { + await countSuccessfulSend(storage); + } + + expect(storage.data[RATING_PROMPT_STORAGE_KEY]).toEqual({ + sendCount: 20, + nextPromptAt: 20, + dismissed: false + }); + expect(shouldShowRatingPrompt(await loadRatingPromptState(storage))).toBe(true); + }); + + it('persists each dismiss action', async () => { + const storage = createMemoryStorage({ + [RATING_PROMPT_STORAGE_KEY]: { sendCount: 20, nextPromptAt: 20, dismissed: false } + }); + + await saveRatingPromptAction(RATING_PROMPT_ACTIONS.LATER, storage); + expect(storage.data[RATING_PROMPT_STORAGE_KEY].nextPromptAt).toBe(70); + + await saveRatingPromptAction(RATING_PROMPT_ACTIONS.NEVER, storage); + expect(storage.data[RATING_PROMPT_STORAGE_KEY].dismissed).toBe(true); + }); + + it('defaults to chrome.storage.local', async () => { + chrome.storage.local.get.mockResolvedValueOnce({ + [RATING_PROMPT_STORAGE_KEY]: { sendCount: 4, nextPromptAt: 20, dismissed: false } + }); + chrome.storage.local.set.mockClear(); + + const state = await countSuccessfulSend(); + + expect(state.sendCount).toBe(5); + expect(chrome.storage.local.set).toHaveBeenCalledWith({ + [RATING_PROMPT_STORAGE_KEY]: { sendCount: 5, nextPromptAt: 20, dismissed: false } + }); + }); +}); diff --git a/tests/settings.test.js b/tests/settings.test.js index 1df0cea..5a3072f 100644 --- a/tests/settings.test.js +++ b/tests/settings.test.js @@ -50,6 +50,14 @@ describe('settings module', () => { expect(chrome.storage.local.get).toHaveBeenCalled(); }); + it('should show the watermark on shared images by default', async () => { + chrome.storage.sync.get.mockImplementation(async (defaults) => defaults); + + const result = await getSettings(); + + expect(result.shareWatermarkEnabled).toBe(true); + }); + it('should expose the default Google provider mode', async () => { chrome.storage.sync.get.mockImplementation(async (defaults) => defaults); @@ -155,6 +163,13 @@ describe('settings module', () => { expect(result.skipped).toEqual([]); }); + it('should import the share watermark preference', async () => { + const result = await importSettings({ shareWatermarkEnabled: false }); + + expect(result.imported).toEqual(['shareWatermarkEnabled']); + expect(chrome.storage.sync.set).toHaveBeenCalledWith({ shareWatermarkEnabled: false }); + }); + it('should skip invalid setting keys', async () => { const settings = { theme: 'dark', diff --git a/tests/share-and-rating-locales.test.js b/tests/share-and-rating-locales.test.js new file mode 100644 index 0000000..f6de73a --- /dev/null +++ b/tests/share-and-rating-locales.test.js @@ -0,0 +1,96 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const LOCALES_DIR = resolve(process.cwd(), '_locales'); + +// Strings added for the store rating prompt, the share snapshot and the +// panel resize handles. +const FEATURE_KEYS = [ + 'ratingPromptMessage', + 'ratingPromptRate', + 'ratingPromptLater', + 'ratingPromptNever', + 'shareButtonText', + 'shareButtonTitle', + 'shareDialogTitle', + 'shareDialogClose', + 'sharePrivacyWarning', + 'shareCopyButton', + 'shareDownloadButton', + 'shareCopied', + 'shareCopyFailed', + 'shareCaptureCancelled', + 'shareCaptureWrongSurface', + 'shareCaptureFailed', + 'shareCaptureLayoutChanged', + 'shareModeLabel', + 'shareModeCurrent', + 'shareModeFull', + 'shareTitleLabel', + 'shareTitlePlaceholder', + 'shareSnapshotTagline', + 'shareSnapshotPromptLabel', + 'shareSnapshotCaption', + 'shareSnapshotSubline', + 'shareTruncatedNote', + 'shareInterruptedNote', + 'shareCaptureTabHidden', + 'shareCurrentViewOnly', + 'shareOutOfViewNote', + 'shareShortcutHint', + 'shareToolbarHint', + 'shareProgressPreparing', + 'shareProgressStep', + 'shareProgressHoldPointer', + 'shareProgressCancel', + 'shareStreamStatus', + 'shareStreamStop', + 'labelShareWatermark', + 'descShareWatermark', + 'msgShareWatermarkEnabled', + 'msgShareWatermarkDisabled', + 'panelResizeHandle' +]; + +const locales = readdirSync(LOCALES_DIR); + +describe('share and rating strings', () => { + it('covers all ten shipped locales', () => { + expect(locales.sort()).toEqual(['de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'ru', 'zh_CN', 'zh_TW']); + }); + + it.each(locales)('%s translates every new string', (locale) => { + const messages = JSON.parse(readFileSync(resolve(LOCALES_DIR, locale, 'messages.json'), 'utf8')); + FEATURE_KEYS.forEach((key) => { + expect(messages[key]?.message?.trim(), `${locale}.${key}`).toBeTruthy(); + }); + }); + + it('keeps the brand and site name in the watermark strings', () => { + locales.forEach((locale) => { + const messages = JSON.parse(readFileSync(resolve(LOCALES_DIR, locale, 'messages.json'), 'utf8')); + expect(messages.descShareWatermark.message, locale).toContain('Panelize'); + expect(messages.descShareWatermark.message, locale).toContain('panelize.dev'); + expect(messages.shareToolbarHint.message, locale).toContain('Panelize'); + }); + }); + + it('keeps every substitution in the snapshot strings', () => { + const substitutions = { + shareSnapshotSubline: ['$1'], + shareTruncatedNote: ['$1'], + shareInterruptedNote: ['$1'], + shareCurrentViewOnly: ['$1'], + shareOutOfViewNote: ['$1'], + shareShortcutHint: ['$1'], + shareProgressStep: ['$1', '$2'] + }; + locales.forEach((locale) => { + const messages = JSON.parse(readFileSync(resolve(LOCALES_DIR, locale, 'messages.json'), 'utf8')); + Object.entries(substitutions).forEach(([key, values]) => { + values.forEach((value) => expect(messages[key].message, `${locale}.${key}`).toContain(value)); + }); + }); + }); +}); diff --git a/tests/share-snapshot.test.js b/tests/share-snapshot.test.js new file mode 100644 index 0000000..3d7e59b --- /dev/null +++ b/tests/share-snapshot.test.js @@ -0,0 +1,521 @@ +import { describe, expect, it } from 'vitest'; +import { + SNAPSHOT_BRAND_NAME, + SNAPSHOT_FONT_FAMILY, + SNAPSHOT_FRAME_STYLES, + SNAPSHOT_MAX_CAPTURE_WIDTH, + SNAPSHOT_MAX_PROMPT_CHARS, + SNAPSHOT_SITE_LABEL, + SNAPSHOT_SITE_LINK_TEXT, + composeSnapshot, + formatProviderList, + formatSnapshotSubline, + getCaptureCrop, + getSnapshotFileName, + getSnapshotFrameChromeHeight, + getSnapshotFrameLayout, + getSnapshotFrameStyle, + normalizePromptText, + toLanguageTag, + wrapTextLines +} from '../modules/share-snapshot.js'; + +/** Every character is 10px wide. */ +const measure = (text) => Array.from(text).length * 10; + +/** Records canvas calls so drawing can be asserted without a real canvas. */ +function createRecordingCanvas() { + const calls = []; + const state = { font: '', fillStyle: '', textAlign: '', textBaseline: '', letterSpacing: '0px' }; + const ctx = new Proxy(state, { + get(target, prop) { + if (prop === 'measureText') { + return (text) => ({ width: measure(text) }); + } + if (['fillRect', 'fillText', 'drawImage', 'arc', 'createLinearGradient', 'createRadialGradient'].includes(prop)) { + if (prop.startsWith('create')) { + return (...args) => { + calls.push({ op: prop, args }); + return { addColorStop: () => {} }; + }; + } + return (...args) => calls.push({ + op: prop, + args, + fillStyle: target.fillStyle, + font: target.font + }); + } + if (prop in target) { + return target[prop]; + } + // Path, transform and state calls are not asserted. + return () => {}; + }, + set(target, prop, value) { + target[prop] = value; + return true; + } + }); + return { + calls, + width: 0, + height: 0, + getContext: () => ctx + }; +} + +describe('normalizePromptText', () => { + it('collapses whitespace and newlines', () => { + expect(normalizePromptText(' Compare\n\nthese\tanswers ')).toBe('Compare these answers'); + }); + + it('caps very long prompts with an ellipsis', () => { + const result = normalizePromptText('a'.repeat(SNAPSHOT_MAX_PROMPT_CHARS + 50)); + expect(Array.from(result)).toHaveLength(SNAPSHOT_MAX_PROMPT_CHARS + 1); + expect(result.endsWith('…')).toBe(true); + }); + + it('counts emoji and CJK as single characters', () => { + expect(normalizePromptText('你好😀世界', 3)).toBe('你好😀…'); + }); + + it('handles empty input', () => { + expect(normalizePromptText(undefined)).toBe(''); + expect(normalizePromptText(' ')).toBe(''); + }); +}); + +describe('wrapTextLines', () => { + it('keeps short text on one line', () => { + expect(wrapTextLines('Hello world', 200, measure)).toEqual(['Hello world']); + }); + + it('wraps at word boundaries', () => { + expect(wrapTextLines('one two three four', 90, measure)).toEqual(['one two', 'three', 'four']); + }); + + it('truncates the last allowed line with an ellipsis', () => { + const lines = wrapTextLines('alpha beta gamma delta epsilon zeta', 110, measure, 2); + expect(lines).toHaveLength(2); + expect(lines[0]).toBe('alpha beta'); + expect(lines[1].endsWith('…')).toBe(true); + expect(measure(lines[1])).toBeLessThanOrEqual(110); + }); + + it('breaks CJK text per character', () => { + const lines = wrapTextLines('比较不同的人工智能回答', 50, measure, 3); + expect(lines).toEqual(['比较不同的', '人工智能回', '答']); + }); + + it('breaks a single word wider than the line', () => { + const lines = wrapTextLines('x'.repeat(25), 100, measure, 3); + expect(lines).toEqual(['x'.repeat(10), 'x'.repeat(10), 'x'.repeat(5)]); + }); + + it('returns no lines for empty text', () => { + expect(wrapTextLines('', 100, measure)).toEqual([]); + }); +}); + +describe('getCaptureCrop', () => { + const rect = { left: 0, top: 52, width: 1280, height: 600 }; + + it('maps CSS pixels onto a high-DPI frame', () => { + expect(getCaptureCrop({ + frameWidth: 2560, + frameHeight: 1600, + viewportWidth: 1280, + viewportHeight: 800, + rect + })).toEqual({ sx: 0, sy: 104, sw: 2560, sh: 1200, width: 2560, height: 1200, scale: 2 }); + }); + + it('downscales captures wider than the cap', () => { + const crop = getCaptureCrop({ + frameWidth: 4050, + frameHeight: 2592, + viewportWidth: 1200, + viewportHeight: 768, + rect: { left: 0, top: 0, width: 1200, height: 768 } + }); + expect(crop.sw).toBe(4050); + expect(crop.width).toBe(SNAPSHOT_MAX_CAPTURE_WIDTH); + expect(crop.height).toBe(Math.round(2592 * (SNAPSHOT_MAX_CAPTURE_WIDTH / 4050))); + expect(crop.scale).toBeCloseTo(SNAPSHOT_MAX_CAPTURE_WIDTH / 1200); + }); + + it('clamps the area to the viewport', () => { + const crop = getCaptureCrop({ + frameWidth: 1000, + frameHeight: 500, + viewportWidth: 1000, + viewportHeight: 500, + rect: { left: -10, top: 400, width: 1100, height: 300 } + }); + expect(crop).toMatchObject({ sx: 0, sy: 400, sw: 1000, sh: 100 }); + }); + + it('rejects empty frames and areas outside the viewport', () => { + expect(() => getCaptureCrop({ + frameWidth: 0, frameHeight: 0, viewportWidth: 100, viewportHeight: 100, rect + })).toThrow('no size'); + expect(() => getCaptureCrop({ + frameWidth: 100, frameHeight: 100, viewportWidth: 100, viewportHeight: 100, + rect: { left: 0, top: 200, width: 100, height: 50 } + })).toThrow('outside the viewport'); + }); +}); + +const FRAME = { + light: SNAPSHOT_FRAME_STYLES.light, + dark: SNAPSHOT_FRAME_STYLES.dark +}; + +describe('getSnapshotFrameLayout', () => { + it('lays out theme A (light): brand row, prompt label, headline, subline, card', () => { + const style = FRAME.light; + const layout = getSnapshotFrameLayout({ + contentWidth: 1392, + contentHeight: 800, + scale: 1, + theme: 'light', + titleLineCount: 1, + hasSubline: true + }); + + expect(layout.style.variant).toBe('a'); + expect(layout.width).toBe(1392 + style.paddingX * 2); + expect(layout.width).toBe(1520); + expect(layout.brand).toMatchObject({ x: 64, y: 48, height: 34, iconSize: 34 }); + expect(layout.label).toMatchObject({ y: 48 + 34 + 40, height: 16 }); + expect(layout.title).toMatchObject({ y: 48 + 34 + 40 + 16 + 10, lineHeight: 45, height: 45, maxWidth: 1240 }); + expect(layout.brand).toMatchObject({ right: 64 + 1392, pillHeight: 34, ctaGap: 14 }); + expect(layout.subline).toMatchObject({ x: 64, y: layout.title.y + 45 + 10, height: 22, maxWidth: 1392 }); + const cardY = layout.subline.y + 22 + 32; + expect(layout.card).toMatchObject({ x: 64, y: cardY, width: 1392, height: 800, titleBarHeight: 0, radius: 16 }); + expect(layout.content).toEqual({ x: 64, y: cardY, width: 1392, height: 800 }); + expect(layout.note).toBeNull(); + expect(layout.height).toBe(cardY + 800 + 56); + }); + + it('lays out theme B (dark): brand row, quoted headline, subline, window card', () => { + const layout = getSnapshotFrameLayout({ + contentWidth: 1392, + contentHeight: 800, + scale: 1, + theme: 'dark', + titleLineCount: 2, + hasSubline: true + }); + + expect(layout.style.variant).toBe('b'); + expect(layout.brand).toMatchObject({ y: 44, height: 34 }); + expect(layout.label).toBeNull(); + expect(layout.title).toMatchObject({ y: 44 + 34 + 36, lineHeight: 46, height: 92, maxWidth: 1200 }); + expect(layout.subline).toMatchObject({ y: layout.title.y + 92 + 12, height: 22 }); + const cardY = layout.subline.y + 22 + 34; + expect(layout.card).toMatchObject({ y: cardY, height: 38 + 800, titleBarHeight: 38, radius: 18, dotSize: 12 }); + expect(layout.content.y).toBe(cardY + 38); + expect(layout.height).toBe(cardY + 38 + 800 + 64); + }); + + it('scales every metric with the capture scale', () => { + const one = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'dark', titleLineCount: 1, hasSubline: true }); + const two = getSnapshotFrameLayout({ contentWidth: 2000, contentHeight: 1000, scale: 2, theme: 'dark', titleLineCount: 1, hasSubline: true }); + expect(two.width).toBe(one.width * 2); + expect(two.height).toBe(one.height * 2); + expect(two.title.font).toContain(`${FRAME.dark.titleFontSize * 2}px`); + }); + + it('collapses the headline when the title is empty', () => { + const light = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'light', titleLineCount: 0 }); + expect(light.label).toBeNull(); + expect(light.title).toBeNull(); + expect(light.card.y).toBe(48 + 34 + FRAME.light.cardGapWithoutHeadline); + + const dark = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'dark', titleLineCount: 0, hasSubline: true }); + expect(dark.title).toBeNull(); + // The subline stays and takes the headline's place under the brand row. + expect(dark.subline.y).toBe(44 + 34 + FRAME.dark.sublineGapWithoutTitle); + + const lightSubline = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'light', titleLineCount: 0, hasSubline: true }); + expect(lightSubline.label).toBeNull(); + expect(lightSubline.subline.y).toBe(48 + 34 + FRAME.light.sublineGapWithoutTitle); + }); + + it('drops the brand row with the watermark off but keeps headline and card', () => { + const light = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'light', titleLineCount: 1, showWatermark: false }); + expect(light.brand).toBeNull(); + expect(light.label).toMatchObject({ y: 48 }); + expect(light.title).not.toBeNull(); + expect(light.card).not.toBeNull(); + + const dark = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'dark', titleLineCount: 1, hasSubline: true, showWatermark: false }); + expect(dark.brand).toBeNull(); + expect(dark.title.y).toBe(44); + expect(dark.card.titleBarHeight).toBe(38); + + // The providers and date describe the content, so they stay. + const lightSubline = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'light', titleLineCount: 1, hasSubline: true, showWatermark: false }); + expect(lightSubline.subline).toMatchObject({ y: lightSubline.title.y + 45 + FRAME.light.sublineGap }); + + const bare = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'light', showWatermark: false }); + expect(bare.card.y).toBe(48); + expect(bare.height).toBe(48 + 500 + 56); + }); + + it('adds the truncation note under the card', () => { + const layout = getSnapshotFrameLayout({ contentWidth: 1000, contentHeight: 500, scale: 1, theme: 'light', hasNote: true }); + expect(layout.note).toMatchObject({ y: layout.card.y + 500 + FRAME.light.noteGap, height: FRAME.light.noteLineHeight }); + expect(layout.height).toBe(layout.note.y + layout.note.height + 56); + }); + + it('reports the height the frame adds around content', () => { + const options = { contentWidth: 1000, scale: 1, theme: 'dark', titleLineCount: 2, hasSubline: true }; + const chrome = getSnapshotFrameChromeHeight(options); + expect(getSnapshotFrameLayout({ ...options, contentHeight: 700 }).height).toBe(chrome + 700); + }); +}); + +describe('getSnapshotFrameStyle', () => { + it('follows the Panelize theme', () => { + expect(getSnapshotFrameStyle('dark')).toBe(SNAPSHOT_FRAME_STYLES.dark); + expect(getSnapshotFrameStyle('light')).toBe(SNAPSHOT_FRAME_STYLES.light); + expect(getSnapshotFrameStyle(undefined)).toBe(SNAPSHOT_FRAME_STYLES.light); + expect(FRAME.light.background).toBe('#F5F4F0'); + expect(FRAME.dark.background).toBe('#0E0F13'); + }); + + it('uses a system font stack with CJK families', () => { + expect(SNAPSHOT_FONT_FAMILY).toMatch(/^-apple-system/); + expect(SNAPSHOT_FONT_FAMILY).toContain('"PingFang SC"'); + expect(SNAPSHOT_FONT_FAMILY).toContain('"Microsoft YaHei"'); + expect(SNAPSHOT_FONT_FAMILY).toContain('"Noto Sans CJK SC"'); + expect(SNAPSHOT_FONT_FAMILY.endsWith('sans-serif')).toBe(true); + }); +}); + +describe('composeSnapshot', () => { + const content = { width: 1000, height: 500, id: 'content' }; + const icon = { width: 128, height: 128, id: 'icon' }; + const texts = (canvas) => canvas.calls.filter((call) => call.op === 'fillText').map((call) => call.args[0]); + + it('draws theme A with brand, tagline, site link, label, headline, subline and content', () => { + const canvas = createRecordingCanvas(); + const layout = composeSnapshot(canvas, { + content, + scale: 1, + icon, + title: 'Which answer is best?', + theme: 'light', + labelText: 'Prompt', + taglineText: 'Compare AI side by side', + captionText: 'Free Chrome extension', + sublineText: 'Same prompt · ChatGPT and Claude · side by side · Oct 22, 2026' + }); + + expect(canvas.width).toBe(layout.width); + expect(canvas.height).toBe(layout.height); + const drawn = canvas.calls.filter((call) => call.op === 'fillText'); + expect(drawn.map((call) => call.args[0])).toEqual([ + SNAPSHOT_BRAND_NAME, + '· Compare AI side by side', + 'Free Chrome extension', + SNAPSHOT_SITE_LINK_TEXT, + 'PROMPT', + 'Which answer is best?', + 'Same prompt · ChatGPT and Claude · side by side · Oct 22, 2026' + ]); + expect(SNAPSHOT_SITE_LINK_TEXT).toBe(`${SNAPSHOT_SITE_LABEL} ↗`); + expect(drawn[2]).toMatchObject({ fillStyle: FRAME.light.captionColor }); + expect(drawn[3]).toMatchObject({ fillStyle: FRAME.light.pillColor }); + expect(drawn[6]).toMatchObject({ fillStyle: FRAME.light.sublineColor }); + const background = canvas.calls.find((call) => call.op === 'fillRect'); + expect(background).toMatchObject({ fillStyle: FRAME.light.background, args: [0, 0, layout.width, layout.height] }); + const images = canvas.calls.filter((call) => call.op === 'drawImage'); + expect(images[0].args[0]).toBe(icon); + expect(images[1].args).toEqual([content, layout.content.x, layout.content.y, 1000, 500]); + }); + + it('draws theme B with quotes, the provider subline and window dots', () => { + const canvas = createRecordingCanvas(); + const layout = composeSnapshot(canvas, { + content, + scale: 1, + icon, + title: 'Which answer is best?', + theme: 'dark', + taglineText: 'ignored in B', + captionText: 'Free Chrome extension', + sublineText: 'Same prompt · ChatGPT and Claude · side by side · Oct 22, 2026' + }); + + const drawn = canvas.calls.filter((call) => call.op === 'fillText'); + expect(drawn.map((call) => call.args[0])).toEqual([ + SNAPSHOT_BRAND_NAME, + 'Free Chrome extension', + SNAPSHOT_SITE_LINK_TEXT, + '“', + 'Which answer is best?', + '”', + 'Same prompt · ChatGPT and Claude · side by side · Oct 22, 2026' + ]); + expect(drawn[2].fillStyle).toBe(FRAME.dark.pillColor); + expect(drawn[3].fillStyle).toBe(FRAME.dark.openQuoteColor); + expect(drawn[5].fillStyle).toBe(FRAME.dark.closeQuoteColor); + expect(canvas.calls.filter((call) => call.op === 'arc')).toHaveLength(3); + expect(layout.content.y).toBe(layout.card.y + 38); + }); + + it('wraps long titles to at most three lines inside the headline width', () => { + const canvas = createRecordingCanvas(); + const layout = composeSnapshot(canvas, { + content: { width: 2000, height: 300 }, + scale: 1, + title: 'word '.repeat(300), + theme: 'light', + showWatermark: false + }); + const lines = texts(canvas); + expect(lines).toHaveLength(3); + expect(lines[2].endsWith('…')).toBe(true); + lines.forEach((line) => expect(measure(line)).toBeLessThanOrEqual(FRAME.light.titleMaxWidth)); + expect(layout.title.lineCount).toBe(3); + }); + + it('wraps CJK titles per character', () => { + const canvas = createRecordingCanvas(); + composeSnapshot(canvas, { + content: { width: 2000, height: 300 }, + scale: 1, + title: '请比较'.repeat(150), + theme: 'light', + showWatermark: false + }); + const lines = texts(canvas); + expect(lines).toHaveLength(3); + expect(Array.from(lines[0])).toHaveLength(FRAME.light.titleMaxWidth / 10); + expect(lines[2].endsWith('…')).toBe(true); + }); + + it('collapses the headline for an empty title', () => { + const canvas = createRecordingCanvas(); + const layout = composeSnapshot(canvas, { + content, + scale: 1, + title: ' ', + theme: 'light', + labelText: 'Prompt', + showWatermark: true + }); + expect(layout.title).toBeNull(); + expect(texts(canvas)).not.toContain('PROMPT'); + }); + + it('keeps the site link on a narrow brand row and drops the tagline, then the caption', () => { + const draw = (contentWidth) => { + const canvas = createRecordingCanvas(); + composeSnapshot(canvas, { + content: { width: contentWidth, height: 300 }, + scale: 1, + icon, + theme: 'light', + taglineText: 'Compare AI side by side', + captionText: 'Free Chrome extension' + }); + return texts(canvas); + }; + expect(draw(800)).toEqual([SNAPSHOT_BRAND_NAME, '· Compare AI side by side', 'Free Chrome extension', SNAPSHOT_SITE_LINK_TEXT]); + expect(draw(600)).toEqual([SNAPSHOT_BRAND_NAME, 'Free Chrome extension', SNAPSHOT_SITE_LINK_TEXT]); + expect(draw(400)).toEqual([SNAPSHOT_BRAND_NAME, SNAPSHOT_SITE_LINK_TEXT]); + }); + + it('wraps the subline once on a narrow capture so the date stays visible', () => { + const subline = 'Same prompt · ChatGPT and Grok · side by side · Oct 22, 2026'; + const canvas = createRecordingCanvas(); + const layout = composeSnapshot(canvas, { + content: { width: 400, height: 300 }, + scale: 1, + theme: 'dark', + showWatermark: false, + sublineText: subline + }); + const lines = texts(canvas); + expect(lines).toHaveLength(2); + expect(lines.join(' ')).toBe(subline); + expect(lines[1].endsWith('Oct 22, 2026')).toBe(true); + expect(layout.subline).toMatchObject({ lineHeight: 22, height: 44 }); + expect(layout.card.y).toBe(layout.subline.y + 44 + FRAME.dark.cardGap); + + const wide = composeSnapshot(createRecordingCanvas(), { + content: { width: 1000, height: 300 }, + scale: 1, + theme: 'dark', + showWatermark: false, + sublineText: subline + }); + expect(wide.subline.height).toBe(22); + }); + + it('draws no brand, caption or site link with the watermark off', () => { + const canvas = createRecordingCanvas(); + const layout = composeSnapshot(canvas, { + content, + scale: 1, + icon, + title: 'Still here', + theme: 'light', + labelText: 'Prompt', + taglineText: 'Compare AI side by side', + captionText: 'Free Chrome extension', + sublineText: 'Same prompt · ChatGPT · side by side · Oct 22, 2026', + showWatermark: false + }); + expect(layout.brand).toBeNull(); + expect(texts(canvas)).toEqual(['PROMPT', 'Still here', 'Same prompt · ChatGPT · side by side · Oct 22, 2026']); + const images = canvas.calls.filter((call) => call.op === 'drawImage'); + expect(images.map((call) => call.args[0])).toEqual([content]); + }); + + it('fades the content bottom and draws the note when truncated', () => { + const canvas = createRecordingCanvas(); + const layout = composeSnapshot(canvas, { + content, + scale: 1, + theme: 'dark', + showWatermark: false, + noteText: 'Long answers truncated' + }); + expect(texts(canvas)).toEqual(['Long answers truncated']); + expect(canvas.calls.some((call) => call.op === 'createLinearGradient')).toBe(true); + expect(layout.note).not.toBeNull(); + }); +}); + +describe('format helpers', () => { + it('formats the subline with a localized date', () => { + expect(formatSnapshotSubline({ providersText: 'Same prompt · ChatGPT and Grok · side by side', date: new Date(2026, 9, 22), locale: 'en-US' })) + .toBe('Same prompt · ChatGPT and Grok · side by side · Oct 22, 2026'); + expect(formatSnapshotSubline({ providersText: '同一提示词 · ChatGPT和Grok · 并排对比', date: new Date(2026, 9, 22), locale: 'zh-CN' })) + .toBe('同一提示词 · ChatGPT和Grok · 并排对比 · 2026年10月22日'); + expect(formatSnapshotSubline({ providersText: '', date: new Date(2026, 9, 22), locale: 'en-US' })).toBe('Oct 22, 2026'); + }); + + it('lists provider names once, in order', () => { + expect(formatProviderList(['ChatGPT', 'Claude', 'ChatGPT', 'Gemini'], 'en')).toBe('ChatGPT, Claude, and Gemini'); + expect(formatProviderList(['ChatGPT', 'Claude', 'Gemini'], 'zh-CN')).toBe('ChatGPT、Claude和Gemini'); + }); + + it('converts extension locale ids to language tags', () => { + expect(toLanguageTag('zh_CN')).toBe('zh-CN'); + expect(toLanguageTag('en')).toBe('en'); + expect(toLanguageTag('')).toBeUndefined(); + }); +}); + +describe('getSnapshotFileName', () => { + it('uses a sortable local timestamp', () => { + expect(getSnapshotFileName(new Date(2026, 9, 20, 9, 5, 3))).toBe('panelize-comparison-20261020-090503.png'); + }); +}); diff --git a/tests/share-trigger.test.js b/tests/share-trigger.test.js new file mode 100644 index 0000000..ae31289 --- /dev/null +++ b/tests/share-trigger.test.js @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getExecuteActionShortcut, + isMultiPanelPageUrl, + isMultiPanelTab +} from '../modules/share-trigger.js'; + +const MULTI_PANEL_URL = 'chrome-extension://abc/multi-panel/multi-panel.html'; + +function createRuntime(contexts = []) { + return { + getURL: (path) => `chrome-extension://abc/${path}`, + getContexts: vi.fn(async () => contexts) + }; +} + +describe('isMultiPanelPageUrl', () => { + it('matches the page with any query or hash', () => { + expect(isMultiPanelPageUrl(MULTI_PANEL_URL, MULTI_PANEL_URL)).toBe(true); + expect(isMultiPanelPageUrl(`${MULTI_PANEL_URL}?mode=popup#x`, MULTI_PANEL_URL)).toBe(true); + }); + + it('rejects other pages', () => { + expect(isMultiPanelPageUrl('chrome-extension://abc/options/options.html', MULTI_PANEL_URL)).toBe(false); + expect(isMultiPanelPageUrl('https://chatgpt.com/', MULTI_PANEL_URL)).toBe(false); + expect(isMultiPanelPageUrl(undefined, MULTI_PANEL_URL)).toBe(false); + }); +}); + +describe('isMultiPanelTab', () => { + it('uses the tab URL when Chrome provides it', async () => { + const runtime = createRuntime(); + expect(await isMultiPanelTab({ id: 3, url: MULTI_PANEL_URL }, runtime)).toBe(true); + expect(await isMultiPanelTab({ id: 3, url: 'https://example.com/' }, runtime)).toBe(false); + expect(await isMultiPanelTab({ id: 3, pendingUrl: MULTI_PANEL_URL }, runtime)).toBe(true); + expect(runtime.getContexts).not.toHaveBeenCalled(); + }); + + it('asks for the tab contexts when the URL is hidden', async () => { + const runtime = createRuntime([{ frameId: 0, documentUrl: MULTI_PANEL_URL }]); + expect(await isMultiPanelTab({ id: 7 }, runtime)).toBe(true); + expect(runtime.getContexts).toHaveBeenCalledWith({ contextTypes: ['TAB'], tabIds: [7] }); + expect(await isMultiPanelTab({ id: 7 }, createRuntime([]))).toBe(false); + }); + + it('ignores missing tabs', async () => { + expect(await isMultiPanelTab(undefined, createRuntime())).toBe(false); + expect(await isMultiPanelTab({ id: -1, url: MULTI_PANEL_URL }, createRuntime())).toBe(false); + }); +}); + +describe('getExecuteActionShortcut', () => { + it('returns the shortcut Chrome reports for the toolbar icon', () => { + expect(getExecuteActionShortcut([ + { name: 'open-prompt-library', shortcut: '⇧⌘P' }, + { name: '_execute_action', shortcut: '⇧⌘E' } + ])).toBe('⇧⌘E'); + }); + + it('returns an empty string when it is unbound', () => { + expect(getExecuteActionShortcut([{ name: '_execute_action', shortcut: '' }])).toBe(''); + expect(getExecuteActionShortcut(undefined)).toBe(''); + }); +}); diff --git a/tests/snapshot-capture.test.js b/tests/snapshot-capture.test.js new file mode 100644 index 0000000..78078b5 --- /dev/null +++ b/tests/snapshot-capture.test.js @@ -0,0 +1,653 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + SNAPSHOT_MESSAGES, + SnapshotLayoutChangedError, + captureCurrentView, + captureFullAnswers, + createSnapshotBridge, + getCaptureArea, + getPanelCellGeometry, + getStitchBudget, + intersectRects, + selectVisiblePanels +} from '../modules/snapshot-capture.js'; + +const rect = (left, top, width, height) => ({ left, top, width, height, right: left + width, bottom: top + height }); + +function messageEvent(data, source) { + const event = new Event('message'); + Object.defineProperty(event, 'data', { value: data }); + Object.defineProperty(event, 'source', { value: source }); + return event; +} + +describe('createSnapshotBridge', () => { + function createFrame(host, reply) { + const contentWindow = { + postMessage: vi.fn((message) => { + const answer = reply(message); + if (answer) { + queueMicrotask(() => host.dispatchEvent(messageEvent(answer, answer.source || contentWindow))); + } + }) + }; + return { contentWindow }; + } + + it('sends the request to the frame and resolves with its state', async () => { + const host = new EventTarget(); + const bridge = createSnapshotBridge(host); + const frame = createFrame(host, (message) => ({ + type: SNAPSHOT_MESSAGES.STATE, + context: 'multi-panel-snapshot', + requestId: message.requestId, + scrollTop: 120 + })); + const state = await bridge.request(frame, SNAPSHOT_MESSAGES.PREPARE, { align: true, prompt: 'Hi' }); + expect(state.scrollTop).toBe(120); + expect(frame.contentWindow.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: SNAPSHOT_MESSAGES.PREPARE, + context: 'multi-panel', + align: true, + prompt: 'Hi', + sessionId: bridge.sessionId + }), '*'); + bridge.dispose(); + }); + + it('ignores replies from another frame and times out', async () => { + vi.useFakeTimers(); + try { + const host = new EventTarget(); + const bridge = createSnapshotBridge(host); + const impostor = {}; + const frame = createFrame(host, (message) => ({ + type: SNAPSHOT_MESSAGES.STATE, + context: 'multi-panel-snapshot', + requestId: message.requestId, + source: impostor + })); + const pending = bridge.request(frame, SNAPSHOT_MESSAGES.SCROLL, { scrollTop: 10 }, 500); + await vi.advanceTimersByTimeAsync(600); + expect(await pending).toBeNull(); + bridge.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('resolves null for frames without a window', async () => { + const bridge = createSnapshotBridge(new EventTarget()); + expect(await bridge.request({}, SNAPSHOT_MESSAGES.RESTORE)).toBeNull(); + bridge.dispose(); + }); +}); + +describe('panel geometry', () => { + const visibleGrid = rect(0, 50, 1200, 700); + + it('intersects rectangles', () => { + expect(intersectRects(rect(0, 0, 100, 100), rect(50, 50, 100, 100))).toMatchObject({ left: 50, top: 50, width: 50, height: 50 }); + expect(intersectRects(rect(0, 0, 10, 10), rect(50, 50, 10, 10))).toMatchObject({ width: 0, height: 0 }); + }); + + const ids = ({ captured, outOfView }) => ({ + captured: captured.map((panel) => panel.id), + outOfView: outOfView.map((panel) => panel.id) + }); + + it('takes only panels fully on screen and lists the cut ones as left out', () => { + // Six 400px columns in a 1200px grid, scrolled to the start. + const panels = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, index) => ({ + id, rect: rect(4 + index * 404, 54, 400, 690) + })); + expect(ids(selectVisiblePanels(panels, visibleGrid))).toEqual({ + captured: ['a', 'b'], + outOfView: ['c', 'd', 'e', 'f'] + }); + }); + + it('skips hidden panels without listing them', () => { + const panels = [ + { id: 'a', rect: rect(4, 54, 590, 690) }, + { id: 'b', rect: rect(598, 54, 590, 690), hidden: true }, + { id: 'c', rect: rect(1180, 54, 590, 690) } + ]; + expect(ids(selectVisiblePanels(panels, visibleGrid))).toEqual({ captured: ['a'], outOfView: ['c'] }); + }); + + it('counts a panel cut by a subpixel as fully on screen', () => { + const panels = [{ id: 'a', rect: rect(-0.5, 54, 400, 690) }, { id: 'b', rect: rect(798.4, 54, 403, 690) }]; + expect(ids(selectVisiblePanels(panels, visibleGrid))).toEqual({ captured: ['a', 'b'], outOfView: [] }); + }); + + it('falls back to the most visible panel when none fits', () => { + // A window narrower than one column. + const narrow = rect(0, 50, 300, 700); + const panels = [ + { id: 'a', rect: rect(-280, 54, 400, 690) }, + { id: 'b', rect: rect(124, 54, 400, 690) }, + { id: 'c', rect: rect(528, 54, 400, 690) } + ]; + expect(ids(selectVisiblePanels(panels, narrow))).toEqual({ captured: ['b'], outOfView: ['a', 'c'] }); + expect(ids(selectVisiblePanels([{ id: 'a', rect: rect(270, 54, 400, 690) }], narrow))) + .toEqual({ captured: [], outOfView: ['a'] }); + }); + + it('captures the panels plus the grid padding, within the visible grid', () => { + const panels = [{ rect: rect(408, 54, 400, 690) }, { rect: rect(812, 54, 400, 340) }, { rect: rect(812, 398, 400, 346) }]; + expect(getCaptureArea(panels, rect(0, 50, 1300, 700))).toMatchObject({ left: 404, top: 50, right: 1216, bottom: 748 }); + expect(getCaptureArea([], visibleGrid)).toBe(visibleGrid); + }); + + it('keeps the side padding even when the grid is scrolled flush to a panel', () => { + // Snapped to the start: the grid padding scrolled out on the left. + const panels = [{ rect: rect(0, 54, 400, 690) }, { rect: rect(404, 54, 400, 690) }]; + expect(getCaptureArea(panels, visibleGrid)).toMatchObject({ left: 0, right: 804 }); + // The most visible panel of a narrow window, cut on the left. + expect(getCaptureArea([{ rect: rect(-100, 54, 400, 690) }], rect(0, 50, 250, 700))) + .toMatchObject({ left: 0, right: 250 }); + }); + + it('maps the conversation clip of a frame onto the page', () => { + const geometry = getPanelCellGeometry({ + panelRect: rect(1000, 54, 590, 690), + iframeRect: rect(1000, 90, 590, 654), + state: { clip: { left: 0, top: 56, right: 560, bottom: 534 }, viewport: { width: 590, height: 654 } }, + visibleGrid + }); + expect(geometry).toEqual({ + left: 1000, + right: 1200, + top: 54, + headBottom: 146, + contentLeft: 1000, + contentRight: 1200, + zoom: 1 + }); + }); +}); + +describe('getStitchBudget', () => { + it('keeps the device scale and leaves room for the frame', () => { + const budget = getStitchBudget({ frameScale: 2, gridWidth: 1200, getChromeHeight: (scale) => 300 * scale }); + expect(budget).toEqual({ scale: 2, contentHeight: (8000 - 600) / 2 }); + }); + + it('caps the output width at 2560 pixels', () => { + const budget = getStitchBudget({ frameScale: 2, gridWidth: 1600, getChromeHeight: () => 0 }); + expect(budget.scale).toBe(1.6); + expect(budget.contentHeight).toBe(5000); + }); +}); + +describe('captureCurrentView', () => { + const originalCreateImageBitmap = globalThis.createImageBitmap; + + afterEach(() => { + globalThis.createImageBitmap = originalCreateImageBitmap; + document.body.innerHTML = ''; + }); + + /** A panel whose page answers every snapshot request. */ + function createPanel(name, left, state) { + const element = document.createElement('div'); + element.getBoundingClientRect = () => rect(left, 0, 400, 600); + document.body.appendChild(element); + const messages = []; + const contentWindow = { + postMessage: vi.fn((message) => { + messages.push(message); + queueMicrotask(() => window.dispatchEvent(messageEvent({ + type: SNAPSHOT_MESSAGES.STATE, + context: 'multi-panel-snapshot', + requestId: message.requestId, + clip: { left: 0, top: 50, right: 400, bottom: 500 }, + viewport: { width: 400, height: 560 }, + ...state + }, contentWindow))); + }) + }; + const iframe = { contentWindow, getBoundingClientRect: () => rect(left, 40, 400, 560) }; + return { panel: { id: name, name, iframe, element }, messages }; + } + + it('hides floating controls, and the pointer when asked, and reports where each panel started', async () => { + globalThis.createImageBitmap = vi.fn(async () => ({ width: 800, height: 600, close: vi.fn() })); + const grid = document.createElement('div'); + grid.getBoundingClientRect = () => rect(0, 0, 800, 600); + const chat = createPanel('ChatGPT', 0, { alignedBy: 'prompt', scrollTop: 900, originalScrollTop: 2000, maxScrollTop: 2000 }); + const quiet = createPanel('DeepSeek', 400, { alignedBy: null, scrollTop: 300, originalScrollTop: 300, maxScrollTop: 1000 }); + const frame = { width: window.innerWidth, height: window.innerHeight, close: vi.fn() }; + + const beforeGrab = vi.fn(async () => {}); + const result = await captureCurrentView({ + source: { + grab: async () => { + expect(beforeGrab).toHaveBeenCalledTimes(1); + return frame; + } + }, + grid, + panels: [chat.panel, quiet.panel], + prompt: 'Explain HTTPS', + hideCursor: true, + beforeGrab + }); + + expect(chat.messages[0]).toMatchObject({ + type: SNAPSHOT_MESSAGES.PREPARE, align: true, prompt: 'Explain HTTPS', stitch: false, hideFloating: true, hideCursor: true + }); + // Everything is put back afterwards. + expect(chat.messages.at(-1).type).toBe(SNAPSHOT_MESSAGES.RESTORE); + expect(quiet.messages.at(-1).type).toBe(SNAPSHOT_MESSAGES.RESTORE); + expect(result.alignment).toEqual([ + { name: 'ChatGPT', alignedBy: 'prompt', originalScrollTop: 2000, startScrollTop: 900, endScrollTop: 900, maxScrollTop: 2000 }, + { name: 'DeepSeek', alignedBy: null, originalScrollTop: 300, startScrollTop: 300, endScrollTop: 300, maxScrollTop: 1000 } + ]); + expect(frame.close).toHaveBeenCalled(); + }); +}); + +/** + * A panel whose page scrolls to wherever it is asked, in a 2000px conversation. + * `viewport` wider than the 400px iframe scales the page down to fit; + * `answers(message)` returning false makes the page stop answering; + * `scrollTo(scrollTop)` is where the page ends up when asked to scroll there. + */ +function createScrollingPanel(name, left, clip = { left: 0, top: 50, right: 400, bottom: 500 }, maxScrollTop = 2000, + { viewport = { width: 400, height: 560 }, answers = () => true, scrollTo = (scrollTop) => scrollTop } = {}) { + const element = document.createElement('div'); + element.getBoundingClientRect = () => rect(left, 0, 400, 600); + document.body.appendChild(element); + const messages = []; + const contentWindow = { + postMessage: vi.fn((message) => { + messages.push(message); + const answered = answers(message); + queueMicrotask(() => window.dispatchEvent(messageEvent(answered ? { + type: SNAPSHOT_MESSAGES.STATE, + context: 'multi-panel-snapshot', + requestId: message.requestId, + clip: { left: 0, top: 50, right: 400, bottom: 500 }, + viewport, + alignedBy: 'prompt', + scrollTop: message.scrollTop === undefined ? 0 : scrollTo(message.scrollTop), + originalScrollTop: 0, + maxScrollTop + } : { + type: SNAPSHOT_MESSAGES.STATE, + context: 'multi-panel-snapshot', + requestId: message.requestId, + error: 'no-session' + }, contentWindow))); + }) + }; + const iframe = { contentWindow, getBoundingClientRect: () => rect(left, 40, 400, 560) }; + return { panel: { id: name, name, iframe, element }, messages }; +} + +function createGrid() { + const grid = document.createElement('div'); + grid.getBoundingClientRect = () => rect(0, 0, 800, 600); + return grid; +} + +/** + * Frames of the whole tab; `onGrab(count)` runs before each is returned. + * `frameScale` is captured pixels per CSS pixel. + */ +function createSource(onGrab, frameScale = 1) { + const frames = []; + let count = 0; + return { + frames, + grab: vi.fn(async () => { + count += 1; + onGrab(count); + const frame = { width: window.innerWidth * frameScale, height: window.innerHeight * frameScale, close: vi.fn() }; + frames.push(frame); + return frame; + }) + }; +} + +describe('a window resized during a capture', () => { + const originalCreateImageBitmap = globalThis.createImageBitmap; + const originalInnerWidth = window.innerWidth; + + afterEach(() => { + globalThis.createImageBitmap = originalCreateImageBitmap; + window.innerWidth = originalInnerWidth; + document.body.innerHTML = ''; + }); + + it('stops full answers rather than crop panels that moved, and puts the pages back', async () => { + globalThis.createImageBitmap = vi.fn(async () => ({ width: 10, height: 10, close: vi.fn() })); + const chat = createScrollingPanel('ChatGPT', 0); + const deep = createScrollingPanel('DeepSeek', 400); + // A tiling window manager widens the window after the first screen. + const source = createSource((count) => { + if (count === 3) window.innerWidth = originalInnerWidth * 2; + }); + + await expect(captureFullAnswers({ + source, + grid: createGrid(), + panels: [chat.panel, deep.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + })).rejects.toBeInstanceOf(SnapshotLayoutChangedError); + + expect(source.grab).toHaveBeenCalledTimes(3); + expect(chat.messages.at(-1).type).toBe(SNAPSHOT_MESSAGES.RESTORE); + expect(deep.messages.at(-1).type).toBe(SNAPSHOT_MESSAGES.RESTORE); + expect(source.frames.every((frame) => frame.close.mock.calls.length > 0)).toBe(true); + }); + + it('stops full answers when a panel was resized in a window of the same size', async () => { + globalThis.createImageBitmap = vi.fn(async () => ({ width: 10, height: 10, close: vi.fn() })); + const chat = createScrollingPanel('ChatGPT', 0); + const deep = createScrollingPanel('DeepSeek', 400); + // The divider between the panels is dragged after the first screen. + const source = createSource((count) => { + if (count === 3) { + chat.panel.element.getBoundingClientRect = () => rect(0, 0, 580, 600); + chat.panel.iframe.getBoundingClientRect = () => rect(0, 40, 580, 560); + deep.panel.element.getBoundingClientRect = () => rect(580, 0, 220, 600); + deep.panel.iframe.getBoundingClientRect = () => rect(580, 40, 220, 560); + } + }); + + await expect(captureFullAnswers({ + source, + grid: createGrid(), + panels: [chat.panel, deep.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + })).rejects.toBeInstanceOf(SnapshotLayoutChangedError); + + expect(source.grab).toHaveBeenCalledTimes(3); + expect(chat.messages.at(-1).type).toBe(SNAPSHOT_MESSAGES.RESTORE); + expect(deep.messages.at(-1).type).toBe(SNAPSHOT_MESSAGES.RESTORE); + }); + + it('stops the current view when the window changed before the frame', async () => { + globalThis.createImageBitmap = vi.fn(async () => ({ width: 800, height: 600, close: vi.fn() })); + const chat = createScrollingPanel('ChatGPT', 0); + const source = createSource(() => { + window.innerWidth = originalInnerWidth / 2; + }); + + await expect(captureCurrentView({ + source, + grid: createGrid(), + panels: [chat.panel], + prompt: 'Explain HTTPS' + })).rejects.toBeInstanceOf(SnapshotLayoutChangedError); + + expect(globalThis.createImageBitmap).not.toHaveBeenCalled(); + expect(chat.messages.at(-1).type).toBe(SNAPSHOT_MESSAGES.RESTORE); + expect(source.frames[0].close).toHaveBeenCalled(); + }); + + it('captures full answers when the window keeps its size', async () => { + globalThis.createImageBitmap = vi.fn(async () => ({ width: 10, height: 10, close: vi.fn() })); + // jsdom has no canvas: a 2D context that draws nothing. + const context = new Proxy({}, { + get: (target, key) => (key in target ? target[key] : key === 'getImageData' + ? () => ({ data: new Uint8ClampedArray(4) }) + : () => {}), + set: (target, key, value) => { target[key] = value; return true; } + }); + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + const chat = createScrollingPanel('ChatGPT', 0); + const steps = []; + const source = createSource(() => steps.push('grab')); + const controller = new AbortController(); + const beforeGrab = vi.fn(async () => { + steps.push('wait'); + }); + + try { + const result = await captureFullAnswers({ + source, + grid: createGrid(), + panels: [chat.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100, + beforeGrab, + signal: controller.signal + }); + // Every frame waits first, e.g. for the pointer to keep still. + expect(steps).toEqual(Array.from({ length: result.screens }, () => ['wait', 'grab']).flat()); + expect(beforeGrab).toHaveBeenCalledWith(controller.signal); + expect(result.currentViewOnly).toEqual([]); + expect(result.truncated).toEqual([]); + expect(result.screens).toBe(source.grab.mock.calls.length); + expect(result.screens).toBeGreaterThan(1); + } finally { + getContext.mockRestore(); + } + }); +}); + +describe('stitched screens', () => { + const originalCreateImageBitmap = globalThis.createImageBitmap; + + afterEach(() => { + globalThis.createImageBitmap = originalCreateImageBitmap; + document.body.innerHTML = ''; + }); + + it('meet without a gap or an overlap at a fractional scale', async () => { + globalThis.createImageBitmap = vi.fn(async (frame, sx, sy, sw, sh, options) => ({ + width: options.resizeWidth, height: options.resizeHeight, close: vi.fn() + })); + const draws = []; + // jsdom has no canvas: a 2D context that records what is drawn. + const context = new Proxy({}, { + get: (target, key) => { + if (key in target) return target[key]; + if (key === 'drawImage') return (...args) => draws.push(args); + if (key === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) }); + return () => {}; + }, + set: (target, key, value) => { target[key] = value; return true; } + }); + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + // A page header ending between two pixels, like Gemini's. + const chat = createScrollingPanel('Gemini', 0, { left: 0, top: 50.4, right: 400, bottom: 500 }); + const source = createSource(() => {}, 1.37); + + try { + const result = await captureFullAnswers({ + source, + grid: createGrid(), + panels: [chat.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + }); + expect(result.screens).toBeGreaterThan(2); + // Head first, then one draw per screen, top to bottom. + const placed = draws.filter((args) => args.length === 5).slice(1) + .map(([, , y, , height]) => ({ y, height })); + expect(placed).toHaveLength(result.screens); + placed.slice(1).forEach((segment, index) => { + expect(segment.y).toBe(placed[index].y + placed[index].height); + }); + } finally { + getContext.mockRestore(); + } + }); + + it('name only the panels whose answer was cut short to fit the image', async () => { + globalThis.createImageBitmap = vi.fn(async (frame, sx, sy, sw, sh, options) => ({ + width: options.resizeWidth, height: options.resizeHeight, close: vi.fn() + })); + // jsdom has no canvas: a 2D context that draws nothing. + const context = new Proxy({}, { + get: (target, key) => (key in target ? target[key] : key === 'getImageData' + ? () => ({ data: new Uint8ClampedArray(4) }) + : () => {}), + set: (target, key, value) => { target[key] = value; return true; } + }); + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + const clip = { left: 0, top: 50, right: 400, bottom: 500 }; + // Far taller than the image allows, next to an answer that fits on one screen. + const long = createScrollingPanel('Kimi', 0, clip, 20000); + const short = createScrollingPanel('DeepSeek', 400, clip, 0); + + try { + const result = await captureFullAnswers({ + source: createSource(() => {}), + grid: createGrid(), + panels: [long.panel, short.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + }); + expect(result.names).toEqual(['Kimi', 'DeepSeek']); + expect(result.truncated).toEqual(['Kimi']); + } finally { + getContext.mockRestore(); + } + }); + + it('fit pages scaled down to their panel by their size on screen', async () => { + globalThis.createImageBitmap = vi.fn(async (frame, sx, sy, sw, sh, options) => ({ + width: options.resizeWidth, height: options.resizeHeight, close: vi.fn() + })); + // jsdom has no canvas: a 2D context that draws nothing. + const context = new Proxy({}, { + get: (target, key) => (key in target ? target[key] : key === 'getImageData' + ? () => ({ data: new Uint8ClampedArray(4) }) + : () => {}), + set: (target, key, value) => { target[key] = value; return true; } + }); + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + const clip = { left: 0, top: 50, right: 400, bottom: 500 }; + // 12000px laid out at 800px wide and drawn at half size: 6000px on screen, + // within the image, next to a page shown at full size. + const scaled = createScrollingPanel('Kimi', 0, clip, 11550, { viewport: { width: 800, height: 1120 } }); + const plain = createScrollingPanel('DeepSeek', 400, clip, 3000); + + try { + const result = await captureFullAnswers({ + source: createSource(() => {}), + grid: createGrid(), + panels: [scaled.panel, plain.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + }); + expect(result.truncated).toEqual([]); + expect(result.alignment.map(({ endScrollTop }) => endScrollTop)).toEqual([11550, 3000]); + } finally { + getContext.mockRestore(); + } + }); + + it('name the panels whose page stopped answering partway', async () => { + globalThis.createImageBitmap = vi.fn(async (frame, sx, sy, sw, sh, options) => ({ + width: options.resizeWidth, height: options.resizeHeight, close: vi.fn() + })); + // jsdom has no canvas: a 2D context that draws nothing. + const context = new Proxy({}, { + get: (target, key) => (key in target ? target[key] : key === 'getImageData' + ? () => ({ data: new Uint8ClampedArray(4) }) + : () => {}), + set: (target, key, value) => { target[key] = value; return true; } + }); + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + // The page navigates away (e.g. to a sign-in page) after its first screen. + const lost = createScrollingPanel('ChatGPT', 0, undefined, 2000, { + answers: (message) => message.type !== SNAPSHOT_MESSAGES.SCROLL + }); + const whole = createScrollingPanel('DeepSeek', 400); + + try { + const result = await captureFullAnswers({ + source: createSource(() => {}), + grid: createGrid(), + panels: [lost.panel, whole.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + }); + expect(result.names).toEqual(['ChatGPT', 'DeepSeek']); + expect(result.interrupted).toEqual(['ChatGPT']); + expect(result.truncated).toEqual([]); + expect(result.alignment[1].endScrollTop).toBe(2000); + } finally { + getContext.mockRestore(); + } + }); + + it('name the panels whose page would not scroll on, after asking again', async () => { + globalThis.createImageBitmap = vi.fn(async (frame, sx, sy, sw, sh, options) => ({ + width: options.resizeWidth, height: options.resizeHeight, close: vi.fn() + })); + // jsdom has no canvas: a 2D context that draws nothing. + const context = new Proxy({}, { + get: (target, key) => (key in target ? target[key] : key === 'getImageData' + ? () => ({ data: new Uint8ClampedArray(4) }) + : () => {}), + set: (target, key, value) => { target[key] = value; return true; } + }); + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + // One page keeps putting back its own position; the other does it once. + const stuck = createScrollingPanel('ChatGPT', 0, undefined, 2000, { scrollTo: () => 0 }); + let resets = 0; + const late = createScrollingPanel('DeepSeek', 400, undefined, 2000, { + scrollTo: (scrollTop) => (scrollTop > 0 && resets++ === 0 ? 0 : scrollTop) + }); + + try { + const result = await captureFullAnswers({ + source: createSource(() => {}), + grid: createGrid(), + panels: [stuck.panel, late.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + }); + expect(result.interrupted).toEqual(['ChatGPT']); + expect(result.truncated).toEqual([]); + expect(result.alignment.map(({ endScrollTop }) => endScrollTop)).toEqual([0, 2000]); + } finally { + getContext.mockRestore(); + } + }); + + it('name the panels whose page keeps jumping to its bottom, after asking again', async () => { + globalThis.createImageBitmap = vi.fn(async (frame, sx, sy, sw, sh, options) => ({ + width: options.resizeWidth, height: options.resizeHeight, close: vi.fn() + })); + // jsdom has no canvas: a 2D context that draws nothing. + const context = new Proxy({}, { + get: (target, key) => (key in target ? target[key] : key === 'getImageData' + ? () => ({ data: new Uint8ClampedArray(4) }) + : () => {}), + set: (target, key, value) => { target[key] = value; return true; } + }); + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + // One page goes back to its bottom on every scroll; the other does it once. + const jumpy = createScrollingPanel('ChatGPT', 0, undefined, 2000, { scrollTo: () => 2000 }); + let jumps = 0; + const late = createScrollingPanel('DeepSeek', 400, undefined, 2000, { + scrollTo: (scrollTop) => (scrollTop > 0 && jumps++ === 0 ? 2000 : scrollTop) + }); + + try { + const result = await captureFullAnswers({ + source: createSource(() => {}), + grid: createGrid(), + panels: [jumpy.panel, late.panel], + prompt: 'Explain HTTPS', + getChromeHeight: () => 100 + }); + expect(result.interrupted).toEqual(['ChatGPT']); + expect(result.truncated).toEqual([]); + expect(result.alignment[1].endScrollTop).toBe(2000); + } finally { + getContext.mockRestore(); + } + }); +}); diff --git a/tests/snapshot-scroll-content-script.test.js b/tests/snapshot-scroll-content-script.test.js new file mode 100644 index 0000000..9814050 --- /dev/null +++ b/tests/snapshot-scroll-content-script.test.js @@ -0,0 +1,791 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const contentScriptSource = readFileSync( + resolve(process.cwd(), 'content-scripts/text-injection-all-providers.js'), + 'utf8' +); + +const CONTAINER = { top: 40, left: 0, width: 800, height: 700, scrollHeight: 4000 }; + +function rect({ top, left = 0, width, height }) { + return { top, left, width, height, right: left + width, bottom: top + height, x: left, y: top }; +} + +function setStaticRect(element, box) { + element.getBoundingClientRect = () => rect(box); +} + +/** + * Builds a conversation in a scrolling container with stubbed geometry: + * happy-dom has no layout, so each message gets a fixed offset in the + * scrolled content. + */ +function createConversation(messages, { stickyHeader = true, composer = true } = {}) { + document.body.innerHTML = ''; + const container = document.createElement('div'); + container.style.overflowY = 'auto'; + let scrollTop = 0; + Object.defineProperties(container, { + scrollTop: { configurable: true, get: () => scrollTop, set: (value) => { scrollTop = value; } }, + scrollHeight: { configurable: true, get: () => CONTAINER.scrollHeight }, + clientHeight: { configurable: true, get: () => CONTAINER.height }, + clientWidth: { configurable: true, get: () => CONTAINER.width }, + clientTop: { configurable: true, get: () => 0 }, + clientLeft: { configurable: true, get: () => 0 } + }); + container.scrollTo = ({ top }) => { scrollTop = top; }; + setStaticRect(container, CONTAINER); + + if (stickyHeader) { + const header = document.createElement('header'); + header.style.position = 'sticky'; + header.textContent = 'Model picker'; + // Stuck to the top; once un-stuck, at the start of the content. + header.getBoundingClientRect = () => rect({ + top: CONTAINER.top - (header.style.position === 'sticky' ? 0 : scrollTop), width: CONTAINER.width, height: 56 + }); + container.appendChild(header); + } + + const elements = messages.map(({ text, offset, tag = 'div', attributes = {} }) => { + const element = document.createElement(tag); + element.textContent = text; + Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value)); + element.getBoundingClientRect = () => rect({ + top: CONTAINER.top + offset - scrollTop, width: 600, height: 40 + }); + container.appendChild(element); + return element; + }); + + if (composer) { + const box = document.createElement('div'); + box.style.position = 'sticky'; + const textarea = document.createElement('textarea'); + textarea.placeholder = 'Message DeepSeek'; + box.appendChild(textarea); + // Stuck to the bottom; once un-stuck, at the end of the content. + box.getBoundingClientRect = () => rect({ + top: CONTAINER.top + (box.style.position === 'sticky' ? CONTAINER.height : CONTAINER.scrollHeight - scrollTop) - 120, + width: CONTAINER.width, + height: 120 + }); + setStaticRect(textarea, { top: CONTAINER.top + CONTAINER.height - 100, left: 20, width: 760, height: 60 }); + container.appendChild(box); + } + + document.body.appendChild(container); + return { container, elements, getScrollTop: () => scrollTop, setScrollTop: (value) => { scrollTop = value; } }; +} + +let nextRequestId = 0; + +function sendSnapshotMessage(data, { source = window } = {}) { + const requestId = `request-${nextRequestId += 1}`; + const reply = new Promise((resolvePromise) => { + const listener = (event) => { + if (event.data?.type === 'PANELIZE_SNAPSHOT_STATE' && event.data.requestId === requestId) { + window.removeEventListener('message', listener); + resolvePromise(event.data); + } + }; + window.addEventListener('message', listener); + }); + window.dispatchEvent(new MessageEvent('message', { + data: { context: 'multi-panel', requestId, ...data }, + source + })); + return reply; +} + +const prepare = (prompt, extra = {}) => sendSnapshotMessage({ + type: 'PANELIZE_SNAPSHOT_PREPARE', sessionId: 'session-1', prompt, align: true, ...extra +}); + +describe('snapshot scroll protocol in provider frames', () => { + beforeAll(() => { + window.eval(contentScriptSource); + }); + + beforeEach(() => { + window.happyDOM.setURL('https://chat.deepseek.com/'); + }); + + it('scrolls the latest matching user message under the sticky header', async () => { + const { elements, getScrollTop } = createConversation([ + { text: 'Explain how HTTPS works in detail', offset: 300 }, + { text: 'An older answer', offset: 500 }, + { text: 'Explain how HTTPS works in detail', offset: 2200 }, + { text: 'The newest answer', offset: 2300 } + ]); + + const state = await prepare('Explain how HTTPS works in detail'); + + expect(state).toMatchObject({ matched: true, alignedBy: 'prompt', originalScrollTop: 0, sessionId: 'session-1', provider: 'deepseek' }); + // Sticky header (56px) and composer (120px) are cut from the clip. + expect(state.clip).toEqual({ left: 0, top: CONTAINER.top + 56, right: 800, bottom: CONTAINER.top + CONTAINER.height - 120 }); + expect(getScrollTop()).toBe(2200 - 56 - 16); + expect(elements[2].getBoundingClientRect().top).toBe(state.clip.top + 16); + expect(state.scrollTop).toBe(getScrollTop()); + expect(state.maxScrollTop).toBe(CONTAINER.scrollHeight - CONTAINER.height); + }); + + it('keeps a side rail out of the clip when the box beside it is scrolled sideways', async () => { + const { container } = createConversation([ + { text: 'Explain how HTTPS works in detail', offset: 300 }, + { text: 'The newest answer', offset: 400 } + ]); + // Like signed-in Qwen in a narrow panel: the content box beside a 60px + // rail is scrolled 30px sideways, so the conversation starts under it. + const rail = document.createElement('nav'); + setStaticRect(rail, { top: 0, left: 0, width: 60, height: 800 }); + const content = document.createElement('div'); + content.style.overflowX = 'auto'; + content.style.overflowY = 'auto'; + Object.defineProperties(content, { + clientWidth: { configurable: true, get: () => 740 }, + clientHeight: { configurable: true, get: () => 800 }, + clientTop: { configurable: true, get: () => 0 }, + clientLeft: { configurable: true, get: () => 0 } + }); + setStaticRect(content, { top: 0, left: 60, width: 740, height: 800 }); + setStaticRect(container, { ...CONTAINER, left: 30 }); + document.body.append(rail, content); + content.appendChild(container); + + const state = await prepare('Explain how HTTPS works in detail'); + + expect(state.clip.left).toBe(60); + expect(state.clip.right).toBe(800); + }); + + it('cuts the fading backdrop of a header laid over the conversation from the clip', async () => { + createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }], { stickyHeader: false }); + // Claude: an absolute title row next to the conversation, whose backdrop + // has no pointer events and fades out below the row. + const header = document.createElement('div'); + header.style.position = 'absolute'; + setStaticRect(header, { top: CONTAINER.top, width: CONTAINER.width, height: 48 }); + const button = document.createElement('button'); + setStaticRect(button, { top: CONTAINER.top + 10, left: 760, width: 28, height: 28 }); + const backdrop = document.createElement('div'); + backdrop.style.position = 'absolute'; + backdrop.style.pointerEvents = 'none'; + setStaticRect(backdrop, { top: CONTAINER.top, width: CONTAINER.width - 12, height: 72 }); + header.append(button, backdrop); + document.body.appendChild(header); + const originalElementFromPoint = document.elementFromPoint; + document.elementFromPoint = (x, y) => (y >= CONTAINER.top && y < CONTAINER.top + 48 ? button : null); + + try { + const state = await prepare('Explain how HTTPS works'); + expect(state.clip.top).toBe(CONTAINER.top + 72); + } finally { + document.elementFromPoint = originalElementFromPoint; + } + }); + + it('matches through whitespace, markdown, zero-width and full-width differences', async () => { + const { getScrollTop } = createConversation([ + { text: 'Explain how HTTPS​ works,\n step by step, for a curious beginner who knows a bit of networking', offset: 900 } + ]); + + const state = await prepare(' **Explain** how `HTTPS` works, step by step, for a curious beginner who knows nothing'); + + expect(state.matched).toBe(true); + expect(getScrollTop()).toBe(900 - 56 - 16); + }); + + it('prefers a message that starts with the prompt over a later quote inside an answer', async () => { + const { getScrollTop } = createConversation([ + { text: 'Why is the sky blue?', offset: 1000 }, + { text: 'You asked: why is the sky blue? Rayleigh scattering…', offset: 1200 } + ]); + + await prepare('Why is the sky blue?'); + + expect(getScrollTop()).toBe(1000 - 56 - 16); + }); + + it('prefers a marked user message over a later answer heading repeating the prompt', async () => { + const { getScrollTop } = createConversation([ + { text: 'Why is the sky blue?', offset: 1000, attributes: { 'data-message-author-role': 'user' } }, + { text: 'Rayleigh scattering…', offset: 1100 }, + { text: 'Why is the sky blue?', offset: 1800, tag: 'h2' } + ]); + + const state = await prepare('Why is the sky blue?'); + + expect(state).toMatchObject({ matched: true, alignedBy: 'prompt' }); + expect(getScrollTop()).toBe(1000 - 56 - 16); + }); + + it('ignores the prompt still sitting in the composer', async () => { + const { container, getScrollTop } = createConversation([{ text: 'Unrelated message', offset: 400 }]); + container.querySelector('textarea').value = 'Summarize this thread'; + container.querySelector('textarea').textContent = 'Summarize this thread'; + + const state = await prepare('Summarize this thread'); + + expect(state.matched).toBe(false); + expect(getScrollTop()).toBe(0); + }); + + it('leaves the panel untouched when nothing matches', async () => { + const { setScrollTop, getScrollTop } = createConversation([{ text: 'Something else entirely', offset: 400 }]); + setScrollTop(321); + + const state = await prepare('A prompt that was never sent here'); + + expect(state).toMatchObject({ matched: false, alignedBy: null, scrollTop: 321 }); + expect(getScrollTop()).toBe(321); + // The main conversation container is still reported for stitching. + expect(state.clip.top).toBe(CONTAINER.top + 56); + }); + + it('does not align when alignment is off', async () => { + const { getScrollTop } = createConversation([{ text: 'Explain how HTTPS works', offset: 2000 }]); + + const state = await prepare('Explain how HTTPS works', { align: false }); + + expect(state.matched).toBe(false); + expect(getScrollTop()).toBe(0); + }); + + it('scrolls to requested positions, clamps them, then restores the original position', async () => { + const { setScrollTop, getScrollTop } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + setScrollTop(77); + await prepare('Explain how HTTPS works'); + + const scrolled = await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_SCROLL', sessionId: 'session-1', scrollTop: 2000 }); + expect(scrolled.scrollTop).toBe(2000); + const clamped = await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_SCROLL', sessionId: 'session-1', scrollTop: 99999 }); + expect(clamped.scrollTop).toBe(CONTAINER.scrollHeight - CONTAINER.height); + + const restored = await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(restored).toMatchObject({ restored: true, scrollTop: 77 }); + expect(getScrollTop()).toBe(77); + + const stale = await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_SCROLL', sessionId: 'session-1', scrollTop: 10 }); + expect(stale.error).toBe('no-session'); + expect(getScrollTop()).toBe(77); + }); + + + it('skips matches in navigation and fixed layers', async () => { + const { getScrollTop } = createConversation([ + { text: 'Explain how HTTPS works', offset: 1200 } + ]); + // A sidebar title and a dialog, both after the message in the page. + const nav = document.createElement('nav'); + const title = document.createElement('a'); + title.href = '/c/1'; + title.textContent = 'Explain how HTTPS works'; + setStaticRect(title, { top: 100, width: 200, height: 20 }); + nav.appendChild(title); + const dialog = document.createElement('div'); + dialog.style.position = 'fixed'; + dialog.textContent = 'Explain how HTTPS works'; + setStaticRect(dialog, { top: 200, width: 400, height: 40 }); + document.body.append(nav, dialog); + + const state = await prepare('Explain how HTTPS works'); + + expect(state.matched).toBe(true); + expect(getScrollTop()).toBe(1200 - 56 - 16); + }); + + it('only aligns a match that moves with the scroll', async () => { + const { elements, getScrollTop } = createConversation([ + { text: 'Explain how HTTPS works', offset: 1200 }, + { text: 'Explain how HTTPS works', offset: 2400 } + ]); + // The latest copy stays put, e.g. it sits in a box that scrolls on its own. + setStaticRect(elements[1], { top: 500, width: 600, height: 40 }); + + const state = await prepare('Explain how HTTPS works'); + + expect(state).toMatchObject({ matched: true, alignedBy: 'prompt' }); + expect(getScrollTop()).toBe(1200 - 56 - 16); + }); + + describe('when full answers find no prompt', () => { + it('starts at the latest user message', async () => { + const { setScrollTop, getScrollTop } = createConversation([ + { text: 'First question', offset: 300, attributes: { 'data-message-author-role': 'user' } }, + { text: 'First answer', offset: 400 }, + { text: 'Second question', offset: 1800, attributes: { 'data-message-author-role': 'user' } }, + { text: 'Second answer', offset: 1900 } + ]); + setScrollTop(3300); + + const state = await prepare('A prompt typed into the provider directly', { stitch: true }); + + expect(state).toMatchObject({ matched: false, alignedBy: 'user-turn', originalScrollTop: 3300 }); + expect(getScrollTop()).toBe(1800 - 56 - 16); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(getScrollTop()).toBe(3300); + }); + + it('starts at the top when no user message is marked, never where the panel was', async () => { + const { setScrollTop, getScrollTop } = createConversation([{ text: 'Something else entirely', offset: 400 }]); + setScrollTop(3300); + + const state = await prepare('A prompt typed into the provider directly', { stitch: true }); + + expect(state).toMatchObject({ matched: false, alignedBy: 'top', scrollTop: 0, originalScrollTop: 3300 }); + expect(getScrollTop()).toBe(0); + const restored = await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(restored.scrollTop).toBe(3300); + }); + + it('also starts at the top without a prompt at all', async () => { + const { setScrollTop, getScrollTop } = createConversation([{ text: 'Something else entirely', offset: 400 }]); + setScrollTop(900); + + const state = await prepare('', { stitch: true, align: false }); + + expect(state.alignedBy).toBe('top'); + expect(getScrollTop()).toBe(0); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + }); + }); + + describe('on a page that puts itself back at its bottom', () => { + // Like a scroll listener keeping the conversation at its end. + function keepAtBottom(container, times) { + const descriptor = Object.getOwnPropertyDescriptor(container, 'scrollTop'); + const bottom = CONTAINER.scrollHeight - CONTAINER.height; + let left = times; + Object.defineProperty(container, 'scrollTop', { + configurable: true, + get: descriptor.get, + set: (value) => { + descriptor.set(value); + if (value < bottom && left > 0) { + left -= 1; + setTimeout(() => descriptor.set(bottom), 0); + } + } + }); + container.scrollTo = ({ top }) => { container.scrollTop = top; }; + } + + const messages = [ + { text: 'Explain how HTTPS works in detail', offset: 300 }, + { text: 'The answer', offset: 400 } + ]; + + it('says full answers could not start at the prompt when the page keeps going back', async () => { + const { container, setScrollTop, getScrollTop } = createConversation(messages); + setScrollTop(CONTAINER.scrollHeight - CONTAINER.height); + keepAtBottom(container, Infinity); + + const state = await prepare('Explain how HTTPS works in detail', { stitch: true }); + + expect(state.error).toBe('unaligned'); + expect(state.sessionId).toBe('session-1'); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(getScrollTop()).toBe(CONTAINER.scrollHeight - CONTAINER.height); + }); + + it('does not claim a current view is aligned when the page keeps going back', async () => { + const { container, setScrollTop } = createConversation(messages); + setScrollTop(CONTAINER.scrollHeight - CONTAINER.height); + keepAtBottom(container, Infinity); + + const state = await prepare('Explain how HTTPS works in detail'); + + expect(state).toMatchObject({ matched: false, alignedBy: null, scrollTop: CONTAINER.scrollHeight - CONTAINER.height }); + expect(state.error).toBeUndefined(); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + }); + + it('starts at the prompt when the page goes back only once', async () => { + const { container, setScrollTop, getScrollTop } = createConversation(messages); + setScrollTop(CONTAINER.scrollHeight - CONTAINER.height); + keepAtBottom(container, 1); + + const state = await prepare('Explain how HTTPS works in detail', { stitch: true }); + + expect(state.error).toBeUndefined(); + expect(state).toMatchObject({ alignedBy: 'prompt', scrollTop: 300 - 56 - 16 }); + expect(getScrollTop()).toBe(300 - 56 - 16); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + }); + }); + + it('hides the pointer in the frame while asked to, then shows it again', async () => { + createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const root = document.documentElement; + + await prepare('Explain how HTTPS works', { hideCursor: true }); + expect(root.hasAttribute('data-panelize-snapshot-cursor')).toBe(true); + const sheets = document.adoptedStyleSheets.length + document.querySelectorAll('#panelize-snapshot-style').length; + expect(sheets).toBe(1); + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(root.hasAttribute('data-panelize-snapshot-cursor')).toBe(false); + expect(document.adoptedStyleSheets.length + document.querySelectorAll('#panelize-snapshot-style').length).toBe(0); + }); + + describe('while stitching full answers', () => { + const CLIP_BOTTOM = CONTAINER.top + CONTAINER.height - 120; + + /** Absolute element with a fixed place on screen, inside `parent`. */ + function addOverlay(parent, { tag = 'div', text = '', box, offsetParent = parent }) { + const element = document.createElement(tag); + element.style.position = 'absolute'; + element.textContent = text; + setStaticRect(element, box); + Object.defineProperty(element, 'offsetParent', { configurable: true, get: () => offsetParent }); + parent.appendChild(element); + return element; + } + + function addFloatingParts(container) { + const composerBox = container.querySelector('textarea').parentElement; + return { + // Icon-only "Scroll to bottom" button above the composer. + button: addOverlay(composerBox, { + tag: 'button', text: '', box: { top: CLIP_BOTTOM - 60, left: 380, width: 36, height: 36 } + }), + fade: addOverlay(composerBox, { box: { top: CLIP_BOTTOM - 40, width: CONTAINER.width, height: 40 } }), + // A text label pinned over the conversation, away from the composer. + label: addOverlay(document.body, { + text: 'Thinking', box: { top: CONTAINER.top + 80, left: 20, width: 80, height: 20 } + }) + }; + } + + /** + * Claude: the composer dock is stuck to the bottom of the conversation + * with a transparent background, and a plain notice card sits above the + * input box. The dock is too tall to be cut from the clip as a whole. + */ + function addComposerDock(container, { position = 'sticky' } = {}) { + const dock = document.createElement('div'); + dock.style.position = position; + setStaticRect(dock, { top: CONTAINER.top + CONTAINER.height - 320, width: CONTAINER.width, height: 320 }); + const notice = document.createElement('div'); + notice.textContent = "You've used 90% of your session limit"; + setStaticRect(notice, { top: CONTAINER.top + CONTAINER.height - 280, left: 20, width: 760, height: 80 }); + const close = document.createElement('button'); + setStaticRect(close, { top: CONTAINER.top + CONTAINER.height - 270, left: 740, width: 28, height: 28 }); + notice.appendChild(close); + const inputBox = document.createElement('div'); + setStaticRect(inputBox, { top: CONTAINER.top + CONTAINER.height - 120, width: CONTAINER.width, height: 120 }); + const textarea = document.createElement('textarea'); + textarea.placeholder = 'Message DeepSeek'; + setStaticRect(textarea, { top: CONTAINER.top + CONTAINER.height - 100, left: 20, width: 760, height: 60 }); + const attach = document.createElement('button'); + setStaticRect(attach, { top: CONTAINER.top + CONTAINER.height - 100, left: 20, width: 28, height: 28 }); + inputBox.append(textarea, attach); + dock.append(notice, inputBox); + container.appendChild(dock); + return { dock, notice, inputBox, textarea, attach }; + } + + it('hides pinned buttons and fades over the conversation, then restores them', async () => { + const { container, elements } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const { button, fade, label } = addFloatingParts(container); + fade.style.setProperty('visibility', 'visible'); + // Absolute parts of a message scroll with it. + const badge = addOverlay(elements[0], { box: { top: 300, left: 610, width: 20, height: 20 } }); + const background = addOverlay(container, { + box: { top: CONTAINER.top, width: CONTAINER.width, height: CONTAINER.height }, offsetParent: document.body + }); + + await prepare('Explain how HTTPS works', { stitch: true }); + + expect(button.style.getPropertyValue('visibility')).toBe('hidden'); + expect(fade.style.getPropertyValue('visibility')).toBe('hidden'); + // Pinned text would show on every stitched screen. + expect(label.style.getPropertyValue('visibility')).toBe('hidden'); + // Content and large layers stay. + expect(badge.style.getPropertyValue('visibility')).toBe(''); + expect(background.style.getPropertyValue('visibility')).toBe(''); + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + + expect(button.style.getPropertyValue('visibility')).toBe(''); + expect(label.style.getPropertyValue('visibility')).toBe(''); + expect(fade.style.getPropertyValue('visibility')).toBe('visible'); + expect(fade.style.getPropertyPriority('visibility')).toBe(''); + }); + + it('un-sticks headings in the middle of the conversation and drops their shadow, then restores them', async () => { + const { container, getScrollTop } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + // A section heading stuck under the header, 1700px into the content. + const heading = document.createElement('h2'); + heading.textContent = 'Handshake'; + heading.style.position = 'sticky'; + heading.style.top = '56px'; + heading.style.boxShadow = '0 4px 8px black'; + heading.getBoundingClientRect = () => rect({ + top: heading.style.position === 'sticky' ? CONTAINER.top + 56 : CONTAINER.top + 1700 - getScrollTop(), + width: 600, + height: 40 + }); + container.insertBefore(heading, container.querySelector('textarea').parentElement); + + const state = await prepare('Explain how HTTPS works', { stitch: true }); + + expect(heading.style.getPropertyValue('position')).toBe('relative'); + expect(heading.style.getPropertyValue('top')).toBe('auto'); + expect(heading.style.getPropertyValue('box-shadow')).toBe('none'); + expect(heading.style.getPropertyValue('visibility')).toBe(''); + // Header and composer at the edges keep sticking, so the clip is the same. + expect(container.querySelector('header').style.position).toBe('sticky'); + expect(container.querySelector('textarea').parentElement.style.position).toBe('sticky'); + expect(state.clip.top).toBe(CONTAINER.top + 56); + expect(state.clip.bottom).toBe(CLIP_BOTTOM); + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + + expect(heading.style.position).toBe('sticky'); + expect(heading.style.top).toBe('56px'); + expect(heading.style.boxShadow).toBe('0 4px 8px black'); + expect(heading.style.getPropertyPriority('position')).toBe(''); + }); + + it('hides fades drawn by pseudo-elements of the composer, then shows them again', async () => { + const { container } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const composerBox = container.querySelector('textarea').parentElement; + const getComputedStyle = window.getComputedStyle; + // Gemini: the composer's ::before is a gradient over the conversation. + window.getComputedStyle = (element, pseudo) => { + if (pseudo === '::before' && element === composerBox) { + return { content: '""', display: 'block', position: 'absolute', backgroundImage: 'linear-gradient(transparent, white)', boxShadow: 'none' }; + } + if (pseudo) { + return { content: 'none', display: 'inline', position: 'static', backgroundImage: 'none', boxShadow: 'none' }; + } + return getComputedStyle.call(window, element); + }; + try { + await prepare('Explain how HTTPS works', { stitch: true }); + expect(composerBox.getAttribute('data-panelize-snapshot-hide')).toBe('before'); + expect(container.hasAttribute('data-panelize-snapshot-hide')).toBe(false); + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(composerBox.hasAttribute('data-panelize-snapshot-hide')).toBe(false); + } finally { + window.getComputedStyle = getComputedStyle; + } + }); + + it('hides notices stacked on a pinned composer, then restores them', async () => { + const { container, elements } = createConversation( + [{ text: 'Explain how HTTPS works', offset: 1500 }], + { composer: false } + ); + const { dock, notice, inputBox, textarea, attach } = addComposerDock(container); + + const state = await prepare('Explain how HTTPS works', { stitch: true }); + + expect(state.clip.bottom).toBe(CONTAINER.top + CONTAINER.height - 120); + expect(notice.style.getPropertyValue('visibility')).toBe('hidden'); + // The input box is under the clip; the dock and the message stay. + for (const element of [dock, inputBox, textarea, attach, elements[0]]) { + expect(element.style.getPropertyValue('visibility')).toBe(''); + } + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + + expect(notice.style.getPropertyValue('visibility')).toBe(''); + }); + + it('leaves the composer surroundings alone when the composer scrolls with the conversation', async () => { + const { container } = createConversation( + [{ text: 'Explain how HTTPS works', offset: 1500 }], + { composer: false } + ); + const { notice } = addComposerDock(container, { position: 'static' }); + + await prepare('Explain how HTTPS works', { stitch: true }); + + expect(notice.style.getPropertyValue('visibility')).toBe(''); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + }); + + it('leaves floating elements alone when not asked to hide them', async () => { + const { container } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const { button, fade } = addFloatingParts(container); + + await prepare('Explain how HTTPS works'); + + expect(button.style.getPropertyValue('visibility')).toBe(''); + expect(fade.style.getPropertyValue('visibility')).toBe(''); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + }); + + it('hides floating controls for a current view, keeping the composer, pinned text and stuck headings', async () => { + const { container, elements, setScrollTop, getScrollTop } = createConversation( + [{ text: 'Explain how HTTPS works', offset: 1500 }], + { composer: false } + ); + const { notice, inputBox, textarea } = addComposerDock(container); + const { button, fade, label } = addFloatingParts({ querySelector: () => textarea }); + const heading = document.createElement('h2'); + heading.style.position = 'sticky'; + setStaticRect(heading, { top: CONTAINER.top + 56, width: 600, height: 40 }); + container.insertBefore(heading, elements[0]); + setScrollTop(1234); + + const state = await prepare('A prompt that was never sent here', { hideFloating: true }); + + expect(state.alignedBy).toBe(null); + expect(getScrollTop()).toBe(1234); + for (const element of [button, fade, notice]) { + expect(element.style.getPropertyValue('visibility')).toBe('hidden'); + } + for (const element of [inputBox, textarea, label]) { + expect(element.style.getPropertyValue('visibility')).toBe(''); + } + // A current view shows the heading where it is stuck. + expect(heading.style.getPropertyValue('position')).toBe('sticky'); + expect(heading.style.getPropertyPriority('position')).toBe(''); + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + for (const element of [button, fade, notice]) { + expect(element.style.getPropertyValue('visibility')).toBe(''); + } + }); + + it('hides a button that only shows up after scrolling', async () => { + const { container } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const { button } = addFloatingParts(container); + button.style.display = 'none'; + + await prepare('Explain how HTTPS works', { stitch: true }); + expect(button.style.getPropertyValue('visibility')).toBe(''); + + button.style.display = ''; + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_SCROLL', sessionId: 'session-1', scrollTop: 2000 }); + expect(button.style.getPropertyValue('visibility')).toBe('hidden'); + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(button.style.getPropertyValue('visibility')).toBe(''); + }); + + it('removes masks that fade the conversation edges and puts them back', async () => { + const { container } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const wrapper = document.createElement('div'); + wrapper.style.setProperty('mask-image', 'linear-gradient(transparent 0px, black 40px)'); + container.replaceWith(wrapper); + wrapper.appendChild(container); + + await prepare('Explain how HTTPS works', { stitch: true }); + expect(wrapper.style.getPropertyValue('mask-image')).toBe('none'); + + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(wrapper.style.getPropertyValue('mask-image')).toBe('linear-gradient(transparent 0px, black 40px)'); + }); + + it('hides an overlay scrollbar, but not one that takes room', async () => { + const { container } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + let offsetWidth = CONTAINER.width; + Object.defineProperty(container, 'offsetWidth', { configurable: true, get: () => offsetWidth }); + + await prepare('Explain how HTTPS works', { stitch: true }); + expect(container.style.getPropertyValue('scrollbar-width')).toBe('none'); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + expect(container.style.getPropertyValue('scrollbar-width')).toBe(''); + + offsetWidth = CONTAINER.width + 15; + await prepare('Explain how HTTPS works', { stitch: true }); + expect(container.style.getPropertyValue('scrollbar-width')).toBe(''); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + }); + + it('puts hidden elements back when a new capture starts without a restore', async () => { + const { container } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const { button } = addFloatingParts(container); + + await prepare('Explain how HTTPS works', { stitch: true }); + expect(button.style.getPropertyValue('visibility')).toBe('hidden'); + + await prepare('Explain how HTTPS works', { sessionId: 'session-2' }); + expect(button.style.getPropertyValue('visibility')).toBe(''); + await sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-2' }); + }); + }); + + describe('putting a virtual list back', () => { + /** + * DeepSeek: rows far from the position are not rendered, so the range is + * an estimate that is short at first and grows once the rows near the + * position render. + */ + function createVirtualList(scrollTop) { + const conversation = createConversation([ + { text: 'Explain how TCP works', offset: 300 }, + { text: 'A long answer', offset: 400 } + ]); + let scrollHeight = CONTAINER.scrollHeight; + Object.defineProperty(conversation.container, 'scrollHeight', { configurable: true, get: () => scrollHeight }); + conversation.setScrollTop(scrollTop); + return { ...conversation, setScrollHeight: (value) => { scrollHeight = value; } }; + } + + async function restoreWhileTheRangeIsShort(list) { + list.setScrollHeight(CONTAINER.scrollHeight - 400); + const restored = sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + setTimeout(() => list.setScrollHeight(CONTAINER.scrollHeight), 250); + return restored; + } + + it('goes back to the end when it was at the end', async () => { + const max = CONTAINER.scrollHeight - CONTAINER.height; + const list = createVirtualList(max); + const state = await prepare('Explain how TCP works', { stitch: true, hideFloating: true }); + expect(state).toMatchObject({ alignedBy: 'prompt', originalScrollTop: max }); + + await restoreWhileTheRangeIsShort(list); + expect(list.getScrollTop()).toBe(max); + }); + + it('goes back to the position it was held short of', async () => { + const list = createVirtualList(3100); + await prepare('Explain how TCP works', { stitch: true, hideFloating: true }); + + await restoreWhileTheRangeIsShort(list); + expect(list.getScrollTop()).toBe(3100); + }); + + it('leaves a list the page scrolled itself meanwhile', async () => { + const list = createVirtualList(3100); + await prepare('Explain how TCP works', { stitch: true, hideFloating: true }); + + list.setScrollHeight(CONTAINER.scrollHeight - 400); + const restored = sendSnapshotMessage({ type: 'PANELIZE_SNAPSHOT_RESTORE', sessionId: 'session-1' }); + // Held at 2900 by the range, then the page scrolls up on its own. + setTimeout(() => list.setScrollTop(1000), 50); + setTimeout(() => list.setScrollHeight(CONTAINER.scrollHeight), 250); + await restored; + expect(list.getScrollTop()).toBe(1000); + }); + }); + + it('ignores snapshot messages that do not come from the embedding page', async () => { + const { getScrollTop } = createConversation([{ text: 'Explain how HTTPS works', offset: 1500 }]); + const replies = []; + const listener = (event) => { + if (event.data?.type === 'PANELIZE_SNAPSHOT_STATE') replies.push(event.data); + }; + window.addEventListener('message', listener); + + window.dispatchEvent(new MessageEvent('message', { + data: { type: 'PANELIZE_SNAPSHOT_PREPARE', context: 'multi-panel', requestId: 'x', sessionId: 's', prompt: 'Explain how HTTPS works', align: true }, + source: null + })); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 600)); + window.removeEventListener('message', listener); + + expect(replies).toEqual([]); + expect(getScrollTop()).toBe(0); + }); +}); diff --git a/tests/snapshot-stitch.test.js b/tests/snapshot-stitch.test.js new file mode 100644 index 0000000..a6e91fc --- /dev/null +++ b/tests/snapshot-stitch.test.js @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { + STITCH_STEP_RATIO, + addStitchScreen, + allocateRowContentHeights, + createStitchTrack, + fitCanvasScale, + getDominantColor, + getNextStitchScrollTop, + getRemainingStitchHeight, + getStitchStep, + getStitchedHeight, + groupCellsIntoRows, + layoutStitchedGrid +} from '../modules/snapshot-stitch.js'; + +/** Simulates a panel scrolling to the requested positions. */ +function runPanel({ clip, scrollTop, contentHeight, cap }) { + const clipHeight = clip.bottom - clip.top; + const maxScrollTop = Math.max(0, contentHeight - clip.bottom); + const track = createStitchTrack({ clip, scrollTop, maxScrollTop }, cap); + let position = scrollTop; + let frame = 0; + while (!track.done) { + addStitchScreen(track, { scrollTop: position, maxScrollTop }, frame); + const next = getNextStitchScrollTop(track, { maxScrollTop }); + if (next === null) break; + position = Math.min(maxScrollTop, Math.max(0, next)); + frame += 1; + if (frame > 100) throw new Error('did not finish'); + } + return { track, frames: frame + 1, clipHeight }; +} + +describe('stitch planning', () => { + it('steps by three quarters of the visible conversation', () => { + expect(STITCH_STEP_RATIO).toBe(0.75); + expect(getStitchStep(600)).toBe(450); + expect(getStitchStep(0)).toBe(1); + }); + + it('covers the conversation from the aligned message to the end without gaps or overlap', () => { + const clip = { top: 60, bottom: 660 }; + const { track } = runPanel({ clip, scrollTop: 1000, contentHeight: 4000 }); + + expect(track.done).toBe(true); + expect(track.truncated).toBe(false); + expect(track.segments[0]).toMatchObject({ frameIndex: 0, top: 60, height: 450, contentTop: 0 }); + let expectedTop = 0; + track.segments.forEach((segment) => { + expect(segment.contentTop).toBe(expectedTop); + expect(segment.top).toBeGreaterThanOrEqual(clip.top); + expect(segment.top + segment.height).toBeLessThanOrEqual(clip.bottom); + expectedTop += segment.height; + }); + // From the clip top at scrollTop 1000 down to the content end. + expect(getStitchedHeight(track)).toBe(4000 - (1000 + 60)); + expect(getRemainingStitchHeight(track)).toBe(0); + }); + + it('takes the whole visible conversation when it fits on one screen', () => { + const { track, frames } = runPanel({ clip: { top: 0, bottom: 500 }, scrollTop: 0, contentHeight: 300 }); + expect(frames).toBe(1); + expect(track.segments).toEqual([{ frameIndex: 0, top: 0, height: 500, contentTop: 0 }]); + }); + + it('stops at the cap and marks the panel truncated', () => { + const { track } = runPanel({ clip: { top: 0, bottom: 600 }, scrollTop: 0, contentHeight: 10000, cap: 2000 }); + expect(getStitchedHeight(track)).toBe(2000); + expect(track.truncated).toBe(true); + }); + + it('asks again once, then gives up on a page that stops scrolling short of its end', () => { + const clip = { top: 0, bottom: 600 }; + const track = createStitchTrack({ clip, scrollTop: 0, maxScrollTop: 3000 }); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 3000 }, 0); + // The page ignored the scroll request, e.g. while restoring its own position. + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 3000 }, 1); + expect(track.done).toBe(false); + expect(getNextStitchScrollTop(track, { maxScrollTop: 3000 })).toBe(450); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 3000 }, 2); + expect(track.done).toBe(true); + expect(track.stalled).toBe(true); + expect(track.segments).toHaveLength(1); + }); + + it('carries on when the page scrolls when asked again', () => { + const clip = { top: 0, bottom: 600 }; + const track = createStitchTrack({ clip, scrollTop: 0, maxScrollTop: 600 }); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 600 }, 0); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 600 }, 1); + addStitchScreen(track, { scrollTop: 450, maxScrollTop: 600 }, 2); + addStitchScreen(track, { scrollTop: 600, maxScrollTop: 600 }, 3); + expect(track.done).toBe(true); + expect(track.stalled).toBe(false); + expect(track.coveredUntil).toBe(1200); + }); + + it('keeps only what joins up when the page keeps jumping past where it was asked to go', () => { + const clip = { top: 0, bottom: 600 }; + const track = createStitchTrack({ clip, scrollTop: 0, maxScrollTop: 3000 }); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 3000 }, 0); + // Asked for 450, the page puts itself back at its bottom. + expect(addStitchScreen(track, { scrollTop: 3000, maxScrollTop: 3000 }, 1)).toBeNull(); + expect(track.done).toBe(false); + expect(getNextStitchScrollTop(track, { maxScrollTop: 3000 })).toBe(450); + expect(addStitchScreen(track, { scrollTop: 3000, maxScrollTop: 3000 }, 2)).toBeNull(); + expect(track.done).toBe(true); + expect(track.stalled).toBe(true); + expect(track.coveredUntil).toBe(450); + expect(track.segments).toHaveLength(1); + }); + + it('carries on when a page that jumped ahead goes where it is asked the next time', () => { + const clip = { top: 0, bottom: 600 }; + const track = createStitchTrack({ clip, scrollTop: 0, maxScrollTop: 600 }); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 600 }, 0); + addStitchScreen(track, { scrollTop: 600, maxScrollTop: 600 }, 1); + addStitchScreen(track, { scrollTop: 450, maxScrollTop: 600 }, 2); + addStitchScreen(track, { scrollTop: 600, maxScrollTop: 600 }, 3); + expect(track.done).toBe(true); + expect(track.stalled).toBe(false); + expect(track.segments.map(({ contentTop, height }) => [contentTop, height])) + .toEqual([[0, 450], [450, 450], [900, 300]]); + }); + + it('finishes a page that shrank back to where it already was covered', () => { + const clip = { top: 0, bottom: 600 }; + const track = createStitchTrack({ clip, scrollTop: 0, maxScrollTop: 450 }); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 450 }, 0); + // A section collapsed: the page now ends where the first screen did. + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 0 }, 1); + expect(track.done).toBe(true); + expect(track.stalled).toBe(false); + }); + + it('follows content that grows while scrolling', () => { + const clip = { top: 0, bottom: 600 }; + const track = createStitchTrack({ clip, scrollTop: 0, maxScrollTop: 400 }); + addStitchScreen(track, { scrollTop: 0, maxScrollTop: 400 }, 0); + addStitchScreen(track, { scrollTop: 400, maxScrollTop: 1400 }, 1); + expect(track.done).toBe(false); + expect(track.end).toBe(2000); + }); +}); + +describe('grid layout', () => { + const cell = (left, top, width = 500) => ({ rect: { left, top, width }, height: 0 }); + + it('groups panels into rows as on screen', () => { + const cells = [cell(504, 300), cell(0, 0), cell(504, 1), cell(0, 300)]; + const rows = groupCellsIntoRows(cells); + expect(rows.map((row) => row.map((item) => [item.rect.left, item.rect.top]))).toEqual([ + [[0, 0], [504, 1]], + [[0, 300], [504, 300]] + ]); + }); + + it('makes each row as tall as its tallest cell', () => { + const rows = [ + [{ rect: { left: 10, top: 0, width: 500 }, height: 900 }, { rect: { left: 514, top: 0, width: 500 }, height: 2000 }], + [{ rect: { left: 10, top: 400, width: 1004 }, height: 300 }] + ]; + const grid = layoutStitchedGrid({ rows, gridLeft: 6, gridWidth: 1012 }); + expect(grid.width).toBe(1012); + expect(grid.rows[0]).toMatchObject({ y: 4, height: 2000 }); + expect(grid.rows[0].cells.map((item) => item.x)).toEqual([4, 508]); + expect(grid.rows[1]).toMatchObject({ y: 4 + 2000 + 4, height: 300 }); + expect(grid.height).toBe(4 + 2000 + 4 + 300 + 4); + }); +}); + +describe('allocateRowContentHeights', () => { + it('keeps everything when it fits', () => { + expect(allocateRowContentHeights([{ fixed: 50, content: 1000 }, { fixed: 50, content: 500 }], 5000)) + .toEqual({ caps: [1000, 500], truncated: false }); + }); + + it('cuts the tallest rows to a shared cap', () => { + const { caps, truncated } = allocateRowContentHeights([ + { fixed: 100, content: 6000 }, + { fixed: 100, content: 500 } + ], 3000); + expect(truncated).toBe(true); + expect(caps).toEqual([2300, 500]); + expect(caps[0] + caps[1] + 200).toBe(3000); + }); + + it('splits evenly when every row is long', () => { + const { caps } = allocateRowContentHeights([ + { fixed: 0, content: 9000 }, + { fixed: 0, content: 9000 } + ], 4000); + expect(caps).toEqual([2000, 2000]); + }); +}); + +describe('getDominantColor', () => { + it('returns the most common color', () => { + const pixels = [ + 255, 255, 255, 255, + 250, 251, 252, 255, + 20, 20, 20, 255 + ]; + expect(getDominantColor(pixels)).toBe('rgb(255, 255, 255)'); + expect(getDominantColor([])).toBe('rgb(255, 255, 255)'); + }); +}); + +describe('fitCanvasScale', () => { + const limits = { maxDimension: 32767, maxArea: 268435456 }; + + it('keeps the wanted scale within limits', () => { + expect(fitCanvasScale(1400, 4000, 2, limits)).toBe(2); + }); + + it('shrinks the scale past the dimension or area limit', () => { + expect(fitCanvasScale(1000, 20000, 2, limits)).toBeCloseTo(32767 / 20000); + expect(fitCanvasScale(15000, 15000, 2, limits)).toBeCloseTo(Math.sqrt(268435456 / (15000 * 15000))); + }); +}); diff --git a/tests/tab-capture.test.js b/tests/tab-capture.test.js new file mode 100644 index 0000000..e9edbd9 --- /dev/null +++ b/tests/tab-capture.test.js @@ -0,0 +1,457 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + IDLE_CAPTURE_FRAME_RATE, + MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND, + TAB_CAPTURE_ERRORS, + TAB_CAPTURE_KINDS, + TabCaptureError, + VISIBLE_TAB_CAPTURE_INTERVAL_MS, + createCallThrottle, + createDisplayMediaSource, + createVisibleTabSource, + dataUrlToBlob, + isCapturePermissionError, + isCaptureQuotaError, + openTabCaptureSource +} from '../modules/tab-capture.js'; + +// Chrome's error messages; the permission one was observed in Chrome for Testing 151. +const PERMISSION_MESSAGE = "Either the '' or 'activeTab' permission is required."; +const QUOTA_MESSAGE = 'This request exceeds the MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND quota.'; + +function createClock() { + let time = 1000; + const waits = []; + return { + waits, + now: () => time, + wait: async (ms) => { + waits.push(ms); + time += ms; + }, + advance: (ms) => { + time += ms; + } + }; +} + +describe('capture error classification', () => { + it('recognizes the missing activeTab grant', () => { + expect(isCapturePermissionError(new Error(PERMISSION_MESSAGE))).toBe(true); + expect(isCapturePermissionError(new Error(QUOTA_MESSAGE))).toBe(false); + expect(isCapturePermissionError(undefined)).toBe(false); + }); + + it('recognizes the capture quota', () => { + expect(isCaptureQuotaError(new Error(QUOTA_MESSAGE))).toBe(true); + expect(isCaptureQuotaError(new Error(PERMISSION_MESSAGE))).toBe(false); + }); +}); + +describe('createCallThrottle', () => { + it('keeps calls under the captureVisibleTab quota', async () => { + expect(MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND).toBe(2); + expect(VISIBLE_TAB_CAPTURE_INTERVAL_MS).toBeGreaterThan(1000 / MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND); + + const clock = createClock(); + const throttle = createCallThrottle(VISIBLE_TAB_CAPTURE_INTERVAL_MS, clock); + await throttle.wait(); + await throttle.wait(); + clock.advance(200); + await throttle.wait(); + expect(clock.waits).toEqual([VISIBLE_TAB_CAPTURE_INTERVAL_MS, VISIBLE_TAB_CAPTURE_INTERVAL_MS - 200]); + }); + + it('does not wait when calls are already spaced out', async () => { + const clock = createClock(); + const throttle = createCallThrottle(500, clock); + await throttle.wait(); + clock.advance(900); + await throttle.wait(); + expect(clock.waits).toEqual([]); + }); +}); + +describe('dataUrlToBlob', () => { + it('decodes base64 PNG data', async () => { + const blob = dataUrlToBlob('data:image/png;base64,iVBORw0KGgo='); + expect(blob.type).toBe('image/png'); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + }); + + it('rejects anything that is not a data URL', () => { + expect(() => dataUrlToBlob('https://example.com/x.png')).toThrow('did not return an image'); + }); +}); + +describe('createVisibleTabSource', () => { + const PNG = 'data:image/png;base64,iVBORw0KGgo='; + const immediateThrottle = () => ({ wait: vi.fn(async () => {}), defer: vi.fn() }); + + it('captures the window as PNG through the throttle', async () => { + const tabs = { captureVisibleTab: vi.fn(async () => PNG) }; + const throttle = immediateThrottle(); + const decode = vi.fn(async (blob) => ({ width: 10, height: 10, type: blob.type })); + const source = createVisibleTabSource({ windowId: 7, tabs, throttle, decode }); + + expect(source.kind).toBe(TAB_CAPTURE_KINDS.VISIBLE_TAB); + expect(await source.grab()).toEqual({ width: 10, height: 10, type: 'image/png' }); + expect(tabs.captureVisibleTab).toHaveBeenCalledWith(7, { format: 'png' }); + expect(throttle.wait).toHaveBeenCalledTimes(1); + }); + + it('turns the missing grant into a permission error for the fallback', async () => { + const tabs = { captureVisibleTab: vi.fn(async () => { throw new Error(PERMISSION_MESSAGE); }) }; + const source = createVisibleTabSource({ tabs, throttle: immediateThrottle(), decode: vi.fn() }); + const error = await source.grab().catch((caught) => caught); + expect(error).toBeInstanceOf(TabCaptureError); + expect(error.code).toBe(TAB_CAPTURE_ERRORS.PERMISSION); + }); + + it('retries after the quota error and gives up after a few attempts', async () => { + const tabs = { + captureVisibleTab: vi.fn() + .mockRejectedValueOnce(new Error(QUOTA_MESSAGE)) + .mockResolvedValueOnce(PNG) + }; + const throttle = immediateThrottle(); + const source = createVisibleTabSource({ tabs, throttle, decode: async () => 'frame' }); + expect(await source.grab()).toBe('frame'); + expect(throttle.defer).toHaveBeenCalledTimes(1); + expect(throttle.wait).toHaveBeenCalledTimes(2); + + const failing = createVisibleTabSource({ + tabs: { captureVisibleTab: vi.fn(async () => { throw new Error(QUOTA_MESSAGE); }) }, + throttle: immediateThrottle(), + decode: vi.fn() + }); + await expect(failing.grab()).rejects.toThrow('MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND'); + }); + + describe('bound to the tab it was opened from', () => { + const TAB = { id: 3, windowId: 7, active: true }; + + it('captures the window showing the tab', async () => { + const tabs = { get: vi.fn(async () => TAB), captureVisibleTab: vi.fn(async () => PNG) }; + const source = createVisibleTabSource({ tabId: 3, tabs, throttle: immediateThrottle(), decode: async () => 'frame' }); + expect(await source.grab()).toBe('frame'); + expect(tabs.get).toHaveBeenCalledWith(3); + expect(tabs.captureVisibleTab).toHaveBeenCalledWith(7, { format: 'png' }); + }); + + it('takes nothing while another tab is in front', async () => { + const tabs = { get: vi.fn(async () => ({ ...TAB, active: false })), captureVisibleTab: vi.fn(async () => PNG) }; + const source = createVisibleTabSource({ tabId: 3, tabs, throttle: immediateThrottle(), decode: vi.fn() }); + await expect(source.grab()).rejects.toMatchObject({ code: TAB_CAPTURE_ERRORS.TAB_HIDDEN }); + expect(tabs.captureVisibleTab).not.toHaveBeenCalled(); + }); + + it('drops a frame taken while the user switched tabs', async () => { + const tabs = { + get: vi.fn().mockResolvedValueOnce(TAB).mockResolvedValueOnce({ ...TAB, active: false }), + captureVisibleTab: vi.fn(async () => PNG) + }; + const decode = vi.fn(); + const source = createVisibleTabSource({ tabId: 3, tabs, throttle: immediateThrottle(), decode }); + await expect(source.grab()).rejects.toMatchObject({ code: TAB_CAPTURE_ERRORS.TAB_HIDDEN }); + expect(decode).not.toHaveBeenCalled(); + }); + + it('drops a frame when the page was hidden meanwhile, even if it is back', async () => { + const doc = new EventTarget(); + doc.visibilityState = 'visible'; + const tabs = { + get: vi.fn(async () => TAB), + captureVisibleTab: vi.fn(async () => { + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + return PNG; + }) + }; + const decode = vi.fn(); + const source = createVisibleTabSource({ tabId: 3, tabs, throttle: immediateThrottle(), decode, doc }); + await expect(source.grab()).rejects.toMatchObject({ code: TAB_CAPTURE_ERRORS.TAB_HIDDEN }); + expect(decode).not.toHaveBeenCalled(); + }); + }); + + it('is unsupported without the tabs API', () => { + expect(() => createVisibleTabSource({ tabs: {} })).toThrow(TabCaptureError); + }); +}); + +describe('tab sharing kept between snapshots', () => { + let calls; + let tracks; + let capturedHandle; + const originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, 'mediaDevices'); + const originalCreateImageBitmap = globalThis.createImageBitmap; + const originalSrcObject = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject'); + + /** A browser-tab track as Chrome hands it out; end() is Chrome's "Stop sharing". */ + function createTrack() { + const track = new EventTarget(); + track.readyState = 'live'; + track.stop = vi.fn(() => { track.readyState = 'ended'; }); + track.getSettings = () => ({ displaySurface: 'browser', frameRate: 30 }); + track.getCaptureHandle = () => ({ handle: capturedHandle }); + track.applyConstraints = vi.fn(async () => {}); + track.end = () => { + track.readyState = 'ended'; + track.dispatchEvent(new Event('ended')); + }; + return track; + } + + beforeEach(() => { + calls = []; + tracks = []; + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + setCaptureHandleConfig: ({ handle }) => { capturedHandle = handle; }, + getDisplayMedia: vi.fn(async (constraints) => { + calls.push(constraints); + const track = createTrack(); + tracks.push(track); + return { getTracks: () => [track], getVideoTracks: () => [track] }; + }) + } + }); + // happy-dom only takes its own MediaStream. + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + get() { return this.testSrcObject ?? null; }, + set(value) { this.testSrcObject = value; } + }); + vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(async function play() { + Object.defineProperty(this, 'paused', { configurable: true, value: false }); + }); + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(function pause() { + Object.defineProperty(this, 'paused', { configurable: true, value: true }); + }); + HTMLVideoElement.prototype.requestVideoFrameCallback = function requestVideoFrameCallback(callback) { + queueMicrotask(() => callback(0, {})); + return 1; + }; + globalThis.createImageBitmap = vi.fn(async (video) => ({ width: 1280, height: 800, video })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const [key, descriptor] of originalWindowSize) { + if (descriptor) { + Object.defineProperty(window, key, descriptor); + } else { + delete window[key]; + } + } + originalWindowSize.clear(); + delete HTMLVideoElement.prototype.requestVideoFrameCallback; + if (originalSrcObject) { + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', originalSrcObject); + } + globalThis.createImageBitmap = originalCreateImageBitmap; + if (originalMediaDevices) { + Object.defineProperty(navigator, 'mediaDevices', originalMediaDevices); + } else { + delete navigator.mediaDevices; + } + }); + + /** Window size in CSS pixels and device scale, as Chrome reports them. */ + const originalWindowSize = new Map(); + function setWindowSize(width, height, scale) { + for (const key of ['innerWidth', 'innerHeight', 'devicePixelRatio']) { + if (!originalWindowSize.has(key)) { + originalWindowSize.set(key, Object.getOwnPropertyDescriptor(window, key)); + } + } + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: height }); + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: scale }); + } + + it('asks for this tab without the pointer, no larger than the tab in device pixels', async () => { + setWindowSize(948, 879, 2); + const source = await createDisplayMediaSource(); + expect(calls).toHaveLength(1); + // A larger frame makes Chrome scale the page up, and lay it out again + // whenever the constraints change. + expect(calls[0].video).toEqual({ + displaySurface: 'browser', cursor: 'never', width: { max: 1896 }, height: { max: 1758 } + }); + expect(calls[0].preferCurrentTab).toBe(true); + expect(source.kind).toBe(TAB_CAPTURE_KINDS.DISPLAY_MEDIA); + source.close(); + }); + + it('reuses the kept stream for the next snapshot, paused and slowed down in between', async () => { + setWindowSize(948, 879, 2); + const first = await openTabCaptureSource({ preferVisibleTab: false }); + expect(first.reused).toBe(false); + const { source } = first; + const bitmap = await source.grab(); + expect(bitmap.video.srcObject).toBeTruthy(); + + await source.stop(); + expect(bitmap.video.paused).toBe(true); + // Constraints are replaced as a whole: the size goes with the frame rate. + expect(tracks[0].applyConstraints).toHaveBeenLastCalledWith({ + width: { max: 1896 }, height: { max: 1758 }, frameRate: IDLE_CAPTURE_FRAME_RATE + }); + expect(tracks[0].stop).not.toHaveBeenCalled(); + + const second = await openTabCaptureSource({ preferVisibleTab: false, keptSource: source }); + expect(second).toMatchObject({ source, reused: true }); + expect(navigator.mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(1); + + // The window was resized in between: measured again. + setWindowSize(1200, 800, 2); + await source.start(); + expect(bitmap.video.paused).toBe(false); + expect(tracks[0].applyConstraints).toHaveBeenLastCalledWith({ + width: { max: 2400 }, height: { max: 1600 }, frameRate: 30 + }); + await expect(source.grab()).resolves.toMatchObject({ width: 1280 }); + source.close(); + }); + + /** + * Frames as Chrome sends them for a tab: only when the page changes, each + * with the time it was taken. `frames` lists capture times relative to the + * moment the page changed; the video shows the last one delivered. + */ + function sendFramesOnChange(frames) { + const pending = []; + let changedAt = null; + const deliver = () => { + while (pending.length && frames.length) { + const offset = frames.shift(); + const callback = pending.shift(); + const captureTime = changedAt + offset; + callback.video.shownCaptureTime = captureTime; + callback.run(captureTime, { captureTime }); + } + }; + HTMLVideoElement.prototype.requestVideoFrameCallback = function requestVideoFrameCallback(run) { + pending.push({ video: this, run }); + if (changedAt !== null) queueMicrotask(deliver); + return pending.length; + }; + globalThis.createImageBitmap = vi.fn(async (video) => ({ capturedAt: video.shownCaptureTime })); + const observer = new MutationObserver(() => { + changedAt = performance.now(); + queueMicrotask(deliver); + }); + observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true }); + return () => observer.disconnect(); + } + + it('takes a frame taken after the grab, not an older one still on its way', async () => { + const source = await createDisplayMediaSource(); + // Two frames from before the latest scroll arrive first. + const stopSending = sendFramesOnChange([-120, -60, 5]); + const asked = performance.now(); + const bitmap = await source.grab(); + stopSending(); + + expect(bitmap.capturedAt).toBeGreaterThanOrEqual(asked); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + source.close(); + // The pixel that makes Chrome send a frame goes away with the stream. + expect(document.querySelector('[aria-hidden="true"]')).toBeNull(); + }); + + it('fails rather than use an old frame when the tab sends no new one', async () => { + const source = await createDisplayMediaSource(); + vi.useFakeTimers(); + try { + HTMLVideoElement.prototype.requestVideoFrameCallback = function requestVideoFrameCallback(run) { + // Only frames from before the grab, one per display refresh. + setTimeout(() => run(0, { captureTime: -1 }), 16); + return 1; + }; + const grabbed = source.grab().catch((error) => error); + await vi.advanceTimersByTimeAsync(5000); + expect(await grabbed).toMatchObject({ code: TAB_CAPTURE_ERRORS.NO_FRAME }); + expect(createImageBitmap).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + source.close(); + } + }); + + it('asks again once sharing was stopped from Chrome', async () => { + const onEnded = vi.fn(); + const { source } = await openTabCaptureSource({ preferVisibleTab: false, onEnded }); + + tracks[0].end(); + + expect(onEnded).toHaveBeenCalledTimes(1); + expect(source.isActive()).toBe(false); + const error = await source.start().catch((caught) => caught); + expect(error).toBeInstanceOf(TabCaptureError); + expect(error.code).toBe(TAB_CAPTURE_ERRORS.ENDED); + await expect(source.grab()).rejects.toMatchObject({ code: TAB_CAPTURE_ERRORS.ENDED }); + + const next = await openTabCaptureSource({ preferVisibleTab: false, keptSource: source }); + expect(next.reused).toBe(false); + expect(next.source).not.toBe(source); + expect(navigator.mediaDevices.getDisplayMedia).toHaveBeenCalledTimes(2); + next.source.close(); + }); + + it('stops every track when closed, e.g. from Stop or when the page goes away, and reports it once', async () => { + const onEnded = vi.fn(); + const source = await createDisplayMediaSource({ onEnded }); + + source.close(); + source.close(); + + expect(tracks[0].stop).toHaveBeenCalledTimes(1); + expect(onEnded).toHaveBeenCalledTimes(1); + expect(source.isActive()).toBe(false); + // Stopping the track itself fires no "ended" event, and none is needed. + tracks[0].end(); + expect(onEnded).toHaveBeenCalledTimes(1); + }); + + it('refuses a kept stream that no longer shows this tab', async () => { + const source = await createDisplayMediaSource(); + capturedHandle = 'panelize-snapshot-another-page'; + + await expect(source.start()).rejects.toMatchObject({ code: TAB_CAPTURE_ERRORS.WRONG_SURFACE }); + await expect(source.grab()).rejects.toMatchObject({ code: TAB_CAPTURE_ERRORS.WRONG_SURFACE }); + source.close(); + }); + + it('does not fall back to tab sharing when this tab is not in front', async () => { + const previousTabs = chrome.tabs; + chrome.tabs = { get: vi.fn(async () => ({ id: 3, windowId: 7, active: false })), captureVisibleTab: vi.fn() }; + try { + await expect(openTabCaptureSource({ tabId: 3 })).rejects.toMatchObject({ code: TAB_CAPTURE_ERRORS.TAB_HIDDEN }); + expect(navigator.mediaDevices.getDisplayMedia).not.toHaveBeenCalled(); + } finally { + chrome.tabs = previousTabs; + } + }); + + it('still tries the silent capture first while a stream is kept', async () => { + const kept = await createDisplayMediaSource(); + const captureVisibleTab = vi.fn(async () => 'data:image/png;base64,iVBORw0KGgo='); + const previousTabs = chrome.tabs; + chrome.tabs = { captureVisibleTab }; + try { + const opened = await openTabCaptureSource({ keptSource: kept }); + expect(opened.source.kind).toBe(TAB_CAPTURE_KINDS.VISIBLE_TAB); + expect(opened.reused).toBe(false); + expect(captureVisibleTab).toHaveBeenCalledTimes(1); + } finally { + chrome.tabs = previousTabs; + kept.close(); + } + }); +});