Náhled sdílení odkazu na sociálních sítích

Open Graph & Twitter Card Preview Generator — SeoLabs

Náhled sdílení odkazu na sociálních sítích

Zjistěte, jak se váš odkaz zobrazí při sdílení na Facebooku, LinkedIn a X (Twitter). Tento nástroj umožňuje vytvořit a zkontrolovat meta tagy Open Graph a Twitter Card, ověřit správné rozměry obrázku a náhled titulku i popisu v reálném čase – vše zdarma.

Vstupy

📝 Obsah
0
Doporučení: 30–70 znaků (nevynucujeme limit).
0
Doporučení: 70–160 znaků.

🏷️ Brand & URL

🗂️ Platforma & typ karty
Google (SERP) – doplňky
Každý řádek = jeden link. Pouze vizuální.
Formát: twitter:site | twitter:creator | og:locale (oddělte čárkou nebo svislítkem)
🖼️ Obrázek
Tip: Pokud se obrázek nezobrazí kvůli CORS, nahrajte lokální soubor níže.
Přetáhněte sem obrázek (PNG/JPG/WebP), vložte ze schránky (Ctrl+V) nebo použijte tlačítko

🧰 Nástroje obrázku
Úprava velikosti obrázku byla odstraněna. Nástroj slouží pro náhledy a generování meta tagů. Pokud potřebujete změnit rozměry, použijte prosím externí editor a obrázek znovu nahrajte.

📤 Výstup
0.90

⚙️ Pokročilé

Náhledy & KPI

OG náhled obrázku

Co je Open Graph a proč je důležitý pro sociální sítě

Open Graph (OG) protokol je sada meta informací v HTML, která pomáhá platformám jako Facebook nebo LinkedIn pochopit, jak prezentovat váš odkaz. Správně nastavené OG tagy zvyšují míru prokliku, konzistenci náhledu a kontrolu nad tím, jak se váš obsah sdílí. Díky tomu se vyhnete náhodným náhledům, špatně oříznutým obrázkům, nebo duplicitním titulkům.

Mezi klíčové OG tagy patří: og:title, og:description, og:image, og:url a og:site_name. Tyto tagy přímo ovlivňují, co v náhledu uvidíte – titulek, popis, cílovou adresu a náhledový obrázek. Platformy navíc udržují vlastní pravidla pro minimální rozměry či poměry stran obrázků. V tomto nástroji uvidíte doporučení a indikaci, jestli je obrázek vhodný.

Twitter Cards: summary vs summary_large_image

X (Twitter) používá dva hlavní typy karet: summary_large_image (16:9 s velkým obrázkem) a summary (čtvercová miniatura vlevo, text vpravo). Oba typy mají jiná doporučení na poměr stran a typicky i různé ořezy. Tento nástroj umožňuje rychle přepínat typ karty a ihned vidět, jak budou titulky a popisy působit v praxi.

Pokud sdílíte stejné URL na více kanálech, dbejte na přiměřenou délku titulku (ideál 30–70 znaků) a popisu (70–160 znaků). Příliš krátké a příliš dlouhé texty mohou snižovat srozumitelnost a výkon. V nástroji proto najdete jednoduché KPI indikátory.

Správná velikost a poměr obrázku

Pro Open Graph je běžné doporučení 1200×630 px (poměr ~1.91:1), zatímco summary_large_image na X (Twitter) preferuje 1200×628 px se stejným poměrem. Pro kartu summary cílte na 600×600 px (1:1). Důležitější než absolutní rozlišení je konzistence poměru stran a dostatečná šířka pro kvalitní rendering (alespoň 600 px).

Pamatujte, že platformy mohou své náhledy aktualizovat a některá pravidla se časem mění. Proto je výhodné náhled vždy otestovat, než nasadíte novou šablonu či kampaň.

Jak vygenerovat meta tagy

V horní části nástroje vyplňte Title, Description, URL, Site name a volitelně alt text obrázku. Následně klikněte na „Zkopírovat <meta> tagy“. Vložené tagy zkopírujte do sekce <head> vaší stránky. Minimální doporučená sada vypadá takto:

