feat: integrate rich text editor with wiki linking functionality in ReplayEffectsSidebar
This commit is contained in:
@@ -947,7 +947,7 @@ function EditorPageContent() {
|
|||||||
.filter((wiki) => wiki && wiki.operation !== "delete")
|
.filter((wiki) => wiki && wiki.operation !== "delete")
|
||||||
.map((wiki) => ({
|
.map((wiki) => ({
|
||||||
id: String(wiki.id || ""),
|
id: String(wiki.id || ""),
|
||||||
label: `${(wiki.title || "").trim() || "Untitled wiki"} (${String(wiki.id || "")})`,
|
label: (wiki.title || "").trim() || "Untitled wiki",
|
||||||
}))
|
}))
|
||||||
.filter((wiki) => wiki.id.length > 0);
|
.filter((wiki) => wiki.id.length > 0);
|
||||||
}, [snapshotWikis]);
|
}, [snapshotWikis]);
|
||||||
|
|||||||
+16
-10
@@ -313,7 +313,8 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "650px",
|
width: "fit-content",
|
||||||
|
maxWidth: "95%",
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -340,6 +341,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
padding: "0 6px",
|
padding: "0 6px",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -391,7 +393,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{onViewModeChange ? (
|
{onViewModeChange ? (
|
||||||
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px" }}>
|
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px", flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onViewModeChange("local")}
|
onClick={() => onViewModeChange("local")}
|
||||||
@@ -428,7 +430,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{onEnterPreview || onExitPreview ? (
|
{onEnterPreview || onExitPreview ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -440,6 +442,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
padding: "0 12px",
|
padding: "0 12px",
|
||||||
background: isPreviewMode ? "#334155" : "#166534",
|
background: isPreviewMode ? "#334155" : "#166534",
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
|
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
|
||||||
title={isPreviewMode ? "Thoat preview" : "Xem nhu nguoi dung"}
|
title={isPreviewMode ? "Thoat preview" : "Xem nhu nguoi dung"}
|
||||||
@@ -447,7 +450,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
{isPreviewMode ? "Editor" : "Preview"}
|
{isPreviewMode ? "Editor" : "Preview"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{onPlayPreviewReplay ? (
|
{onPlayPreviewReplay ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -464,6 +467,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
background: "#2563eb",
|
background: "#2563eb",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
aria-label="Play selected replay"
|
aria-label="Play selected replay"
|
||||||
title="Play replay của geometry đang chọn"
|
title="Play replay của geometry đang chọn"
|
||||||
@@ -481,16 +485,16 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
Play
|
Play
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleZoomByStep(-0.8)}
|
onClick={() => handleZoomByStep(-0.8)}
|
||||||
style={zoomButtonStyle}
|
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
||||||
aria-label="Zoom out"
|
aria-label="Zoom out"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={zoomBounds.min}
|
min={zoomBounds.min}
|
||||||
@@ -520,21 +524,22 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: "60px",
|
||||||
accentColor: "#38bdf8",
|
accentColor: "#38bdf8",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
aria-label="Map zoom"
|
aria-label="Map zoom"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleZoomByStep(0.8)}
|
onClick={() => handleZoomByStep(0.8)}
|
||||||
style={zoomButtonStyle}
|
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minWidth: "56px",
|
minWidth: "56px",
|
||||||
@@ -542,6 +547,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
color: "#cbd5e1",
|
color: "#cbd5e1",
|
||||||
fontVariantNumeric: "tabular-nums",
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{zoomLevel.toFixed(1)}x
|
{zoomLevel.toFixed(1)}x
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
|
|
||||||
type PanelProps = {
|
type PanelProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,9 +13,12 @@ export function Panel({
|
|||||||
defaultOpen,
|
defaultOpen,
|
||||||
children,
|
children,
|
||||||
}: PanelProps) {
|
}: PanelProps) {
|
||||||
|
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details
|
<details
|
||||||
open={Boolean(defaultOpen)}
|
open={open}
|
||||||
|
onToggle={(e) => setOpen(e.currentTarget.open)}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
padding: 10,
|
padding: 10,
|
||||||
@@ -39,23 +42,33 @@ export function Panel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
{badge ? (
|
<span
|
||||||
<span
|
style={{
|
||||||
style={{
|
width: 22,
|
||||||
padding: "2px 8px",
|
height: 22,
|
||||||
borderRadius: 999,
|
borderRadius: 6,
|
||||||
border: "1px solid #334155",
|
border: "1px solid #334155",
|
||||||
background: "#0b1220",
|
background: "#0b1220",
|
||||||
color: "#cbd5e1",
|
color: "#cbd5e1",
|
||||||
fontSize: 12,
|
display: "inline-flex",
|
||||||
fontWeight: 850,
|
alignItems: "center",
|
||||||
flex: "0 0 auto",
|
justifyContent: "center",
|
||||||
}}
|
fontSize: 14,
|
||||||
>
|
fontWeight: 700,
|
||||||
{badge}
|
flex: "0 0 auto",
|
||||||
</span>
|
}}
|
||||||
) : null}
|
>
|
||||||
|
{open ? "−" : "+"}
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
<style>{`
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
list-style: none !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<div style={{ marginTop: 10 }}>{children}</div>
|
<div style={{ marginTop: 10 }}>{children}</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
import type {
|
import type {
|
||||||
BattleReplay,
|
BattleReplay,
|
||||||
GeoFunctionName,
|
GeoFunctionName,
|
||||||
@@ -11,6 +13,16 @@ import type {
|
|||||||
UIOptionName,
|
UIOptionName,
|
||||||
} from "@/uhm/types/projects";
|
} from "@/uhm/types/projects";
|
||||||
import { Panel } from "./Panel";
|
import { Panel } from "./Panel";
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
import Label from "@/components/form/Label";
|
||||||
|
import { fetchWikiBySlug, searchWikisByTitle } from "@/uhm/api/wikis";
|
||||||
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
|
const ReactQuillEditor = dynamic<any>(() => import("react-quill-new"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div style={{ height: "120px", background: "#0b1220", borderRadius: "8px" }} className="animate-pulse" />,
|
||||||
|
});
|
||||||
|
|
||||||
type Choice = {
|
type Choice = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +53,7 @@ type ActionFieldConfig = {
|
|||||||
kind:
|
kind:
|
||||||
| "text"
|
| "text"
|
||||||
| "textarea"
|
| "textarea"
|
||||||
|
| "rich-text"
|
||||||
| "number"
|
| "number"
|
||||||
| "boolean"
|
| "boolean"
|
||||||
| "color"
|
| "color"
|
||||||
@@ -141,13 +154,11 @@ const buttonStyle = {
|
|||||||
|
|
||||||
const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
||||||
set_dialog: {
|
set_dialog: {
|
||||||
label: "Dialog box",
|
label: "Narrative Box",
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "clear", label: "Ẩn dialog (Clear)", kind: "boolean" },
|
{ name: "clear", label: "Ẩn narrative (Clear)", kind: "boolean" },
|
||||||
{ name: "avatar", label: "Avatar URL", kind: "text", placeholder: "https://... (avatar)" },
|
{ name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (URL ảnh)" },
|
||||||
{ name: "text", label: "Nội dung", kind: "textarea", placeholder: "Lời thoại / Dẫn chuyện" },
|
{ name: "text", label: "Nội dung", kind: "rich-text", placeholder: "Nội dung dẫn chuyện..." },
|
||||||
{ name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (ảnh đè)" },
|
|
||||||
{ name: "image_caption", label: "Chú thích ảnh", kind: "text", placeholder: "Chú thích ảnh" },
|
|
||||||
],
|
],
|
||||||
create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }),
|
create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }),
|
||||||
deserialize: (params) => {
|
deserialize: (params) => {
|
||||||
@@ -155,18 +166,14 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
|||||||
if (data === null) {
|
if (data === null) {
|
||||||
return {
|
return {
|
||||||
clear: true,
|
clear: true,
|
||||||
avatar: "",
|
|
||||||
text: "",
|
|
||||||
image_url: "",
|
image_url: "",
|
||||||
image_caption: "",
|
text: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
clear: false,
|
clear: false,
|
||||||
avatar: asString(data?.avatar),
|
|
||||||
text: asString(data?.text),
|
|
||||||
image_url: asString(data?.image_url),
|
image_url: asString(data?.image_url),
|
||||||
image_caption: asString(data?.image_caption),
|
text: asString(data?.text),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
serialize: (values) => {
|
serialize: (values) => {
|
||||||
@@ -174,15 +181,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
|||||||
return [null];
|
return [null];
|
||||||
}
|
}
|
||||||
const data: any = {
|
const data: any = {
|
||||||
avatar: asString(values.avatar),
|
avatar: "",
|
||||||
text: asString(values.text),
|
text: asString(values.text),
|
||||||
|
image_caption: "",
|
||||||
};
|
};
|
||||||
if (values.image_url) {
|
if (values.image_url) {
|
||||||
data.image_url = asString(values.image_url);
|
data.image_url = asString(values.image_url);
|
||||||
}
|
}
|
||||||
if (values.image_caption) {
|
|
||||||
data.image_caption = asString(values.image_caption);
|
|
||||||
}
|
|
||||||
return [data];
|
return [data];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -200,6 +205,236 @@ export default function ReplayEffectsSidebar({
|
|||||||
getCurrentMapViewState,
|
getCurrentMapViewState,
|
||||||
onMutateReplay,
|
onMutateReplay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const wikis = useEditorStore((state) => state.snapshotWikis);
|
||||||
|
|
||||||
|
// Quill: custom link UI (link-to-wiki by slug).
|
||||||
|
const wikiLinkIntentRef = useRef<{
|
||||||
|
quill: any;
|
||||||
|
range: any;
|
||||||
|
existingHref: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false);
|
||||||
|
const [wikiLinkQuery, setWikiLinkQuery] = useState("");
|
||||||
|
const [wikiLinkSearchMode, setWikiLinkSearchMode] = useState<"title" | "slug">("title");
|
||||||
|
const [wikiLinkError, setWikiLinkError] = useState<string | null>(null);
|
||||||
|
const [globalWikiResults, setGlobalWikiResults] = useState<any[]>([]);
|
||||||
|
const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false);
|
||||||
|
const [globalWikiSearchError, setGlobalWikiSearchError] = useState<string | null>(null);
|
||||||
|
const globalWikiSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const handleLinkClick = useCallback((quill: any) => {
|
||||||
|
if (!quill) return;
|
||||||
|
const range = quill.getSelection?.() ?? null;
|
||||||
|
const existingHref =
|
||||||
|
range && (quill.getFormat?.(range)?.link ?? quill.getFormat?.(range.index, range.length)?.link) || null;
|
||||||
|
|
||||||
|
wikiLinkIntentRef.current = {
|
||||||
|
quill,
|
||||||
|
range,
|
||||||
|
existingHref: typeof existingHref === "string" ? existingHref : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedText =
|
||||||
|
range && range.length > 0 ? String(quill.getText?.(range.index, range.length) || "").trim() : "";
|
||||||
|
setWikiLinkQuery(selectedText.slice(0, 80));
|
||||||
|
setWikiLinkError(null);
|
||||||
|
setIsWikiLinkOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const localWikiLinkCandidates = useMemo(() => {
|
||||||
|
if (!isWikiLinkOpen) return [];
|
||||||
|
const q = wikiLinkQuery.trim().toLowerCase();
|
||||||
|
|
||||||
|
const base = (wikis || [])
|
||||||
|
.filter((w) => w && typeof w.id === "string" && w.operation !== "delete")
|
||||||
|
.filter((w) => typeof w.slug === "string" && w.slug.trim().length > 0);
|
||||||
|
|
||||||
|
const filtered = (() => {
|
||||||
|
if (!q.length) return base;
|
||||||
|
if (wikiLinkSearchMode === "slug") {
|
||||||
|
return base.filter((w) => String(w.slug || "").toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
return base.filter((w) => (w.title || "").toLowerCase().includes(q));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return filtered.slice(0, 20).map((w) => ({
|
||||||
|
key: `local:${w.id}`,
|
||||||
|
title: (w.title || "").trim() || "Untitled wiki",
|
||||||
|
slug: String(w.slug).trim(),
|
||||||
|
source: "local" as const,
|
||||||
|
}));
|
||||||
|
}, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode, wikis]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWikiLinkOpen) return;
|
||||||
|
|
||||||
|
const keyword = wikiLinkQuery.trim();
|
||||||
|
if (!keyword.length) {
|
||||||
|
setGlobalWikiResults([]);
|
||||||
|
setIsGlobalWikiSearching(false);
|
||||||
|
setGlobalWikiSearchError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
const requestId = ++globalWikiSearchRequestRef.current;
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
setIsGlobalWikiSearching(true);
|
||||||
|
setGlobalWikiSearchError(null);
|
||||||
|
try {
|
||||||
|
const rows =
|
||||||
|
wikiLinkSearchMode === "slug"
|
||||||
|
? (() => fetchWikiBySlug(keyword))()
|
||||||
|
: (() => searchWikisByTitle(keyword, { limit: 12 }))();
|
||||||
|
|
||||||
|
const resolved = await rows;
|
||||||
|
if (disposed || requestId !== globalWikiSearchRequestRef.current) return;
|
||||||
|
|
||||||
|
const list = Array.isArray(resolved) ? resolved : resolved ? [resolved] : [];
|
||||||
|
setGlobalWikiResults(list);
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed || requestId !== globalWikiSearchRequestRef.current) return;
|
||||||
|
console.error("Search global wikis failed", err);
|
||||||
|
setGlobalWikiResults([]);
|
||||||
|
setGlobalWikiSearchError("Không search được wiki trên server.");
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === globalWikiSearchRequestRef.current) {
|
||||||
|
setIsGlobalWikiSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 260);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode]);
|
||||||
|
|
||||||
|
const globalWikiLinkCandidates = useMemo(() => {
|
||||||
|
if (!isWikiLinkOpen) return [];
|
||||||
|
const out: any[] = [];
|
||||||
|
for (const row of globalWikiResults || []) {
|
||||||
|
const slug = typeof row?.slug === "string" ? row.slug.trim() : "";
|
||||||
|
if (!slug.length) continue;
|
||||||
|
out.push({
|
||||||
|
key: `global:${row.id || slug}`,
|
||||||
|
title: (row.title || "").trim() || "Untitled wiki",
|
||||||
|
slug,
|
||||||
|
source: "global",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.slice(0, 20);
|
||||||
|
}, [globalWikiResults, isWikiLinkOpen]);
|
||||||
|
|
||||||
|
const wikiLinkCandidates = useMemo(() => {
|
||||||
|
const localSlugs = new Set(localWikiLinkCandidates.map((w) => w.slug));
|
||||||
|
const dedupedGlobal = globalWikiLinkCandidates.filter((w) => !localSlugs.has(w.slug));
|
||||||
|
return [...localWikiLinkCandidates, ...dedupedGlobal];
|
||||||
|
}, [globalWikiLinkCandidates, localWikiLinkCandidates]);
|
||||||
|
|
||||||
|
const closeWikiLinkModal = useCallback(() => {
|
||||||
|
setIsWikiLinkOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyWikiLink = useCallback((target: { title: string; slug: string }) => {
|
||||||
|
const intent = wikiLinkIntentRef.current;
|
||||||
|
const quill = intent?.quill;
|
||||||
|
if (!quill) return;
|
||||||
|
|
||||||
|
const slug = target.slug.trim();
|
||||||
|
const range = intent?.range ?? quill.getSelection?.() ?? null;
|
||||||
|
if (!range) {
|
||||||
|
setWikiLinkError("Không lấy được vị trí selection trong editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
quill.setSelection?.(range.index, range.length, "silent");
|
||||||
|
|
||||||
|
if (range.length > 0) {
|
||||||
|
quill.formatText?.(range.index, range.length, "link", slug, "user");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (target.title || "").trim() || slug;
|
||||||
|
quill.insertText?.(range.index, label, { link: slug }, "user");
|
||||||
|
quill.setSelection?.(range.index + label.length, 0, "silent");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
}, [closeWikiLinkModal]);
|
||||||
|
|
||||||
|
const applyMissingWikiLink = useCallback(() => {
|
||||||
|
const intent = wikiLinkIntentRef.current;
|
||||||
|
const quill = intent?.quill;
|
||||||
|
if (!quill) return;
|
||||||
|
|
||||||
|
const href = "__missing__";
|
||||||
|
const range = intent?.range ?? quill.getSelection?.() ?? null;
|
||||||
|
if (!range) {
|
||||||
|
setWikiLinkError("Không lấy được vị trí selection trong editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
quill.setSelection?.(range.index, range.length, "silent");
|
||||||
|
|
||||||
|
if (range.length > 0) {
|
||||||
|
quill.formatText?.(range.index, range.length, "link", href, "user");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = wikiLinkQuery.trim().slice(0, 120) || "link";
|
||||||
|
quill.insertText?.(range.index, label, { link: href }, "user");
|
||||||
|
quill.setSelection?.(range.index + label.length, 0, "silent");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
}, [closeWikiLinkModal, wikiLinkQuery]);
|
||||||
|
|
||||||
|
const removeWikiLink = useCallback(() => {
|
||||||
|
const intent = wikiLinkIntentRef.current;
|
||||||
|
const quill = intent?.quill;
|
||||||
|
if (!quill) return;
|
||||||
|
const range = intent?.range ?? quill.getSelection?.() ?? null;
|
||||||
|
if (!range) return;
|
||||||
|
quill.setSelection?.(range.index, range.length, "silent");
|
||||||
|
if (range.length > 0) {
|
||||||
|
quill.formatText?.(range.index, range.length, "link", false, "user");
|
||||||
|
} else {
|
||||||
|
quill.format?.("link", false, "user");
|
||||||
|
}
|
||||||
|
closeWikiLinkModal();
|
||||||
|
}, [closeWikiLinkModal]);
|
||||||
|
|
||||||
|
const isUrlQuery = useMemo(() => {
|
||||||
|
const q = wikiLinkQuery.trim();
|
||||||
|
return q.startsWith("http://") || q.startsWith("https://") || q.includes("/");
|
||||||
|
}, [wikiLinkQuery]);
|
||||||
|
|
||||||
|
const applyExternalLink = useCallback(() => {
|
||||||
|
const intent = wikiLinkIntentRef.current;
|
||||||
|
const quill = intent?.quill;
|
||||||
|
if (!quill) return;
|
||||||
|
|
||||||
|
const href = wikiLinkQuery.trim();
|
||||||
|
const range = intent?.range ?? quill.getSelection?.() ?? null;
|
||||||
|
if (!range) {
|
||||||
|
setWikiLinkError("Không lấy được vị trí selection trong editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
quill.setSelection?.(range.index, range.length, "silent");
|
||||||
|
|
||||||
|
if (range.length > 0) {
|
||||||
|
quill.formatText?.(range.index, range.length, "link", href, "user");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = href;
|
||||||
|
quill.insertText?.(range.index, label, { link: href }, "user");
|
||||||
|
quill.setSelection?.(range.index + label.length, 0, "silent");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
}, [closeWikiLinkModal, wikiLinkQuery]);
|
||||||
|
|
||||||
const stages = useMemo(() => replay?.detail || [], [replay?.detail]);
|
const stages = useMemo(() => replay?.detail || [], [replay?.detail]);
|
||||||
const selectedStage =
|
const selectedStage =
|
||||||
stages.find((stage) => stage.id === selectedStageId) ||
|
stages.find((stage) => stage.id === selectedStageId) ||
|
||||||
@@ -303,6 +538,7 @@ export default function ReplayEffectsSidebar({
|
|||||||
onUpdateActions={(nextActions, label) =>
|
onUpdateActions={(nextActions, label) =>
|
||||||
updateActionGroup("use_narrow_function", nextActions, label)
|
updateActionGroup("use_narrow_function", nextActions, label)
|
||||||
}
|
}
|
||||||
|
onLinkClick={handleLinkClick}
|
||||||
/>
|
/>
|
||||||
<MapFunctionShortcutPanel
|
<MapFunctionShortcutPanel
|
||||||
currentTimelineYear={currentTimelineYear}
|
currentTimelineYear={currentTimelineYear}
|
||||||
@@ -341,6 +577,176 @@ export default function ReplayEffectsSidebar({
|
|||||||
Chọn một step ở panel trái để chỉnh hiệu ứng.
|
Chọn một step ở panel trái để chỉnh hiệu ứng.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Modal
|
||||||
|
isOpen={isWikiLinkOpen}
|
||||||
|
onClose={closeWikiLinkModal}
|
||||||
|
className="max-w-[620px] p-6 !bg-[#0f172a] border border-[#1e293b] text-slate-100 rounded-xl dark"
|
||||||
|
>
|
||||||
|
<div style={{ display: "grid", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: "#ffffff" }}>Chèn Link Wiki</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: 8 }}>
|
||||||
|
<Label className="!text-slate-300">Tìm kiếm wiki hoặc nhập URL</Label>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||||
|
<input
|
||||||
|
value={wikiLinkQuery}
|
||||||
|
onChange={(e) => setWikiLinkQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
height: 44,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingRight: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#f1f5f9",
|
||||||
|
outline: "none"
|
||||||
|
}}
|
||||||
|
placeholder={wikiLinkSearchMode === "slug" ? "Nhập slug..." : "Nhập tiêu đề hoặc URL..."}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={wikiLinkSearchMode}
|
||||||
|
onChange={(e) => setWikiLinkSearchMode(e.target.value === "slug" ? "slug" : "title")}
|
||||||
|
style={{
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingRight: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#f1f5f9",
|
||||||
|
outline: "none"
|
||||||
|
}}
|
||||||
|
aria-label="Search mode"
|
||||||
|
>
|
||||||
|
<option value="title">Tiêu đề</option>
|
||||||
|
<option value="slug">Slug</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{wikiLinkError ? (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: "#f87171" }}>{wikiLinkError}</div>
|
||||||
|
) : null}
|
||||||
|
{globalWikiSearchError ? (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: "#f87171" }}>{globalWikiSearchError}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 280, overflowY: "auto", borderRadius: 12, border: "1px solid #1e293b", backgroundColor: "#0b1220" }}>
|
||||||
|
<div style={{ padding: 8, display: "grid", gap: 4 }}>
|
||||||
|
{isGlobalWikiSearching ? (
|
||||||
|
<div style={{ paddingLeft: 12, paddingRight: 12, paddingTop: 8, paddingBottom: 8, fontSize: 12, color: "#94a3b8" }}>
|
||||||
|
Đang tìm kiếm…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{wikiLinkCandidates.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyWikiLink(w)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid transparent",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingRight: 12,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
transition: "all 0.2s",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#f1f5f9"
|
||||||
|
}}
|
||||||
|
className="hover-link-item"
|
||||||
|
title={w.slug || undefined}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: "#f1f5f9", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{(w.title || "").trim() || "Untitled wiki"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#94a3b8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{String(w.slug)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
paddingLeft: 8,
|
||||||
|
paddingRight: 8,
|
||||||
|
paddingTop: 2,
|
||||||
|
paddingBottom: 2,
|
||||||
|
borderRadius: 9999,
|
||||||
|
border: w.source === "local" ? "1px solid rgba(16, 185, 129, 0.3)" : "1px solid rgba(59, 130, 246, 0.3)",
|
||||||
|
color: w.source === "local" ? "#34d399" : "#60a5fa"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{wikiLinkCandidates.length === 0 ? (
|
||||||
|
<div style={{ paddingLeft: 12, paddingRight: 12, paddingTop: 16, paddingBottom: 16, fontSize: 14, color: "#94a3b8" }}>
|
||||||
|
Không tìm thấy wiki phù hợp (hoặc các wiki khác chưa có slug).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
{isUrlQuery ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={applyExternalLink}
|
||||||
|
className="!bg-[#0284c7] hover:!bg-[#0369a1] !text-white !ring-0 !border-0"
|
||||||
|
>
|
||||||
|
Chèn Link ngoài
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={applyMissingWikiLink}
|
||||||
|
className="!bg-[#334155] hover:!bg-[#475569] !text-slate-100 !ring-0 !border-0"
|
||||||
|
>
|
||||||
|
Link trống
|
||||||
|
</Button>
|
||||||
|
{wikiLinkIntentRef.current?.existingHref ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={removeWikiLink}
|
||||||
|
className="!bg-[#b91c1c] hover:!bg-[#991b1b] !text-white !ring-0 !border-0"
|
||||||
|
>
|
||||||
|
Xóa Link
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={closeWikiLinkModal}
|
||||||
|
className="!bg-[#1e293b] hover:!bg-[#334155] !text-slate-300 !ring-0 !border-0"
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<style>{`
|
||||||
|
.hover-link-item:hover {
|
||||||
|
border-color: #334155 !important;
|
||||||
|
background-color: rgba(30, 41, 59, 0.5) !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -598,106 +1004,6 @@ function MapCameraViewPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UiSimpleEffectsPanel({
|
|
||||||
draft,
|
|
||||||
onToggleOption,
|
|
||||||
onApply,
|
|
||||||
}: {
|
|
||||||
draft: UiEffectsDraftState;
|
|
||||||
onToggleOption: (option: UIOptionName) => void;
|
|
||||||
onApply: () => void;
|
|
||||||
}) {
|
|
||||||
const activeCount = uiSimpleOptionValues.filter((option) => draft.selected[option]).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel title="UI Effects" badge={`${activeCount}`} defaultOpen>
|
|
||||||
<div style={{ display: "grid", gap: 12 }}>
|
|
||||||
<UiOptionToggleRow
|
|
||||||
optionValues={uiSimpleOptionValues}
|
|
||||||
draft={draft}
|
|
||||||
onToggleOption={onToggleOption}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onApply}
|
|
||||||
style={{
|
|
||||||
...buttonStyle,
|
|
||||||
background: "#0f766e",
|
|
||||||
border: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UiInputEffectsPanel({
|
|
||||||
draft,
|
|
||||||
wikiChoices,
|
|
||||||
onToggleOption,
|
|
||||||
onChangeDraft,
|
|
||||||
onApply,
|
|
||||||
}: {
|
|
||||||
draft: UiEffectsDraftState;
|
|
||||||
wikiChoices: Choice[];
|
|
||||||
onToggleOption: (option: UIOptionName) => void;
|
|
||||||
onChangeDraft: (patch: Partial<UiEffectsDraftState>) => void;
|
|
||||||
onApply: () => void;
|
|
||||||
}) {
|
|
||||||
const activeCount = uiInputOptionValues.filter((option) => draft.selected[option]).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel title="UI Input Effects" badge={`${activeCount}`} defaultOpen>
|
|
||||||
<div style={{ display: "grid", gap: 12 }}>
|
|
||||||
<UiOptionToggleRow
|
|
||||||
optionValues={uiInputOptionValues}
|
|
||||||
draft={draft}
|
|
||||||
onToggleOption={onToggleOption}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{draft.selected.wiki ? (
|
|
||||||
<FieldInput
|
|
||||||
field={{ name: "wiki_id", label: "Wiki", kind: "wiki" }}
|
|
||||||
value={draft.wiki_id}
|
|
||||||
geometryChoices={[]}
|
|
||||||
wikiChoices={wikiChoices}
|
|
||||||
onChange={(nextValue) => onChangeDraft({ wiki_id: asString(nextValue) })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{draft.selected.toast ? (
|
|
||||||
<FieldInput
|
|
||||||
field={{
|
|
||||||
name: "message",
|
|
||||||
label: "Message",
|
|
||||||
kind: "textarea",
|
|
||||||
placeholder: "Nội dung thông báo",
|
|
||||||
}}
|
|
||||||
value={draft.message}
|
|
||||||
geometryChoices={[]}
|
|
||||||
wikiChoices={wikiChoices}
|
|
||||||
onChange={(nextValue) => onChangeDraft({ message: asString(nextValue) })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onApply}
|
|
||||||
style={{
|
|
||||||
...buttonStyle,
|
|
||||||
background: "#0f766e",
|
|
||||||
border: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UiOptionToggleRow({
|
function UiOptionToggleRow({
|
||||||
optionValues,
|
optionValues,
|
||||||
draft,
|
draft,
|
||||||
@@ -719,8 +1025,6 @@ function UiOptionToggleRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// UiVisibilityOptions removed since toggles are evaluated directly
|
|
||||||
|
|
||||||
function SimpleOptionToggleRow<T extends string>({
|
function SimpleOptionToggleRow<T extends string>({
|
||||||
options,
|
options,
|
||||||
onToggleOption,
|
onToggleOption,
|
||||||
@@ -776,52 +1080,66 @@ function UiEffectsEditor({
|
|||||||
setDraft(buildUiEffectsDraftState(actions));
|
setDraft(buildUiEffectsDraftState(actions));
|
||||||
}, [actions]);
|
}, [actions]);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const evaluatedOptions: UIOptionName[] = ["timeline", "layer_panel", "zoom_panel", "wiki"];
|
||||||
|
const updatedDraft = {
|
||||||
|
...draft,
|
||||||
|
selected: {
|
||||||
|
...draft.selected,
|
||||||
|
wiki: Boolean(draft.wiki_id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextActions = replaceUiActionsByGroup(actions, evaluatedOptions, updatedDraft);
|
||||||
|
const label = buildUiEffectsApplyLabel("UI Effects", updatedDraft, evaluatedOptions);
|
||||||
|
onApplyActions(nextActions, label);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Panel title="UI Effects" defaultOpen>
|
||||||
<UiSimpleEffectsPanel
|
<div style={{ display: "grid", gap: 12 }}>
|
||||||
draft={draft}
|
<UiOptionToggleRow
|
||||||
onToggleOption={(option) =>
|
optionValues={uiSimpleOptionValues}
|
||||||
setDraft((prev) => ({
|
draft={draft}
|
||||||
...prev,
|
onToggleOption={(option) =>
|
||||||
selected: {
|
setDraft((prev) => ({
|
||||||
...prev.selected,
|
...prev,
|
||||||
[option]: !prev.selected[option],
|
selected: {
|
||||||
},
|
...prev.selected,
|
||||||
}))
|
[option]: !prev.selected[option],
|
||||||
}
|
},
|
||||||
onApply={() =>
|
}))
|
||||||
onApplyActions(
|
}
|
||||||
replaceUiActionsByGroup(actions, uiSimpleOptionValues, draft),
|
/>
|
||||||
buildUiEffectsApplyLabel("UI Effects", draft, uiSimpleOptionValues)
|
|
||||||
)
|
<div style={{ borderTop: "1px solid #1f2937", margin: "8px 0" }} />
|
||||||
}
|
|
||||||
/>
|
<FieldInput
|
||||||
<UiInputEffectsPanel
|
field={{ name: "wiki_id", label: "Mở Wiki", kind: "wiki" }}
|
||||||
draft={draft}
|
value={draft.wiki_id}
|
||||||
wikiChoices={wikiChoices}
|
geometryChoices={[]}
|
||||||
onToggleOption={(option) =>
|
wikiChoices={wikiChoices}
|
||||||
setDraft((prev) => ({
|
onChange={(nextValue) =>
|
||||||
...prev,
|
setDraft((prev) => ({
|
||||||
selected: {
|
...prev,
|
||||||
...prev.selected,
|
wiki_id: asString(nextValue),
|
||||||
[option]: !prev.selected[option],
|
}))
|
||||||
},
|
}
|
||||||
}))
|
/>
|
||||||
}
|
|
||||||
onChangeDraft={(patch) =>
|
<button
|
||||||
setDraft((prev) => ({
|
type="button"
|
||||||
...prev,
|
onClick={handleApply}
|
||||||
...patch,
|
style={{
|
||||||
}))
|
...buttonStyle,
|
||||||
}
|
background: "#0f766e",
|
||||||
onApply={() =>
|
border: "none",
|
||||||
onApplyActions(
|
}}
|
||||||
replaceUiActionsByGroup(actions, uiInputOptionValues, draft),
|
>
|
||||||
buildUiEffectsApplyLabel("UI Inputs", draft, uiInputOptionValues)
|
Apply
|
||||||
)
|
</button>
|
||||||
}
|
</div>
|
||||||
/>
|
</Panel>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,6 +1153,7 @@ function ActionGroupEditor<T extends string>({
|
|||||||
createOnSelect = false,
|
createOnSelect = false,
|
||||||
emptyOptionLabel,
|
emptyOptionLabel,
|
||||||
onUpdateActions,
|
onUpdateActions,
|
||||||
|
onLinkClick,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
groupLabel: string;
|
groupLabel: string;
|
||||||
@@ -845,6 +1164,7 @@ function ActionGroupEditor<T extends string>({
|
|||||||
createOnSelect?: boolean;
|
createOnSelect?: boolean;
|
||||||
emptyOptionLabel?: string;
|
emptyOptionLabel?: string;
|
||||||
onUpdateActions: (nextActions: ReplayAction<T>[], label: string) => void;
|
onUpdateActions: (nextActions: ReplayAction<T>[], label: string) => void;
|
||||||
|
onLinkClick?: (quill: any) => void;
|
||||||
}) {
|
}) {
|
||||||
const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
|
const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
|
||||||
const [composerFunctionName, setComposerFunctionName] = useState<T | "">(
|
const [composerFunctionName, setComposerFunctionName] = useState<T | "">(
|
||||||
@@ -857,6 +1177,28 @@ function ActionGroupEditor<T extends string>({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lastLoadedActionsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (JSON.stringify(actions) === JSON.stringify(lastLoadedActionsRef.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastLoadedActionsRef.current = actions;
|
||||||
|
|
||||||
|
if (actions.length > 0) {
|
||||||
|
const first = actions[0];
|
||||||
|
setComposerFunctionName(first.function_name);
|
||||||
|
const def = definitions[first.function_name];
|
||||||
|
if (def) {
|
||||||
|
setComposerDraftValues(def.deserialize(first.params));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const defaultFun = createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T);
|
||||||
|
setComposerFunctionName(defaultFun);
|
||||||
|
setComposerDraftValues(buildActionComposerDraft(definitions, defaultFun));
|
||||||
|
}
|
||||||
|
}, [actions, definitions, createOnSelect, functionNames]);
|
||||||
|
|
||||||
const composerDefinition = composerFunctionName
|
const composerDefinition = composerFunctionName
|
||||||
? definitions[composerFunctionName]
|
? definitions[composerFunctionName]
|
||||||
: null;
|
: null;
|
||||||
@@ -955,6 +1297,7 @@ function ActionGroupEditor<T extends string>({
|
|||||||
value={composerDraftValues[field.name]}
|
value={composerDraftValues[field.name]}
|
||||||
geometryChoices={geometryChoices}
|
geometryChoices={geometryChoices}
|
||||||
wikiChoices={wikiChoices}
|
wikiChoices={wikiChoices}
|
||||||
|
onLinkClick={onLinkClick}
|
||||||
onChange={(nextValue) =>
|
onChange={(nextValue) =>
|
||||||
setComposerDraftValues((prev) => ({
|
setComposerDraftValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -1001,12 +1344,14 @@ function FieldInput({
|
|||||||
geometryChoices,
|
geometryChoices,
|
||||||
wikiChoices,
|
wikiChoices,
|
||||||
onChange,
|
onChange,
|
||||||
|
onLinkClick,
|
||||||
}: {
|
}: {
|
||||||
field: ActionFieldConfig;
|
field: ActionFieldConfig;
|
||||||
value: ActionValue | undefined;
|
value: ActionValue | undefined;
|
||||||
geometryChoices: Choice[];
|
geometryChoices: Choice[];
|
||||||
wikiChoices: Choice[];
|
wikiChoices: Choice[];
|
||||||
onChange: (nextValue: ActionValue) => void;
|
onChange: (nextValue: ActionValue) => void;
|
||||||
|
onLinkClick?: (quill: any) => void;
|
||||||
}) {
|
}) {
|
||||||
const baseLabel = (
|
const baseLabel = (
|
||||||
<div style={{ fontSize: 12, color: "#cbd5e1", fontWeight: 700 }}>
|
<div style={{ fontSize: 12, color: "#cbd5e1", fontWeight: 700 }}>
|
||||||
@@ -1014,6 +1359,36 @@ function FieldInput({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (field.kind === "rich-text") {
|
||||||
|
return (
|
||||||
|
<label style={{ display: "grid", gap: 6 }}>
|
||||||
|
{baseLabel}
|
||||||
|
<div style={{ background: "#0b1220", borderRadius: 6, border: "1px solid #334155" }} className="dark">
|
||||||
|
<ReactQuillEditor
|
||||||
|
theme="snow"
|
||||||
|
value={asString(value)}
|
||||||
|
onChange={(content: string) => onChange(content)}
|
||||||
|
modules={{
|
||||||
|
toolbar: {
|
||||||
|
container: [
|
||||||
|
["bold", "italic", "underline", "strike"],
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
["link"],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
link: function (this: { quill?: any }) {
|
||||||
|
onLinkClick?.(this?.quill);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.kind === "textarea") {
|
if (field.kind === "textarea") {
|
||||||
return (
|
return (
|
||||||
<label style={{ display: "grid", gap: 6 }}>
|
<label style={{ display: "grid", gap: 6 }}>
|
||||||
|
|||||||
@@ -86,116 +86,53 @@ export default function ReplayPreviewOverlay({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dialog?.image_url ? (
|
{dialog && (dialog.text?.trim() || dialog.image_url?.trim()) ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 18,
|
right: hasWikiPreview ? 472 : 18,
|
||||||
bottom: 96,
|
bottom: 96,
|
||||||
width: 320,
|
width: 380,
|
||||||
borderRadius: 18,
|
borderRadius: 20,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
border: "1px solid rgba(148, 163, 184, 0.22)",
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
background: "rgba(15, 23, 42, 0.9)",
|
background: "rgba(11, 18, 32, 0.85)",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
|
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{dialog.image_url?.trim() ? (
|
||||||
src={dialog.image_url}
|
|
||||||
alt={dialog.image_caption || "Historical image"}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
display: "block",
|
|
||||||
maxHeight: 240,
|
|
||||||
objectFit: "cover",
|
|
||||||
background: "#020617",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{dialog.image_caption?.trim() ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "10px 12px",
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 1.45,
|
|
||||||
color: "#cbd5e1",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dialog.image_caption}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{dialog && dialog.text?.trim() ? (
|
|
||||||
dialog.avatar?.trim() ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 18,
|
|
||||||
bottom: 96,
|
|
||||||
maxWidth: 420,
|
|
||||||
display: "grid",
|
|
||||||
gap: 10,
|
|
||||||
gridTemplateColumns: "56px 1fr",
|
|
||||||
alignItems: "start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={dialog.avatar}
|
src={dialog.image_url}
|
||||||
alt="speaker"
|
alt="Historical"
|
||||||
style={{
|
style={{
|
||||||
width: 56,
|
width: "100%",
|
||||||
height: 56,
|
display: "block",
|
||||||
borderRadius: "50%",
|
maxHeight: 220,
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
border: "2px solid rgba(125, 211, 252, 0.55)",
|
background: "#020617",
|
||||||
background: "#0f172a",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{dialog.text?.trim() ? (
|
||||||
<div
|
<div
|
||||||
|
className="ql-editor"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 18,
|
padding: "16px",
|
||||||
border: "1px solid rgba(148, 163, 184, 0.24)",
|
|
||||||
background: "rgba(15, 23, 42, 0.92)",
|
|
||||||
padding: "14px 16px",
|
|
||||||
color: "#f8fafc",
|
color: "#f8fafc",
|
||||||
boxShadow: "0 14px 36px rgba(2, 6, 23, 0.38)",
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
overflowY: "auto",
|
||||||
|
maxHeight: "250px",
|
||||||
|
background: "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
dangerouslySetInnerHTML={{ __html: dialog.text }}
|
||||||
<div
|
/>
|
||||||
style={{
|
) : null}
|
||||||
fontSize: 15,
|
</div>
|
||||||
lineHeight: 1.5,
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dialog.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "50%",
|
|
||||||
bottom: 90,
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
maxWidth: 720,
|
|
||||||
borderRadius: 18,
|
|
||||||
border: "1px solid rgba(148, 163, 184, 0.24)",
|
|
||||||
background: "rgba(2, 6, 23, 0.84)",
|
|
||||||
color: "#f8fafc",
|
|
||||||
padding: "10px 18px",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1.45,
|
|
||||||
textAlign: "center",
|
|
||||||
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dialog.text}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPreviewMode ? (
|
{isPreviewMode ? (
|
||||||
|
|||||||
@@ -1361,7 +1361,8 @@ function buildNarrativeActionEntry(
|
|||||||
} else {
|
} else {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (dialog.text) {
|
if (dialog.text) {
|
||||||
parts.push(`text=${summarizeValue(dialog.text, "")}`);
|
const plainText = dialog.text.replace(/<[^>]*>/g, "");
|
||||||
|
parts.push(`text=${summarizeValue(plainText, "")}`);
|
||||||
}
|
}
|
||||||
if (dialog.avatar) {
|
if (dialog.avatar) {
|
||||||
parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`);
|
parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user