Cosenseでホバーカードを実装する
https://gyazo.com/ed95c0378d313a0c36397e2ed1528241
自分のCosenseには動画のようにリンクをホバーすると概要が見れるといった機能を実装しています。
ただ、これメンバーごとにJSを埋め込んでるので、他の人には反映されないのが難点…。
もし他の人にもデフォルトで実装する方法ご存じの方いらっしゃいましたら教えてくださいHelloPeople.icon
なんと…課金しないと実装できないんですね…。HelloPeople.icon
任意のJSを他者のブラウザで実行可能になってしまうので、ビジネス版以外で使えるようになることはまずないと思いますtakker.icon
cf. ProjectCSSは見た目を無茶苦茶にできるけど任意コード実行まではできないので許容されている デザインがかっこいいtakker.icon
ありがとうございます!どんどん使ってください!HelloPeople.icon
ソースコード
code:script.js
// Cosense Hover Card Preview
(() => {
const PROJECT = location.pathname.split('/')1; const CACHE = new Map();
let card = null;
let hideTimer = null;
let showTimer = null;
let currentLink = null;
function createCard() {
const el = document.createElement('div');
el.id = 'hover-card';
el.innerHTML = `
<div class="hc-image"></div>
<div class="hc-body">
<div class="hc-title"></div>
<div class="hc-desc"></div>
</div>
`;
document.body.appendChild(el);
el.addEventListener('mouseenter', () => {
clearTimeout(hideTimer);
});
el.addEventListener('mouseleave', () => {
scheduleHide();
});
return el;
}
const style = document.createElement('style');
style.textContent = `
position: fixed;
z-index: 100000;
width: 260px;
border-radius: 20px;
box-shadow: 0 8px 30px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04);
overflow: hidden;
opacity: 0;
pointer-events: none;
transform: translateY(6px);
transition: opacity .18s ease, transform .18s ease;
font-family: 'Inter', 'Zen Kaku Gothic New', sans-serif;
}
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
width: 100%;
height: 140px;
background-size: cover;
background-position: center;
}
background: none;
}
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
padding: 14px 16px 16px;
}
font-weight: 700;
font-size: 15px;
line-height: 1.3;
margin-bottom: 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
font-size: 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
`;
document.head.appendChild(style);
async function fetchPage(title) {
if (CACHE.has(title)) return CACHE.get(title);
try {
const res = await fetch(/api/pages/${PROJECT}/${encodeURIComponent(title)});
if (!res.ok) return null;
const data = await res.json();
CACHE.set(title, data);
return data;
} catch {
return null;
}
}
function extractImage(page) {
if (page.image) return page.image;
for (const line of (page.descriptions || [])) {
const m = line.match(/https?:\/\/^\s\]+\.(?:png|jpe?g|gif|webp|svg)/i); }
for (const l of (page.lines || []).slice(0, 10)) {
const m = l.text?.match(/https?:\/\/^\s\]+\.(?:png|jpe?g|gif|webp|svg)/i); }
return null;
}
function show(link, data) {
if (!card) card = createCard();
const imgEl = card.querySelector('.hc-image');
const titleEl = card.querySelector('.hc-title');
const descEl = card.querySelector('.hc-desc');
titleEl.textContent = data.title || '';
descEl.textContent = (data.descriptions || []).join(' ').slice(0, 200);
const imgUrl = extractImage(data);
imgEl.innerHTML = '';
if (imgUrl) {
imgEl.classList.add('has-img');
const img = document.createElement('img');
img.src = imgUrl;
img.loading = 'lazy';
imgEl.appendChild(img);
} else {
imgEl.classList.remove('has-img');
}
const rect = link.getBoundingClientRect();
let top = rect.bottom + 8;
let left = rect.left;
if (left + 270 > window.innerWidth) left = window.innerWidth - 275;
if (left < 5) left = 5;
if (top + 280 > window.innerHeight) top = rect.top - 280;
card.style.top = top + 'px';
card.style.left = left + 'px';
card.classList.add('visible');
}
function hide() {
if (card) card.classList.remove('visible');
currentLink = null;
}
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(hide, 250);
}
// リンクに直接 mouseenter/mouseleave を付与
const attachedLinks = new WeakSet();
function attachLink(link) {
if (attachedLinks.has(link)) return;
attachedLinks.add(link);
link.addEventListener('mouseenter', () => {
clearTimeout(hideTimer);
clearTimeout(showTimer);
if (link === currentLink && card && card.classList.contains('visible')) return;
currentLink = link;
showTimer = setTimeout(async () => {
const title = link.getAttribute('data-page-title');
if (!title) return;
const data = await fetchPage(title);
if (!data || currentLink !== link) return;
show(link, data);
}, 300);
});
link.addEventListener('mouseleave', (e) => {
clearTimeout(showTimer);
const related = e.relatedTarget;
if (card && (card === related || card.contains(related))) return;
scheduleHide();
});
}
function scanLinks() {
document.querySelectorAll('a.page-link').forEach(attachLink);
}
scanLinks();
const observer = new MutationObserver(() => scanLinks());
observer.observe(document.body, { childList: true, subtree: true });
document.addEventListener('scroll', () => {
clearTimeout(showTimer);
hide();
}, true);
})();