Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
212 lines
6.2 KiB
JavaScript
212 lines
6.2 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.
|
||
*/
|
||
;(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 debounceTimer = null
|
||
|
||
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
|
||
|
||
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 })
|
||
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
|
||
}
|
||
return false
|
||
})
|
||
|
||
broadcastSelection()
|
||
})()
|