<meta property="og:title" content="..." />
<meta property="og:description" content="..." />
<meta property="og:image" content="..." />
<meta property="og:url" content="..." />
<meta property="og:site_name" content="..." />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image|summary" />
<meta name="twitter:title" content="..." />
<meta name="twitter:description" content="..." />
<meta name="twitter:image" content="..." />

Pokud používáte og:image:alt a twitter:image:alt, zlepšíte přístupnost a kontext pro uživatele i vyhledávače. Volitelné jsou také twitter:site, twitter:creator a og:locale.

Časté problémy: CORS a měření obrázků

Při načtení obrázku ze vzdálené URL mohou servery blokovat přístup kvůli CORS. Výsledkem je, že sice vidíte obrázek, ale nelze s ním dělat operace přes canvas (např. převzorkování). Tento nástroj proto dává jasnou informaci, když se obrázek z URL nepodaří změřit. Nejjednodušší řešení je nahrát obrázek jako lokální soubor.

SEO tipy: obsah, rychlost a konzistence

  • Konzistentní metadata: Udržujte soulad mezi titulkem stránky, OG titulkem a H1 nadpisem. Nepřehánějte s klíčovými slovy.
  • Popis s hodnotou: Popis by měl doplňovat titulek, slibovat konkrétní přínos a být čitelný i při zalomení.
  • Rychlost: Optimalizujte obrázky (komprese, správný formát), aby náhledy načítaly rychle.
  • Testování: Ověřte náhledy na desktopu i mobilu, pro různé platformy (OG, Twitter, Slack, Discord, Telegram, Instagram, Google simulace).
  • Verzování: Při změně obrázku použijte query param (cache buster), aby si platformy načetly novou verzi.

Jak funguje tento nástroj (technicky)

Nástroj je 100% v prohlížeči, bez backendu a bez externích knihoven. Využívá čisté HTML, CSS a ES6 JavaScript. Obrázky z lokálních souborů se načítají přes FileReader, URL obrázků se zkouší načíst přímo a případně přes fetch na Blob. Převzorkování bylo na přání odstraněno, protože může být limitováno CORS a zbytečně komplikovat UX.

Pro koho je nástroj určený

Pro marketéry, SEO specialisty, copywritery, vývojáře i provozovatele webu, kteří chtějí rychle a přesně připravit náhledy pro sociální sítě. Zjednodušuje práci při přípravě kampaní, blogpostů, produktových stránek i PR sdělení.

Nejčastější otázky

Proč se mi obrázek z URL někdy nezobrazí?

Nejčastěji kvůli CORS – server obrázek neumožní načíst do kontextu prohlížeče. Zkuste nahrát lokálně, nebo použijte jiný zdroj, který CORS povoluje.

Proč se náhled liší od toho, co vidím na Facebooku?

Platformy mají vlastní heuristiky a mohou provádět ořezy či zalamování jinak. Tento nástroj se snaží o věrnou kompozici, ale neobsahuje brand prvky a přesné algoritmy každé platformy.

Musím používat všechny OG a Twitter meta tagy?

Ne, ale doporučujeme minimální sadu uvedenou výše. Čím lépe metadata popíšete, tím větší kontrolu nad náhledem budete mít.

Další rozšíření

Do budoucna je možné doplnit export náhledů pro další platformy, pokročilejší simulaci zalomení textu, nebo generátor statické stránky s vloženými metadaty. Vše probíhá 100% lokálně v prohlížeči.

