- bouton flottant rond (44px), semi-transparent blur, hover plus fonce, deplacable souris (pos persistee storage) - option afficher/cacher dans popup extension (showButton) - clipper: notification instantanee des onglets FlowDeck via tabs.sendMessage + polling 15s + BroadcastChannel + visibilitychange - fix bookmark template literal '+title+' -> \/\/\/\/\ (_page_editor_scripts.html) - sync static/extension + zip
267 lines
9.2 KiB
JavaScript
267 lines
9.2 KiB
JavaScript
// FlowDeck Web Clipper — content script
|
|
// Injected in every page: floating button + context menu + selection handling
|
|
(function () {
|
|
if (window.__fdClipperInjected) return;
|
|
window.__fdClipperInjected = true;
|
|
|
|
const BTN_ID = "fd-clipper-btn";
|
|
const STORAGE_KEY = "flowdeck_clipper";
|
|
let dragState = null;
|
|
|
|
async function getCfg() {
|
|
try {
|
|
const data = await chrome.storage.local.get(STORAGE_KEY);
|
|
return data[STORAGE_KEY] || {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function createFloatButton() {
|
|
const cfg = await getCfg();
|
|
if (cfg.showButton === false) return;
|
|
if (document.getElementById(BTN_ID)) return;
|
|
if (!document.body) return;
|
|
const btn = document.createElement("button");
|
|
btn.id = BTN_ID;
|
|
btn.className = "fd-clipper-btn";
|
|
btn.textContent = "📌";
|
|
btn.title = "Clip to FlowDeck (glisser pour déplacer)";
|
|
btn.setAttribute("aria-label", "Clip to FlowDeck");
|
|
// Apply saved position
|
|
const pos = cfg.pos;
|
|
if (pos && typeof pos.left === "number" && typeof pos.top === "number") {
|
|
btn.style.left = pos.left + "px";
|
|
btn.style.top = pos.top + "px";
|
|
btn.style.right = "auto";
|
|
btn.style.bottom = "auto";
|
|
}
|
|
btn.addEventListener("click", (e) => {
|
|
if (btn.dataset.dragged === "1") {
|
|
btn.dataset.dragged = "0";
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
openClipper("article");
|
|
});
|
|
btn.addEventListener("mousedown", onDragStart);
|
|
document.body.appendChild(btn);
|
|
}
|
|
|
|
function removeFloatButton() {
|
|
const el = document.getElementById(BTN_ID);
|
|
if (el) el.remove();
|
|
}
|
|
|
|
function applyVisibility(show) {
|
|
if (show === false) {
|
|
removeFloatButton();
|
|
} else {
|
|
const el = document.getElementById(BTN_ID);
|
|
if (el) el.style.display = "";
|
|
else createFloatButton();
|
|
}
|
|
}
|
|
|
|
function onDragStart(e) {
|
|
if (e.button !== 0) return;
|
|
const btn = e.currentTarget;
|
|
const rect = btn.getBoundingClientRect();
|
|
dragState = {
|
|
btn: btn,
|
|
offsetX: e.clientX - rect.left,
|
|
offsetY: e.clientY - rect.top,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
moved: false
|
|
};
|
|
// Prevent text selection during drag
|
|
e.preventDefault();
|
|
document.addEventListener("mousemove", onDragMove);
|
|
document.addEventListener("mouseup", onDragEnd);
|
|
btn.classList.add("fd-dragging");
|
|
}
|
|
|
|
function onDragMove(e) {
|
|
if (!dragState) return;
|
|
const dx = Math.abs(e.clientX - dragState.startX);
|
|
const dy = Math.abs(e.clientY - dragState.startY);
|
|
if (dx > 3 || dy > 3) dragState.moved = true;
|
|
const btn = dragState.btn;
|
|
let left = e.clientX - dragState.offsetX;
|
|
let top = e.clientY - dragState.offsetY;
|
|
const maxLeft = window.innerWidth - btn.offsetWidth - 4;
|
|
const maxTop = window.innerHeight - btn.offsetHeight - 4;
|
|
left = Math.max(4, Math.min(left, maxLeft));
|
|
top = Math.max(4, Math.min(top, maxTop));
|
|
btn.style.left = left + "px";
|
|
btn.style.top = top + "px";
|
|
btn.style.right = "auto";
|
|
btn.style.bottom = "auto";
|
|
}
|
|
|
|
async function onDragEnd(e) {
|
|
document.removeEventListener("mousemove", onDragMove);
|
|
document.removeEventListener("mouseup", onDragEnd);
|
|
if (!dragState) return;
|
|
const btn = dragState.btn;
|
|
btn.classList.remove("fd-dragging");
|
|
const moved = dragState.moved;
|
|
dragState = null;
|
|
if (moved) {
|
|
// Suppress next click
|
|
btn.dataset.dragged = "1";
|
|
setTimeout(() => { btn.dataset.dragged = "0"; }, 400);
|
|
const left = parseInt(btn.style.left, 10);
|
|
const top = parseInt(btn.style.top, 10);
|
|
if (!isNaN(left) && !isNaN(top)) {
|
|
try {
|
|
const data = await chrome.storage.local.get(STORAGE_KEY);
|
|
const cfg = data[STORAGE_KEY] || {};
|
|
cfg.pos = { left: left, top: top };
|
|
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
|
|
} catch (_e) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Listen to storage changes (show/hide toggle & position sync across tabs)
|
|
try {
|
|
if (chrome.storage && chrome.storage.onChanged) {
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area !== "local" || !changes[STORAGE_KEY]) return;
|
|
const nv = changes[STORAGE_KEY].newValue || {};
|
|
const ov = changes[STORAGE_KEY].oldValue || {};
|
|
if (nv.showButton !== ov.showButton) {
|
|
applyVisibility(nv.showButton !== false);
|
|
}
|
|
// Position already handled via direct style; if changed elsewhere sync it
|
|
if (nv.pos && JSON.stringify(nv.pos) !== JSON.stringify(ov.pos)) {
|
|
const el = document.getElementById(BTN_ID);
|
|
if (el && nv.pos.left != null) {
|
|
el.style.left = nv.pos.left + "px";
|
|
el.style.top = nv.pos.top + "px";
|
|
el.style.right = "auto";
|
|
el.style.bottom = "auto";
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (_e) {}
|
|
|
|
function getSelectionHtml() {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0 || sel.toString().trim().length < 3) return "";
|
|
const range = sel.getRangeAt(0);
|
|
const div = document.createElement("div");
|
|
div.appendChild(range.cloneContents());
|
|
return div.innerHTML;
|
|
}
|
|
|
|
async function openClipper(type) {
|
|
const html = document.documentElement.outerHTML;
|
|
const selectionHtml = getSelectionHtml();
|
|
const selectionText = window.getSelection() ? window.getSelection().toString().trim() : "";
|
|
let clipType = type;
|
|
if (selectionHtml && selectionText.length > 20) clipType = "selection";
|
|
const payload = {
|
|
url: window.location.href,
|
|
title: document.title,
|
|
content: html,
|
|
content_type: clipType,
|
|
selection_html: selectionHtml,
|
|
metadata: {
|
|
og_title: document.querySelector('meta[property="og:title"]')?.content || "",
|
|
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || "",
|
|
author: document.querySelector('meta[name="author"]')?.content || ""
|
|
}
|
|
};
|
|
try {
|
|
const resp = await chrome.runtime.sendMessage({ action: "clip", data: payload });
|
|
if (resp && resp.error === "not_authenticated") {
|
|
alert("FlowDeck: not authenticated. Open FlowDeck settings to connect the clipper.");
|
|
} else if (resp && resp.status === "ok") {
|
|
showToast("Clipped to FlowDeck \u2713 \u2014 page " + resp.page_id);
|
|
} else if (resp && resp.error) {
|
|
showToast("Clip failed: " + resp.error, true);
|
|
}
|
|
} catch (e) {
|
|
console.error("FlowDeck clipper", e);
|
|
showToast("Clip failed: " + e.message, true);
|
|
}
|
|
}
|
|
|
|
function showToast(msg, isError) {
|
|
let t = document.getElementById("fd-clipper-toast");
|
|
if (!t) {
|
|
t = document.createElement("div");
|
|
t.id = "fd-clipper-toast";
|
|
t.className = "fd-clipper-toast";
|
|
document.body.appendChild(t);
|
|
}
|
|
t.textContent = msg;
|
|
t.style.background = isError ? "#e03e3e" : "#1a7f37";
|
|
t.classList.add("show");
|
|
setTimeout(() => t.classList.remove("show"), 3000);
|
|
}
|
|
|
|
// Runtime messages: from background (context menu, refresh) or popup
|
|
try {
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (!message || !message.action) return;
|
|
if (message.action === "clipSelection") {
|
|
openClipper("selection");
|
|
try { sendResponse({ ok: true }); } catch (_e) {}
|
|
return true;
|
|
}
|
|
if (message.action === "fdRefreshTree" || message.action === "refreshTree" || message.action === "fdClipCreated") {
|
|
// FlowDeck page refresh signal (injected on FlowDeck origin)
|
|
try {
|
|
if (window.appState && typeof window.appState.refreshSidebarTree === "function") {
|
|
window.appState.refreshSidebarTree();
|
|
} else {
|
|
window.dispatchEvent(new CustomEvent("flowdeck:clip-created", { detail: message.data || {} }));
|
|
document.dispatchEvent(new CustomEvent("flowdeck:workspace-changed"));
|
|
// Fallback: try calling global
|
|
if (window.FlowDeck && window.FlowDeck._toast) {}
|
|
}
|
|
// Also try direct fetch fallback
|
|
const ul = document.getElementById("sidebar-workspace-items");
|
|
if (ul && window.appState && window.appState.refreshSidebarTree) {
|
|
window.appState.refreshSidebarTree();
|
|
}
|
|
} catch (_e) {}
|
|
try { sendResponse({ ok: true }); } catch (_e) {}
|
|
return true;
|
|
}
|
|
if (message.action === "toggleButton") {
|
|
applyVisibility(message.show);
|
|
try { sendResponse({ ok: true }); } catch (_e) {}
|
|
return true;
|
|
}
|
|
if (message.action === "getSelectionHtml") {
|
|
try { sendResponse({ html: getSelectionHtml() }); } catch (_e) {}
|
|
return true;
|
|
}
|
|
});
|
|
} catch (_e) {}
|
|
|
|
// Keyboard shortcut Ctrl+Shift+C
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") {
|
|
e.preventDefault();
|
|
openClipper("article");
|
|
}
|
|
});
|
|
|
|
// Expose for popup / background scripting
|
|
window.__fdOpenClipper = openClipper;
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", createFloatButton);
|
|
} else {
|
|
createFloatButton();
|
|
}
|
|
})();
|