diff --git a/src/components/ui/markdown-lazy.tsx b/src/components/ui/markdown-lazy.tsx new file mode 100644 index 0000000..672e96a --- /dev/null +++ b/src/components/ui/markdown-lazy.tsx @@ -0,0 +1,32 @@ +"use client"; + +// react-markdown + remark zinciri (~215 KB) yalnızca markdown gerçekten +// çizileceği zaman insin. next/dynamic yerine elle sarmalayıcı: `loading` +// children alamıyor, fallback'in AYNI class'lı düz metin olması gerekiyor ki +// stream sırasında iskelet/boşluk flash'ı olmasın (chunk gelince yerinde +// zenginleşir). + +import { useEffect, useReducer } from "react"; +import type { MarkdownProps } from "./markdown"; + +let mod: typeof import("./markdown") | null = null; +let soz: Promise | null = null; + +/** Chunk'ı önden ısıt (ör. sohbet mount olduğunda). */ +export function preloadMarkdown(): Promise { + return (soz ??= import("./markdown").then((m) => { + mod = m; + })); +} + +export function MarkdownLazy({ + fallback, + ...props +}: MarkdownProps & { fallback: React.ReactNode }) { + const [, tazele] = useReducer((c: number) => c + 1, 0); + useEffect(() => { + if (!mod) void preloadMarkdown().then(() => tazele()); + }, []); + const Markdown = mod?.Markdown; + return Markdown ? : <>{fallback}; +} diff --git a/src/components/ui/markdown.tsx b/src/components/ui/markdown.tsx index 6fda0f3..a00d424 100644 --- a/src/components/ui/markdown.tsx +++ b/src/components/ui/markdown.tsx @@ -1,5 +1,4 @@ import { cn } from "@/lib/utils" -import { marked } from "marked" import { memo, useId, useMemo } from "react" import ReactMarkdown, { Components } from "react-markdown" import remarkBreaks from "remark-breaks" @@ -13,9 +12,27 @@ export type MarkdownProps = { components?: Partial } +// Stream memoization'ı için bloklara bölme. Eskiden marked.lexer yapıyordu — +// react-markdown'ın micromark'ına ek ikinci bir tam parser'ı (~460 KB paket) +// client'a taşımak blok bölmek için gereksiz: kod çiti dışındaki boş satırlar +// zaten markdown'da blok sınırıdır. Çit içi boş satırlar korunur. function parseMarkdownIntoBlocks(markdown: string): string[] { - const tokens = marked.lexer(markdown) - return tokens.map((token) => token.raw) + const bloklar: string[] = [] + let mevcut: string[] = [] + let citte = false + for (const satir of markdown.split("\n")) { + if (/^\s*(```|~~~)/.test(satir)) citte = !citte + if (!citte && satir.trim() === "") { + if (mevcut.length) { + bloklar.push(mevcut.join("\n")) + mevcut = [] + } + } else { + mevcut.push(satir) + } + } + if (mevcut.length) bloklar.push(mevcut.join("\n")) + return bloklar.length ? bloklar : [markdown] } function extractLanguage(className?: string): string { diff --git a/src/components/ui/message.tsx b/src/components/ui/message.tsx index 3a11f55..b43cc32 100644 --- a/src/components/ui/message.tsx +++ b/src/components/ui/message.tsx @@ -6,7 +6,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import { Markdown } from "./markdown" +import { MarkdownLazy } from "./markdown-lazy" +import type { MarkdownProps } from "./markdown" export type MessageProps = { children: React.ReactNode @@ -48,7 +49,7 @@ export type MessageContentProps = { children: React.ReactNode markdown?: boolean className?: string -} & React.ComponentProps & +} & MarkdownProps & React.HTMLProps const MessageContent = ({ @@ -63,9 +64,15 @@ const MessageContent = ({ ) return markdown ? ( - + // Fallback aynı class'lı düz metin: chunk inene dek CLS/flash olmadan + // ham içerik görünür, sonra yerinde zenginleşir. + {children}} + {...props} + > {children as string} - + ) : (
{children} diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 536c42a..9508c1a 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -14,6 +14,7 @@ import { ChatContainerScrollAnchor, } from "@/components/ui/chat-container"; import { Message, MessageContent } from "@/components/ui/message"; +import { preloadMarkdown } from "@/components/ui/markdown-lazy"; import { PromptInput, PromptInputAction, @@ -90,6 +91,12 @@ export function SohbetClient({ const [kredi, setKredi] = useState(baslangicKredi); const router = useRouter(); + // Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı + // (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın. + useEffect(() => { + void preloadMarkdown(); + }, []); + useEffect(() => { // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; // popup gibi geç mount olan yerlerde eski davranış sürer. diff --git a/src/features/rehber/components/el-cizimi-sema.tsx b/src/features/rehber/components/el-cizimi-sema.tsx index 29c461f..ad0955f 100644 --- a/src/features/rehber/components/el-cizimi-sema.tsx +++ b/src/features/rehber/components/el-cizimi-sema.tsx @@ -6,7 +6,7 @@ // Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir; // sayfa paketine girmez. -import { useEffect, useId, useState } from "react"; +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 @@ -30,38 +30,58 @@ export function ElCizimiSema({ figcaption?: React.ReactNode; }) { const [svg, setSvg] = useState(null); + const figureRef = useRef(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; - import("mermaid").then(async ({ default: mermaid }) => { - mermaid.initialize({ - startOnLoad: false, - 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", + 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, + look: "handDrawn", + handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik) + theme: "base", fontFamily: `${elYazisi.style.fontFamily}, cursive`, - fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü - }, + 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); }); - const sonuc = await mermaid.render(semaId, tanim); - if (!iptal) setSvg(sonuc.svg); - }); + } + return () => { iptal = true; + io.disconnect(); }; }, [semaId, tanim]); return ( -
+
{svg ? (