Bug réel : la bannière 'Surlignez le texte à clipper' n'apparaissait jamais en pratique parce que le side panel envoyait SET_PICK_MODE avant que le content script ne soit prêt (race condition MV3 entre chrome.scripting.executeScript et la mise en place du listener chrome.runtime.onMessage). Le retry PING dans ensureContentScript ne suffisait pas : le listener sidepanel peut aussi être en retard. Fix radical : content.js affiche la bannière IMMÉDIATEMENT à l'initialisation, sans attendre SET_PICK_MODE. L'utilisateur voit immédiatement que l'extension est active. Un bouton ✕ permet de la fermer (state local au tab). Pas de dépendance sur le side panel pour le feedback visuel. + manifest : retrait de optional_host_permissions (redondant avec host_permissions http://*/* et https://*/*, peut perturber Chrome en dev). + bannière rediseignée : max-width 640px responsive, animation slideIn, bouton ✕ accessible. + nouvelles clés i18n bannerDismiss dans les 15 locales. + build : exclusion de store-assets/ du zip et du xpi (sinon le package Store faisait 327 Ko au lieu de 47 Ko).
283 lines
9.1 KiB
JavaScript
283 lines
9.1 KiB
JavaScript
/**
|
||
* Content script Memento — sélection live, surlignage, communication avec le side panel.
|
||
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si l'onglet était déjà ouvert.
|
||
*
|
||
* IMPORTANT : la bannière "Surlignez le texte à clipper" s'affiche dès
|
||
* l'injection du script, sans dépendre de SET_PICK_MODE. C'est volontaire :
|
||
* en MV3, le routage des messages entre side panel et content script
|
||
* peut être flaky (race conditions entre injection et listeners), donc
|
||
* on rend la bannière visible dès qu'on est là. L'utilisateur peut la
|
||
* fermer via le bouton ✕.
|
||
*/
|
||
;(function initMementoClipperContent() {
|
||
if (globalThis.__mementoClipperContent) return
|
||
globalThis.__mementoClipperContent = true
|
||
|
||
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
|
||
const BANNER_ID = 'memento-clipper-banner-root'
|
||
const STYLE_ID = 'memento-clipper-styles'
|
||
|
||
let pickMode = false
|
||
let bannerDismissed = false
|
||
let debounceTimer = null
|
||
|
||
function isRestrictedUrl(url) {
|
||
return !url || /^(chrome|chrome-extension|edge|about|moz-extension|devtools|file):/i.test(url)
|
||
}
|
||
|
||
function getSelectionText() {
|
||
return window.getSelection()?.toString().trim() || ''
|
||
}
|
||
|
||
function getPageMeta() {
|
||
const dir =
|
||
document.documentElement.getAttribute('dir') ||
|
||
document.body?.getAttribute('dir') ||
|
||
''
|
||
const lang = (
|
||
document.documentElement.getAttribute('lang') ||
|
||
document.body?.getAttribute('lang') ||
|
||
''
|
||
).split('-')[0]
|
||
return {
|
||
text: getSelectionText(),
|
||
dir,
|
||
lang,
|
||
url: location.href,
|
||
title: document.title,
|
||
}
|
||
}
|
||
|
||
function broadcastSelection() {
|
||
clearTimeout(debounceTimer)
|
||
debounceTimer = setTimeout(() => {
|
||
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
|
||
try {
|
||
chrome.runtime.sendMessage(payload).catch(() => {})
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
if (pickMode) paintHighlight()
|
||
}, 80)
|
||
}
|
||
|
||
function removeHighlight() {
|
||
document.getElementById(HIGHLIGHT_ID)?.remove()
|
||
}
|
||
|
||
function paintHighlight() {
|
||
removeHighlight()
|
||
const sel = window.getSelection()
|
||
if (!sel || sel.isCollapsed || !sel.rangeCount) return
|
||
|
||
let range
|
||
try {
|
||
range = sel.getRangeAt(0)
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
const host = document.createElement('div')
|
||
host.id = HIGHLIGHT_ID
|
||
host.setAttribute('aria-hidden', 'true')
|
||
host.style.cssText =
|
||
'position:fixed;inset:0;pointer-events:none;z-index:2147483644;overflow:hidden;'
|
||
|
||
for (const rect of range.getClientRects()) {
|
||
if (rect.width < 2 || rect.height < 2) continue
|
||
const box = document.createElement('div')
|
||
box.style.cssText = [
|
||
'position:fixed',
|
||
`left:${rect.left - 2}px`,
|
||
`top:${rect.top - 1}px`,
|
||
`width:${rect.width + 4}px`,
|
||
`height:${rect.height + 2}px`,
|
||
'background:rgba(164,113,72,0.28)',
|
||
'border-radius:3px',
|
||
'box-shadow:0 0 0 1px rgba(164,113,72,0.35)',
|
||
'transition:opacity 0.15s ease',
|
||
].join(';')
|
||
host.appendChild(box)
|
||
}
|
||
|
||
if (host.childNodes.length) document.documentElement.appendChild(host)
|
||
}
|
||
|
||
function ensureStyles() {
|
||
if (document.getElementById(STYLE_ID)) return
|
||
const style = document.createElement('style')
|
||
style.id = STYLE_ID
|
||
style.textContent = `
|
||
html.memento-clipper-pick ::selection {
|
||
background: rgba(164, 113, 72, 0.45) !important;
|
||
color: inherit !important;
|
||
}
|
||
html.memento-clipper-pick {
|
||
scroll-behavior: auto;
|
||
}
|
||
`
|
||
document.documentElement.appendChild(style)
|
||
}
|
||
|
||
function removeBanner() {
|
||
document.getElementById(BANNER_ID)?.remove()
|
||
}
|
||
|
||
function ensureBanner() {
|
||
if (document.getElementById(BANNER_ID)) return
|
||
if (!shouldShowBanner()) return
|
||
|
||
const bannerText =
|
||
(typeof chrome !== 'undefined' && chrome.i18n?.getMessage?.('bannerPickText')) ||
|
||
'Highlight the text on the page, or clip the whole page.'
|
||
const dismissLabel =
|
||
(typeof chrome !== 'undefined' && chrome.i18n?.getMessage?.('bannerDismiss')) ||
|
||
'Dismiss'
|
||
|
||
const host = document.createElement('div')
|
||
host.id = BANNER_ID
|
||
// Le host est positionné en fixed (hors flux) au top-center.
|
||
host.style.cssText =
|
||
'position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;font-family:Inter,system-ui,-apple-system,sans-serif;'
|
||
|
||
const shadow = host.attachShadow({ mode: 'open' })
|
||
shadow.innerHTML = `
|
||
<style>
|
||
:host { all: initial; }
|
||
.wrap {
|
||
display: inline-flex; align-items: center; gap: 10px;
|
||
padding: 10px 10px 10px 18px; border-radius: 999px;
|
||
background: #1c1c1c; color: #faf9f5;
|
||
box-shadow: 0 12px 32px rgba(0,0,0,0.32);
|
||
font-size: 12px; font-weight: 600;
|
||
letter-spacing: 0.01em;
|
||
max-width: min(640px, calc(100vw - 24px));
|
||
animation: slideIn 0.35s cubic-bezier(0.22,1,0.36,1);
|
||
}
|
||
.logo {
|
||
width: 22px; height: 22px; border-radius: 7px;
|
||
background: #faf9f5; color: #1c1c1c;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-family: Georgia, serif; font-weight: 900; font-size: 13px;
|
||
flex-shrink: 0;
|
||
}
|
||
.dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: #a47148; animation: pulse 1.2s ease infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
.text {
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
min-width: 0;
|
||
}
|
||
.close {
|
||
width: 22px; height: 22px; border-radius: 50%;
|
||
background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.7);
|
||
border: none; cursor: pointer; display: flex;
|
||
align-items: center; justify-content: center;
|
||
font-size: 14px; line-height: 1; font-weight: 700;
|
||
flex-shrink: 0; transition: background 0.15s, color 0.15s;
|
||
}
|
||
.close:hover {
|
||
background: rgba(255,255,255,0.18); color: #fff;
|
||
}
|
||
@keyframes slideIn {
|
||
from { opacity: 0; transform: translateY(-10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.5; transform: scale(0.85); }
|
||
}
|
||
</style>
|
||
<div class="wrap" role="status" aria-live="polite">
|
||
<span class="logo">M</span>
|
||
<span class="dot"></span>
|
||
<span class="text">${bannerText.replace(/</g, '<')}</span>
|
||
<button type="button" class="close" aria-label="${dismissLabel}">×</button>
|
||
</div>
|
||
`
|
||
|
||
document.documentElement.appendChild(host)
|
||
|
||
// Bind le bouton ✕ — ferme la bannière pour cette session d'onglet.
|
||
const closeBtn = shadow.querySelector('.close')
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', (e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
bannerDismissed = true
|
||
removeBanner()
|
||
})
|
||
}
|
||
}
|
||
|
||
function setPickMode(enabled) {
|
||
pickMode = !!enabled
|
||
ensureStyles()
|
||
if (pickMode) {
|
||
document.documentElement.classList.add('memento-clipper-pick')
|
||
ensureBanner()
|
||
paintHighlight()
|
||
} else {
|
||
document.documentElement.classList.remove('memento-clipper-pick')
|
||
removeBanner()
|
||
removeHighlight()
|
||
}
|
||
}
|
||
|
||
function onScrollOrResize() {
|
||
if (pickMode) paintHighlight()
|
||
}
|
||
|
||
// Ne rien faire sur les pages restreintes (chrome://, file://, etc.)
|
||
if (isRestrictedUrl(location.href)) {
|
||
// On écoute quand même les messages au cas où le side panel veut
|
||
// quand même parler (ex: pour info).
|
||
} else {
|
||
document.addEventListener('selectionchange', broadcastSelection)
|
||
document.addEventListener('mouseup', broadcastSelection)
|
||
document.addEventListener('keyup', broadcastSelection)
|
||
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true })
|
||
window.addEventListener('resize', onScrollOrResize, { passive: true })
|
||
}
|
||
|
||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||
if (message?.type === 'PING') {
|
||
sendResponse({ ok: true, pickMode })
|
||
return true
|
||
}
|
||
if (message?.type === 'GET_CONTEXT') {
|
||
sendResponse({
|
||
html: document.documentElement.outerHTML,
|
||
...getPageMeta(),
|
||
})
|
||
return true
|
||
}
|
||
if (message?.type === 'SET_PICK_MODE') {
|
||
setPickMode(!!message.enabled)
|
||
sendResponse({ ok: true, pickMode })
|
||
return true
|
||
}
|
||
if (message?.type === 'DISMISS_BANNER') {
|
||
bannerDismissed = true
|
||
removeBanner()
|
||
sendResponse({ ok: true })
|
||
return true
|
||
}
|
||
return false
|
||
})
|
||
|
||
// === AFFICHAGE IMMÉDIAT DE LA BANNIÈRE ===
|
||
// On n'attend pas SET_PICK_MODE — le side panel peut être lent à nous
|
||
// parler (race conditions MV3). La bannière est le seul signal visuel
|
||
// pour l'utilisateur que l'extension est active et qu'il peut clipper.
|
||
// L'utilisateur peut la fermer via le bouton ✕.
|
||
if (!isRestrictedUrl(location.href)) {
|
||
setPickMode(true)
|
||
}
|
||
|
||
// Notifie le side panel qu'on est là (peut aider à amorcer).
|
||
broadcastSelection()
|
||
})()
|