`; const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); triggerDownload(url, 'meta-tags.html'); setTimeout(() => URL.revokeObjectURL(url), 1000); }); // Kopírování JSON byId('btnCopyJSON').addEventListener('click', async () => { const json = JSON.stringify({ title: state.title, description: state.description, url: state.url, siteName: state.siteName, cardType: state.twitterCard, image: { src: state.image.src, width: state.image.width, height: state.image.height } }, null, 2); const ok = await copyToClipboard(json); setHint(ok ? 'JSON zkopírován do schránky.' : 'Kopírování se nezdařilo.'); }); // Reset byId('btnReset').addEventListener('click', () => { Object.assign(state, { ...defaults, image: { src: '', width: 0, height: 0, aspect: 0, sourceType: 'none' }, lastCanvasDataUrl: '' }); byId('title').value = state.title; byId('description').value = state.description; byId('sitename').value = state.siteName; byId('url').value = state.url; byId('twitterCard').value = state.twitterCard; byId('showBrand').checked = state.showBrand; byId('imgAlt').value = state.imgAlt || ''; byId('metaExtras').value = ''; byId('outFormat').value = state.output.format; byId('outQuality').value = state.output.quality; byId('outQualityVal').textContent = state.output.quality.toFixed(2); byId('customSize').value = ''; byId('imageUrl').value = ''; byId('imageFile').value = ''; byId('btnDownload').href = '#'; byId('btnDownload').setAttribute('aria-disabled','true'); toggleGrid(false); toggleFocal(false); setActiveTwitterLayout(); enableImageActions(false); setHint('Nastaveny výchozí hodnoty.'); updateCounters(); updatePreviews(); }); // Volby formátu/kvality byId('outFormat').addEventListener('change', (e) => { state.output.format = e.target.value; }); byId('outQuality').addEventListener('input', (e) => { const v = Number(e.target.value); state.output.quality = clamp(v, 0.5, 1); byId('outQualityVal').textContent = state.output.quality.toFixed(2); }); // Zoom pro crop const qualityEl = byId('outQuality'); qualityEl.addEventListener('change', (e) => { const v = Number(e.target.value); state.output.quality = clamp(v, 0.5, 1); byId('outQualityVal').textContent = state.output.quality.toFixed(2); }); // Vlastní rozměry // (Vlastní rozměry odebrány) // Focal point toggle a grid byId('btnToggleGrid').addEventListener('click', () => toggleGrid(!state.showGrid)); byId('btnToggleFocal').addEventListener('click', () => { pushHistory(); toggleFocal(!state.focal.enabled); }); // Otevřít crop editor (stejný toggle použijeme k zobrazení modalu) byId('btnToggleFocal').addEventListener('click', () => { if (state.focal.enabled) openCropModal(); }); // Top actions mirror byId('btnToggleGridTop').addEventListener('click', () => toggleGrid(!state.showGrid)); byId('btnToggleFocalTop').addEventListener('click', () => { pushHistory(); toggleFocal(!state.focal.enabled); if (state.focal.enabled) openCropModal(); }); // (Dávkový export odebrán) // Sdílení přes URL (hash base64 JSON) byId('btnShareUrl').addEventListener('click', async () => { const share = buildShareState(); const json = JSON.stringify(share); const b64 = btoa(unescape(encodeURIComponent(json))); const url = `${location.origin}${location.pathname}#s=${b64}`; const ok = await copyToClipboard(url); setHint(ok ? 'Odkaz zkopírován. Sdílej tento URL.' : 'Kopírování odkazu selhalo.'); }); // Export náhledu jako PNG (bez cizích domén – čistý DOM, bez cross-origin obrázků) byId('btnExportOgPng').addEventListener('click', () => exportPreviewAsPng('previewOg', 'og-preview.png')); byId('btnExportTwitterPng').addEventListener('click', () => exportPreviewAsPng('previewTwitter', 'twitter-preview.png')); // Import JSON konfigurace byId('btnImportJSON').addEventListener('click', async () => { const file = byId('jsonFile').files && byId('jsonFile').files[0]; if (!file) { setHint('Vyberte JSON soubor.'); return; } try { const text = await file.text(); const cfg = JSON.parse(text); applyConfig(cfg); setHint('Konfigurace načtena.'); } catch { setHint('Načtení JSON selhalo.'); } }); // Crop modal byId('btnCloseCrop').addEventListener('click', closeCropModal); byId('btnSaveCrop').addEventListener('click', saveCropModal); byId('cropAspect').addEventListener('change', () => { cropEditor.aspectMode = byId('cropAspect').value; resizeCropCanvas(); drawCropCanvas(); }); byId('cropZoom2').addEventListener('input', (e) => { cropEditor.zoom = clamp(Number(e.target.value), 1, 3); byId('cropZoom2Val').textContent = `${cropEditor.zoom.toFixed(2)}×`; drawCropCanvas(); }); // Tab přepínače byId('tabOg').addEventListener('click', () => setActiveTab('og')); byId('tabTwitter').addEventListener('click', () => setActiveTab('twitter')); byId('tabSlack').addEventListener('click', () => setActiveTab('slack')); byId('tabDiscord').addEventListener('click', () => setActiveTab('discord')); byId('tabTelegram').addEventListener('click', () => setActiveTab('telegram')); const tabIg = byId('tabInstagram'); if (tabIg) tabIg.addEventListener('click', () => setActiveTab('instagram')); const tabG = byId('tabGoogle'); if (tabG) tabG.addEventListener('click', () => setActiveTab('google')); // default ensure OG visible setActiveTab('og'); } function generateMetaTags() { const img = state.image.src || ''; const lines = [ ``, ``, ``, state.imgAlt ? `` : '', ``, ``, state.extras.ogLocale ? `` : '', ``, ``, ``, ``, ``, state.imgAlt ? `` : '', state.extras.twitterSite ? `` : '', state.extras.twitterCreator ? `` : '' ]; return lines.filter(Boolean).join('\n'); } function escapeHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } async function copyToClipboard(text) { try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return true; } } catch {} // Fallback try { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, 999999); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch { return false; } } // ---------- Extras parsing, custom size, downloads ---------- function parseExtras(val) { // povolíme formáty: "@site, @creator, cs_CZ" nebo "@site | @creator | cs_CZ" const parts = String(val).split(/[|,]/).map(s => s.trim()).filter(Boolean); // mapování: 0 -> twitter:site, 1 -> twitter:creator, 2 -> og:locale state.extras.twitterSite = parts[0] || ''; state.extras.twitterCreator = parts[1] || ''; state.extras.ogLocale = parts[2] || ''; } function parseCustomSize(text) { const m = String(text).trim().match(/^(\d+)\s*[xX]\s*(\d+)$/); if (!m) return null; const w = parseInt(m[1], 10); const h = parseInt(m[2], 10); if (!w || !h || w > 8000 || h > 8000) return null; return { w, h }; } function fileExtFromMime(mime) { if (mime === 'image/png') return 'png'; if (mime === 'image/webp') return 'webp'; return 'jpg'; } function triggerDownload(dataUrl, filename) { const a = document.createElement('a'); a.href = dataUrl; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // ---------- Ohniskový bod & mřížka ---------- function toggleGrid(on) { state.showGrid = !!on; const wraps = ['ogWrap','twLargeWrap','twSummaryWrap']; for (const id of wraps) { const el = byId(id); if (!el) continue; el.classList.toggle('show-grid', state.showGrid); } } function toggleFocal(on) { state.focal.enabled = !!on; const wraps = ['ogWrap','twLargeWrap','twSummaryWrap']; const dots = ['ogFocal','twLargeFocal','twSummaryFocal']; for (let i = 0; i < wraps.length; i++) { const w = byId(wraps[i]); const d = byId(dots[i]); if (!w || !d) continue; w.classList.toggle('show-focal', state.focal.enabled); positionFocalDot(d, w, state.focal.x, state.focal.y); // klikání pro změnu ohniska if (state.focal.enabled) { w.onpointerdown = (ev) => { const rect = w.getBoundingClientRect(); const x = clamp((ev.clientX - rect.left) / rect.width, 0, 1); const y = clamp((ev.clientY - rect.top) / rect.height, 0, 1); state.focal.x = x; state.focal.y = y; positionFocalDot(d, w, x, y); }; } else { w.onpointerdown = null; } } } function positionFocalDot(dot, wrap, x, y) { const rect = wrap.getBoundingClientRect(); dot.style.left = `${x * rect.width}px`; dot.style.top = `${y * rect.height}px`; } // ---------- LocalStorage (autosave) ---------- const LS_KEY = 'og_tw_preview_state_v1'; function saveToLocalStorage() { try { const { image, lastCanvasDataUrl, ...rest } = state; // neukládat binární dataURL velkých obrazů automaticky localStorage.setItem(LS_KEY, JSON.stringify(rest)); } catch {} } function loadFromLocalStorage() { try { const raw = localStorage.getItem(LS_KEY); if (!raw) return false; const data = JSON.parse(raw); Object.assign(state, data); return true; } catch { return false; } } // ---------- Inicializace ---------- function init() { // Načíst autosave, pokud existuje loadFromLocalStorage(); bindEvents(); // Výchozí hodnoty do inputů byId('title').value = state.title; byId('description').value = state.description; byId('sitename').value = state.siteName; byId('url').value = state.url; byId('twitterCard').value = state.twitterCard; byId('showBrand').checked = state.showBrand; byId('igAspect').value = state.igAspect || '1:1'; byId('imgAlt').value = state.imgAlt || ''; byId('metaExtras').value = [state.extras.twitterSite, state.extras.twitterCreator, state.extras.ogLocale].filter(Boolean).join(', '); byId('gDate').value = state.g.date || ''; byId('gType').value = state.g.type || ''; byId('gSitelinks').value = state.g.sitelinks || ''; byId('outFormat').value = state.output.format; byId('outQuality').value = state.output.quality; byId('outQualityVal').textContent = state.output.quality.toFixed(2); setActiveTwitterLayout(); updateCounters(); enableImageActions(false); updatePreviews(); // počáteční meta try { byId('metaPreview').value = generateMetaTags(); } catch {} // Autosave listeners (debounced) document.addEventListener('input', () => debounce(saveToLocalStorage, 300)); // Collapsible sections init/restore const sections = [ 'sec-obsah','sec-brand','sec-platform','sec-image','sec-tools','sec-output','sec-meta','sec-advanced' ]; sections.forEach(id => { const heading = byId(id); if (!heading) return; heading.classList.add('collapsible'); heading.addEventListener('click', () => toggleSection(id)); const collapsed = localStorage.getItem('coll_'+id) === '1'; if (collapsed) collapseSection(id, true); }); // Podpora načtení konfigurace z URL hashe try { const hash = location.hash; const m = hash.match(/#s=([^&]+)/); if (m && m[1]) { const json = decodeURIComponent(escape(atob(m[1]))); applyConfig(JSON.parse(json)); setHint('Konfigurace načtena z odkazu.'); } const p = hash.match(/presets=([^&]+)/); if (p && p[1]) { const payload = JSON.parse(decodeURIComponent(escape(atob(p[1])))); if (payload.p) { byId('presetList').value = payload.p; byId('btnBatchExportCustom').disabled = parsePresetList(payload.p).length === 0; setHint('Presety načteny z odkazu.'); } } } catch {} // Undo/Redo shortcuts document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); } if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) { e.preventDefault(); redo(); } }); } // ---------- Undo/Redo ---------- function shallowSnapshot() { return { title: state.title, description: state.description, url: state.url, siteName: state.siteName, twitterCard: state.twitterCard, showBrand: state.showBrand, imgAlt: state.imgAlt, output: { ...state.output }, focal: { ...state.focal } }; } function pushHistory() { try { state.history.push(shallowSnapshot()); state.future = []; } catch {} } function undo() { if (!state.history.length) return; const snap = state.history.pop(); state.future.push(shallowSnapshot()); Object.assign(state, snap); byId('title').value = state.title; byId('description').value = state.description; byId('sitename').value = state.siteName; byId('url').value = state.url; byId('twitterCard').value = state.twitterCard; byId('showBrand').checked = state.showBrand; byId('igAspect').value = state.igAspect || '1:1'; byId('imgAlt').value = state.imgAlt || ''; byId('outFormat').value = state.output.format; byId('outQuality').value = state.output.quality; byId('outQualityVal').textContent = state.output.quality.toFixed(2); setActiveTwitterLayout(); updateCounters(); updatePreviews(); } function redo() { if (!state.future.length) return; const snap = state.future.pop(); state.history.push(shallowSnapshot()); Object.assign(state, snap); byId('title').value = state.title; byId('description').value = state.description; byId('sitename').value = state.siteName; byId('url').value = state.url; byId('twitterCard').value = state.twitterCard; byId('showBrand').checked = state.showBrand; byId('igAspect').value = state.igAspect || '1:1'; byId('imgAlt').value = state.imgAlt || ''; byId('outFormat').value = state.output.format; byId('outQuality').value = state.output.quality; byId('outQualityVal').textContent = state.output.quality.toFixed(2); setActiveTwitterLayout(); updateCounters(); updatePreviews(); } function toggleSection(id){ const h = byId(id); if (!h) return; const group = h.nextElementSibling; const isCollapsed = h.classList.toggle('collapsed'); if (group && group.classList.contains('section-group')) { group.style.display = isCollapsed ? 'none' : ''; } try { localStorage.setItem('coll_'+id, isCollapsed ? '1' : '0'); } catch {} } function collapseSection(id, collapsed){ const h = byId(id); if (!h) return; const group = h.nextElementSibling; if (collapsed) { h.classList.add('collapsed'); if (group) group.style.display = 'none'; } else { h.classList.remove('collapsed'); if (group) group.style.display = ''; } } // ---------- Helpers: presety, export DOM, konfigurace ---------- function parsePresetList(text) { return String(text) .split(',') .map(s => s.trim()) .map(parseCustomSize) .filter(Boolean); } // Presets save/load/share function savePresets(text) { try { localStorage.setItem('og_tw_presets', text); setHint('Presety uloženy.'); } catch {} } function loadPresets() { try { return localStorage.getItem('og_tw_presets') || ''; } catch { return ''; } } function sharePresets(text) { const payload = { p: text }; const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); const url = `${location.origin}${location.pathname}#presets=${b64}`; return url; } function buildShareState() { // kompaktní podmnožina bez velkých dat return { t: state.title, d: state.description, u: state.url, s: state.siteName, c: state.twitterCard, a: state.imgAlt, x: { ts: state.extras.twitterSite, tc: state.extras.twitterCreator, ol: state.extras.ogLocale } }; } function applyConfig(cfg) { // přijat JSON ve tvaru plném nebo sdíleném (kompaktním) const full = { title: cfg.title ?? cfg.t ?? state.title, description: cfg.description ?? cfg.d ?? state.description, url: cfg.url ?? cfg.u ?? state.url, siteName: cfg.siteName ?? cfg.s ?? state.siteName, twitterCard: cfg.cardType ?? cfg.c ?? state.twitterCard, imgAlt: cfg.imgAlt ?? cfg.a ?? state.imgAlt, extras: cfg.extras ?? { twitterSite: cfg.x?.ts ?? state.extras.twitterSite, twitterCreator: cfg.x?.tc ?? state.extras.twitterCreator, ogLocale: cfg.x?.ol ?? state.extras.ogLocale } }; Object.assign(state, full); byId('title').value = state.title; byId('description').value = state.description; byId('sitename').value = state.siteName; byId('url').value = state.url; byId('twitterCard').value = state.twitterCard; byId('imgAlt').value = state.imgAlt || ''; byId('metaExtras').value = [state.extras.twitterSite, state.extras.twitterCreator, state.extras.ogLocale].filter(Boolean).join(', '); setActiveTwitterLayout(); updateCounters(); updatePreviews(); } function exportPreviewAsPng(regionId, filename) { // Jednoduchý DOM -> canvas rasterizer (bez cizích obrázků by měl projít) const region = byId(regionId); const rect = region.getBoundingClientRect(); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.floor(rect.width)); canvas.height = Math.max(1, Math.floor(rect.height)); const ctx = canvas.getContext('2d'); // Vyplnit pozadí ctx.fillStyle = '#0f141a'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Vykreslit obrázky a texty ručně jen pro náš layout try { if (regionId === 'previewOg') { drawOgToCanvas(ctx, canvas.width); } else { drawTwitterToCanvas(ctx, canvas.width, regionId === 'previewTwitter' && state.twitterCard === 'summary'); } triggerDownload(canvas.toDataURL('image/png'), filename); } catch { setHint('Export PNG se nepodařil (pravděpodobně kvůli cross-origin obrázku).'); } } function drawOgToCanvas(ctx, cw) { // Rozměry zhruba podle náhledu const pad = 12; const imgH = Math.round(cw / 1.91); // Obrázek const imgEl = byId('ogImage'); if (imgEl && imgEl.complete) { try { ctx.drawImage(imgEl, 0, 0, cw, imgH); } catch {} } // Texty ctx.fillStyle = '#e6eef7'; ctx.font = '700 16px system-ui'; ctx.fillText(state.title, pad, imgH + pad + 14); ctx.fillStyle = '#cfe2f6'; ctx.font = '12px system-ui'; const desc = clipText(state.description, 120); ctx.fillText(desc, pad, imgH + pad + 34); ctx.fillStyle = '#9fb2c7'; ctx.font = '12px system-ui'; const footer = `${state.siteName || ''} • ${getDomain(state.url) || ''}`; ctx.fillText(footer, pad, imgH + pad + 54); } function drawTwitterToCanvas(ctx, cw, summary) { if (summary) { const left = 120; const imgEl = byId('twSummaryImage'); if (imgEl && imgEl.complete) { try { ctx.drawImage(imgEl, 0, 0, left, left); } catch {} } ctx.fillStyle = '#e6eef7'; ctx.font = '700 14px system-ui'; ctx.fillText(state.title, left + 12, 18); ctx.fillStyle = '#cfe2f6'; ctx.font = '12px system-ui'; ctx.fillText(clipText(state.description, 110), left + 12, 38); ctx.fillStyle = '#9fb2c7'; ctx.font = '12px system-ui'; ctx.fillText(getDomain(state.url) || '', left + 12, 58); } else { const imgH = Math.round(cw / 1.91); const imgEl = byId('twLargeImage'); if (imgEl && imgEl.complete) { try { ctx.drawImage(imgEl, 0, 0, cw, imgH); } catch {} } ctx.fillStyle = '#e6eef7'; ctx.font = '700 14px system-ui'; ctx.fillText(state.title, 12, imgH + 20); ctx.fillStyle = '#cfe2f6'; ctx.font = '12px system-ui'; ctx.fillText(clipText(state.description, 120), 12, imgH + 38); ctx.fillStyle = '#9fb2c7'; ctx.font = '12px system-ui'; ctx.fillText(getDomain(state.url) || '', 12, imgH + 56); } } function clipText(text, max) { const t = String(text || ''); return t.length > max ? t.slice(0, max - 1) + '…' : t; } // ---------- Crop editor logic ---------- const cropEditor = { aspectMode: 'auto', zoom: 1, w: 0, h: 0 }; function openCropModal() { if (!state.image.src) { setHint('Nejprve nahrajte obrázek.'); return; } byId('cropModal').classList.add('show'); cropEditor.aspectMode = 'auto'; cropEditor.zoom = state.focal.zoom || 1; byId('cropAspect').value = 'auto'; byId('cropZoom2').value = cropEditor.zoom; byId('cropZoom2Val').textContent = `${cropEditor.zoom.toFixed(2)}×`; resizeCropCanvas(); attachCropHandlers(); drawCropCanvas(); } function closeCropModal() { byId('cropModal').classList.remove('show'); detachCropHandlers(); } function saveCropModal() { state.focal.zoom = cropEditor.zoom; closeCropModal(); setHint('Výřez uložen.'); } function resizeCropCanvas() { const c = byId('cropCanvas'); const targetAspect = cropEditor.aspectMode === 'auto' ? currentAspect() : Number(cropEditor.aspectMode); const wrap = c.parentElement.getBoundingClientRect(); c.width = Math.floor(Math.min(960, wrap.width - 2)); c.height = Math.floor(c.width / targetAspect); cropEditor.w = c.width; cropEditor.h = c.height; } function drawCropCanvas() { const c = byId('cropCanvas'); const ctx = c.getContext('2d'); ctx.fillStyle = '#0b0f13'; ctx.fillRect(0,0,c.width,c.height); const img = byId('ogImage'); if (!img || !img.complete) return; // spočti cover s ohniskem + zoom const iw = img.naturalWidth || state.image.width || 0; const ih = img.naturalHeight || state.image.height || 0; if (!iw || !ih) return; const s = Math.max(c.width / iw, c.height / ih) * clamp(cropEditor.zoom, 1, 3); const nw = iw * s; const nh = ih * s; const fx = clamp(state.focal.x, 0, 1); const fy = clamp(state.focal.y, 0, 1); const maxDx = Math.max(0, nw - c.width); const maxDy = Math.max(0, nh - c.height); const dx = - (fx * maxDx - maxDx / 2); const dy = - (fy * maxDy - maxDy / 2); try { ctx.drawImage(img, dx, dy, nw, nh); } catch {} // overlay mřížky ctx.strokeStyle = 'rgba(103,232,249,0.6)'; ctx.lineWidth = 1; const thirdX = c.width / 3; const thirdY = c.height / 3; for (let i=1;i<=2;i++){ ctx.beginPath(); ctx.moveTo(thirdX * i, 0); ctx.lineTo(thirdX * i, c.height); ctx.stroke(); } for (let i=1;i<=2;i++){ ctx.beginPath(); ctx.moveTo(0, thirdY * i); ctx.lineTo(c.width, thirdY * i); ctx.stroke(); } } function attachCropHandlers() { const c = byId('cropCanvas'); c.onpointerdown = (e) => { const r = c.getBoundingClientRect(); const x = clamp((e.clientX - r.left) / r.width, 0, 1); const y = clamp((e.clientY - r.top) / r.height, 0, 1); state.focal.x = x; state.focal.y = y; drawCropCanvas(); }; window.addEventListener('resize', onCropResize); } function detachCropHandlers() { const c = byId('cropCanvas'); c.onpointerdown = null; window.removeEventListener('resize', onCropResize); } function onCropResize(){ resizeCropCanvas(); drawCropCanvas(); } // ---------- Text wrapping measurement ---------- function measureWrapping() { const m = byId('measure'); m.style.width = Math.max(byId('previewOg').clientWidth, 320) + 'px'; // OG headline approx metrics m.style.font = '700 16px system-ui'; m.textContent = state.title; const titleOverflow = m.scrollWidth > m.clientWidth * 1.2; // hrubý odhad // OG desc m.style.font = '12px system-ui'; m.textContent = state.description; const descOverflow = m.scrollWidth > m.clientWidth * 2.2; // cca 2 řádky m.textContent = ''; return { titleOverflow, descOverflow }; } document.addEventListener('DOMContentLoaded', init); // Lightweight service worker for offline (PWA) if ('serviceWorker' in navigator) { const swCode = `self.addEventListener('install', (e)=>{self.skipWaiting();});self.addEventListener('activate', (e)=>{self.clients.claim();});self.addEventListener('fetch', (e)=>{e.respondWith((async()=>{try{return await fetch(e.request);}catch{return new Response('

Offline

',{headers:{'Content-Type':'text/html'}});} })());});`; const blob = new Blob([swCode], { type: 'application/javascript' }); const swUrl = URL.createObjectURL(blob); navigator.serviceWorker.register(swUrl).catch(()=>{}); }