use requestAnimationFrame for hover popup

This commit is contained in:
taDuc
2026-05-27 03:18:11 +07:00
parent 184abb25b4
commit 3d21d078cf
33 changed files with 2210 additions and 423 deletions
+22 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, memo } from "react";
import "react-quill-new/dist/quill.snow.css";
import type { Entity } from "@/uhm/api/entities";
@@ -22,6 +22,7 @@ type Props = {
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxDragWidth?: number;
compactHeader?: boolean;
};
function escapeHtml(input: string): string {
@@ -50,6 +51,7 @@ function slugifyHeading(raw: string): string {
if (!input.length) return "";
return input
.toLowerCase()
.replace(/đ/g, "d")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
@@ -122,7 +124,7 @@ function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
return { html: doc.body.innerHTML, toc };
}
export default function PublicWikiSidebar({
function PublicWikiSidebar({
entity,
wiki,
isLoading,
@@ -132,6 +134,7 @@ export default function PublicWikiSidebar({
sidebarWidth,
onSidebarWidthChange,
maxDragWidth,
compactHeader = false,
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null);
@@ -311,20 +314,22 @@ export default function PublicWikiSidebar({
>
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0, flex: 1 }}>
{compactHeader ? null : (
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
)}
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
<div
style={{
marginTop: 4,
marginTop: compactHeader ? 0 : 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
@@ -345,7 +350,7 @@ export default function PublicWikiSidebar({
{entity.description.trim()}
</div>
) : null}
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
{!compactHeader && wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
<div
style={{
marginTop: 6,
@@ -638,3 +643,5 @@ export default function PublicWikiSidebar({
</div>
);
}
export default memo(PublicWikiSidebar);
+130 -128
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, memo, type ComponentProps } from "react";
import dynamic from "next/dynamic";
import "react-quill-new/dist/quill.snow.css";
import { useShallow } from "zustand/react/shallow";
@@ -39,7 +39,7 @@ type QuillLinkFormat = {
__uhmOriginalSanitize?: unknown;
};
type QuillImageFormatCtor = {
new (): {
new(): {
domNode: Element;
format(name: string, value: string): void;
};
@@ -64,7 +64,7 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
const { wikis, requestedActiveId } = useEditorStore(
useShallow((state) => ({
wikis: state.snapshotWikis,
@@ -95,7 +95,7 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
activeWikiId: string | null;
existingHref: string | null;
} | null>(null);
const wikiLinkHandlerRef = useRef<(quill: QuillLike | null | undefined) => void>(() => {});
const wikiLinkHandlerRef = useRef<(quill: QuillLike | null | undefined) => void>(() => { });
const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false);
const [wikiLinkQuery, setWikiLinkQuery] = useState("");
const [wikiLinkError, setWikiLinkError] = useState<string | null>(null);
@@ -285,17 +285,17 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
setWikiSaveError(null);
setWikis((prev) =>
prev.map((w) =>
w.id !== activeId
? w
: {
...w,
source: w.source,
operation: w.operation === "create" ? "create" : "update",
title: nextTitle,
slug: nextSlug,
doc: payload,
}
prev.map((w) =>
w.id !== activeId
? w
: {
...w,
source: w.source,
operation: w.operation === "create" ? "create" : "update",
title: nextTitle,
slug: nextSlug,
doc: payload,
}
)
);
setOpen(false);
@@ -703,122 +703,122 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
)}
{collapsed ? null : (
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo wiki mới
</div>
<button
type="button"
onClick={() =>
setIsCreateOpen((v) => {
const next = !v;
if (next) {
setCreateError(null);
setIsCheckingCreateSlug(false);
setCreateSlugTouched(false);
}
return next;
})
}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
{isCreateOpen ? (
<>
<input
value={createTitle}
onChange={(e) => {
const nextTitle = e.target.value;
setCreateTitle(nextTitle);
setCreateError(null);
if (!createSlugTouched) {
setCreateSlug(slugifyWikiTitle(nextTitle));
}
}}
placeholder="Tieu de wiki"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<input
value={createSlug}
onChange={(e) => {
setCreateSlugTouched(true);
setCreateSlug(e.target.value);
setCreateError(null);
}}
placeholder="Slug"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo wiki mới
</div>
<button
type="button"
onClick={handleCreateWikiFromPanel}
disabled={isCheckingCreateSlug}
onClick={() =>
setIsCreateOpen((v) => {
const next = !v;
if (next) {
setCreateError(null);
setIsCheckingCreateSlug(false);
setCreateSlugTouched(false);
}
return next;
})
}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 600,
opacity: isCheckingCreateSlug ? 0.7 : 1,
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
Tạo wiki mới
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
{createError ? (
<div style={{ color: "#fca5a5", fontSize: 12 }}>
{createError}
</div>
) : null}
</>
) : null}
</div>
</div>
{isCreateOpen ? (
<>
<input
value={createTitle}
onChange={(e) => {
const nextTitle = e.target.value;
setCreateTitle(nextTitle);
setCreateError(null);
if (!createSlugTouched) {
setCreateSlug(slugifyWikiTitle(nextTitle));
}
}}
placeholder="Tieu de wiki"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<input
value={createSlug}
onChange={(e) => {
setCreateSlugTouched(true);
setCreateSlug(e.target.value);
setCreateError(null);
}}
placeholder="Slug"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<button
type="button"
onClick={handleCreateWikiFromPanel}
disabled={isCheckingCreateSlug}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 600,
opacity: isCheckingCreateSlug ? 0.7 : 1,
}}
>
Tạo wiki mới
</button>
{createError ? (
<div style={{ color: "#fca5a5", fontSize: 12 }}>
{createError}
</div>
) : null}
</>
) : null}
</div>
)}
<Modal
@@ -1001,11 +1001,10 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
</div>
</div>
<span
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${
w.source === "local"
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${w.source === "local"
? "border-emerald-300/60 text-emerald-600 dark:text-emerald-300"
: "border-blue-300/60 text-blue-600 dark:text-blue-300"
}`}
}`}
>
{w.source}
</span>
@@ -1110,6 +1109,7 @@ function slugifyWikiTitle(raw: string): string {
if (!input.length) return "";
return input
.toLowerCase()
.replace(/đ/g, "d")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
@@ -1149,3 +1149,5 @@ function downloadTextFile(filename: string, contents: string, mime: string): voi
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export default memo(WikiSidebarPanel);