Şemanın metin karşılığı yalnızca SVG çizilince `aria-label` olarak basılıyordu; JS çalıştırmayan botlar iskeletten başka bir şey görmüyordu. Artık aynı `ariaLabel` metni figure içinde bir `<details>` olarak sunucu HTML'ine girer. mermaid import/render hatası yakalanır: iskelet kalkar, metin açık gelir, mermaid'in body'de bıraktığı geçici div temizlenir. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
"use client";
|
||
|
||
// Ürün genelindeki "el çizimi" mermaid şemalarının tek kaynağı: /meraklisina
|
||
// hunisi de rehber yazılarındaki şemalar da bu bileşenle çizilir, böylece
|
||
// görünüm (font, renk teması, çizgi titremesi) her yerde birebir aynı kalır.
|
||
// Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir;
|
||
// sayfa paketine girmez.
|
||
|
||
import { useEffect, useId, useRef, useState } from "react";
|
||
import localFont from "next/font/local";
|
||
|
||
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
|
||
// Mocktail, Misti's Fonts). Türkçe glifler doğrulandı (ş, ğ, ı, İ… hepsi
|
||
// var). DİKKAT: lisans "kişisel kullanım için ücretsiz" — canlıya çıkmadan
|
||
// mistifonts.com/sweet-cucumber-mocktail adresinden ticari lisans alınmalı.
|
||
const elYazisi = localFont({
|
||
src: "../../../fonts/SweetCucumberMocktail.woff2",
|
||
display: "swap",
|
||
});
|
||
|
||
export function ElCizimiSema({
|
||
tanim,
|
||
ariaLabel,
|
||
figcaption,
|
||
}: {
|
||
/** Mermaid tanımı (flowchart vb.) */
|
||
tanim: string;
|
||
/** Şemanın tamamını anlatan erişilebilirlik metni */
|
||
ariaLabel: string;
|
||
figcaption?: React.ReactNode;
|
||
}) {
|
||
const [svg, setSvg] = useState<string | null>(null);
|
||
// mermaid yüklenemez ya da çizemezse (bazı tarayıcılarda `getBBox` hatası)
|
||
// sonsuz iskelet yerine metin karşılığı açık gelir
|
||
const [hata, setHata] = useState(false);
|
||
const figureRef = useRef<HTMLElement>(null);
|
||
// mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz
|
||
const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
|
||
|
||
// ~1 MB'lık mermaid mount'ta değil, şema görünürlüğe yaklaşınca insin —
|
||
// şemalar hep fold altında (/meraklisina sayfa sonu, rehber makale ortası).
|
||
useEffect(() => {
|
||
const el = figureRef.current;
|
||
if (!el) return;
|
||
let iptal = false;
|
||
const io = new IntersectionObserver(
|
||
(girisler) => {
|
||
if (!girisler.some((g) => g.isIntersecting)) return;
|
||
io.disconnect();
|
||
cizdir();
|
||
},
|
||
// Kullanıcı şemaya varmadan yükleme başlasın
|
||
{ rootMargin: "200px 0px" },
|
||
);
|
||
io.observe(el);
|
||
|
||
function cizdir() {
|
||
import("mermaid")
|
||
.then(async ({ default: mermaid }) => {
|
||
mermaid.initialize({
|
||
startOnLoad: false,
|
||
// Hata olursa mermaid kendi "bomba" SVG'sini basmasın; biz toparlıyoruz
|
||
suppressErrorRendering: true,
|
||
look: "handDrawn",
|
||
handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik)
|
||
theme: "base",
|
||
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
|
||
themeVariables: {
|
||
primaryColor: "#ffffff",
|
||
primaryBorderColor: "#e2e8f0",
|
||
primaryTextColor: "#0f172a",
|
||
lineColor: "#94a3b8",
|
||
edgeLabelBackground: "#ffffff",
|
||
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
|
||
fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü
|
||
},
|
||
});
|
||
const sonuc = await mermaid.render(semaId, tanim);
|
||
if (!iptal) setSvg(sonuc.svg);
|
||
})
|
||
.catch(() => {
|
||
// mermaid.render atarsa geçici ölçüm div'ini body'de bırakabiliyor
|
||
document.getElementById(`d${semaId}`)?.remove();
|
||
if (!iptal) setHata(true);
|
||
});
|
||
}
|
||
|
||
return () => {
|
||
iptal = true;
|
||
io.disconnect();
|
||
};
|
||
}, [semaId, tanim]);
|
||
|
||
return (
|
||
<figure ref={figureRef} className="mt-6">
|
||
{svg ? (
|
||
<div
|
||
role="img"
|
||
aria-label={ariaLabel}
|
||
className={`${elYazisi.className} overflow-x-auto [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full`}
|
||
dangerouslySetInnerHTML={{ __html: svg }}
|
||
/>
|
||
) : hata ? null : (
|
||
<div
|
||
aria-hidden
|
||
className="h-96 animate-pulse rounded-xl bg-slate-100"
|
||
/>
|
||
)}
|
||
{figcaption ? (
|
||
<figcaption className="mt-3 text-xs text-slate-500">
|
||
{figcaption}
|
||
</figcaption>
|
||
) : null}
|
||
{/* Şemanın metin karşılığı sunucu HTML'inde de durur: JS çalıştırmayan
|
||
botlar ve şemayı çizemeyen tarayıcılar aynı içeriği okur. Kaynak
|
||
yine tek `ariaLabel` prop'u — ikinci bir metin tutulmaz. */}
|
||
<details
|
||
open={hata || undefined}
|
||
className="mt-2 text-xs text-slate-500"
|
||
>
|
||
<summary className="cursor-pointer font-medium text-slate-600">
|
||
Şemayı metin olarak oku
|
||
</summary>
|
||
<p className="mt-2 text-sm leading-6 text-slate-600">{ariaLabel}</p>
|
||
</details>
|
||
</figure>
|
||
);
|
||
}
|