Files
Momento/memento-note/extension/dist-chrome-store/content.js
Antigravity 1e9b1b30c9
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
fix(extension): bannière visible dès l'injection + exclusion store-assets
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).
2026-07-18 10:20:31 +00:00

283 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&lt;')}</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()
})()