- content.js : écrit la sélection dans chrome.storage.session/local en plus du message runtime - sidepanel.js : lit le storage en fallback, loggue chaque étape, affiche un diagnostic panel - sidepanel.js : avertissement explicite si le content script n'est pas détecté - sidepanel.js : sépare le rendu Markdown du résumé et l'affichage texte brut de l'extrait - sidepanel.css : agrandit la zone de prévisualisation - i18n : nouvelles clés contentScriptMissing, diagnosticsTitle, copyDiagnostics - locales : correction FR (Retour, Enregistrement, Note, etc.) + version 0.4.4 partout - extract-article.ts : nettoie textContent des métadonnées (dates, temps de lecture, sources) - analyze/route.ts : excerpt propre via makeExcerpt - builds Chrome Store et Firefox regénérés en 0.4.4
241 lines
7.3 KiB
JavaScript
241 lines
7.3 KiB
JavaScript
/**
|
||
* Content script Momento — 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.
|
||
*
|
||
* Architecture v0.4.4 :
|
||
* - La sélection courante est écrite dans chrome.storage.session/local comme source de vérité
|
||
* (filet de sécurité si le runtime.sendMessage est perdu).
|
||
* - Les messages runtime servent de notification rapide au side panel.
|
||
* - SET_PICK_MODE répond toujours avec l’état pickMode + la sélection courante.
|
||
*/
|
||
;(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'
|
||
const SESSION_KEY = 'memento_clipper_session'
|
||
|
||
let pickMode = false
|
||
let debounceTimer = null
|
||
|
||
function storageArea() {
|
||
// Firefox < 128 n’a pas storage.session en MV3 ; on fallback sur local.
|
||
return chrome.storage.session || chrome.storage.local
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
async function writeSession(extra = {}) {
|
||
try {
|
||
await storageArea().set({
|
||
[SESSION_KEY]: {
|
||
...getPageMeta(),
|
||
...extra,
|
||
updatedAt: Date.now(),
|
||
},
|
||
})
|
||
} catch (err) {
|
||
console.debug('[memento] storage session write failed', err)
|
||
}
|
||
}
|
||
|
||
function broadcastSelection() {
|
||
clearTimeout(debounceTimer)
|
||
debounceTimer = setTimeout(async () => {
|
||
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
|
||
writeSession({ source: 'selection' })
|
||
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
|
||
|
||
const bannerText =
|
||
(typeof chrome !== 'undefined' && chrome.i18n?.getMessage?.('bannerPickText')) ||
|
||
'Highlight the text to clip'
|
||
|
||
const host = document.createElement('div')
|
||
host.id = BANNER_ID
|
||
host.style.cssText =
|
||
'all:initial;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:none;font-family:Inter,system-ui,sans-serif;'
|
||
|
||
const shadow = host.attachShadow({ mode: 'open' })
|
||
shadow.innerHTML = `
|
||
<style>
|
||
.pill {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 18px; border-radius: 999px;
|
||
background: #1c1c1c; color: #faf9f5;
|
||
box-shadow: 0 12px 32px rgba(0,0,0,0.22);
|
||
font-size: 12px; font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
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: 12px;
|
||
}
|
||
.dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: #a47148; animation: pulse 1.2s ease infinite;
|
||
}
|
||
@keyframes slideIn {
|
||
from { opacity: 0; transform: translateY(-8px); }
|
||
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="pill">
|
||
<span class="logo">M</span>
|
||
<span class="dot"></span>
|
||
<span>${bannerText.replace(/</g, '<')}</span>
|
||
</div>
|
||
`
|
||
document.documentElement.appendChild(host)
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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, selection: getPageMeta() })
|
||
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, selection: getPageMeta() })
|
||
return true
|
||
}
|
||
return false
|
||
})
|
||
|
||
// Amorcer le storage dès l'injection (utile si une sélection existait avant l'ouverture du side panel).
|
||
writeSession({ source: 'init' })
|
||
broadcastSelection()
|
||
})()
|