From 047f6627363bb6ce781b81b5021026d3bfa9537c Mon Sep 17 00:00:00 2001 From: taDuc Date: Sun, 17 May 2026 21:45:33 +0700 Subject: [PATCH] complete replay editor v1 --- src/app/editor/[id]/page.tsx | 178 +- src/uhm/components/Map.tsx | 3 - .../editor/ReplayEffectsSidebar.tsx | 1604 +++++++++++++++++ .../editor/ReplayTimelineSidebar.tsx | 1399 ++++++++++++++ src/uhm/components/map/useMapInteraction.ts | 3 +- src/uhm/components/wiki/WikiSidebarPanel.tsx | 2 - src/uhm/doc/commit_snapshot.ts | 460 +++-- src/uhm/doc/developer_guide.md | 1 - src/uhm/doc/editor_state_replay.md | 218 +++ src/uhm/doc/export_json_replay.md | 246 +++ src/uhm/doc/map_engine.md | 1 - .../lib/editor/project/useProjectCommands.ts | 24 +- src/uhm/lib/editor/snapshot/editorSnapshot.ts | 324 +++- src/uhm/lib/editor/state/useEditorState.ts | 202 ++- src/uhm/lib/map/engines/selectingEngine.ts | 27 +- src/uhm/lib/map/styles/geotypeLayers.ts | 16 +- src/uhm/lib/replay/mapActions.ts | 105 +- src/uhm/lib/replay/replayDispatcher.ts | 221 ++- src/uhm/lib/replay/uiActions.ts | 36 +- src/uhm/types/entities.ts | 5 - src/uhm/types/geo.ts | 3 - src/uhm/types/projects.ts | 62 +- src/uhm/types/wiki.ts | 8 - 23 files changed, 4658 insertions(+), 490 deletions(-) create mode 100644 src/uhm/components/editor/ReplayEffectsSidebar.tsx create mode 100644 src/uhm/components/editor/ReplayTimelineSidebar.tsx create mode 100644 src/uhm/doc/editor_state_replay.md create mode 100644 src/uhm/doc/export_json_replay.md diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 5eb7793..c22cdec 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,13 +1,15 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react"; import { useParams, useRouter } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; -import Map from "@/uhm/components/Map"; +import Map, { type MapHandle } from "@/uhm/components/Map"; import Editor from "@/uhm/components/Editor"; import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel"; +import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar"; +import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar"; import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; @@ -82,6 +84,7 @@ function EditorPageContent() { const geoBindingStatusTimeoutRef = useRef(null); const localCreatedEntityIdsRef = useRef>(new Set()); const lastSelectedFeatureIdRef = useRef(null); + const mapHandleRef = useRef(null); const { mode, internalSetMode, @@ -151,7 +154,6 @@ function EditorPageContent() { setTimelineFilterEnabled, geometryBindingFilterEnabled, setGeoBindingStatus, - hoveredGeometryId, geometryFocusRequest, setGeometryFocusRequest, replayFeatureId, @@ -228,7 +230,6 @@ function EditorPageContent() { setTimelineFilterEnabled: state.setTimelineFilterEnabled, geometryBindingFilterEnabled: state.geometryBindingFilterEnabled, setGeoBindingStatus: state.setGeoBindingStatus, - hoveredGeometryId: state.hoveredGeometryId, geometryFocusRequest: state.geometryFocusRequest, setGeometryFocusRequest: state.setGeometryFocusRequest, replayFeatureId: state.replayFeatureId, @@ -287,7 +288,6 @@ function EditorPageContent() { id: String(e.id || ""), name: String(e.name || "").trim() || String(e.id || ""), description: e.description ?? null, - status: typeof e.status === "number" ? e.status : 1, geometry_count: 0, })) .filter((e) => e.id.length > 0 && e.name.length > 0); @@ -297,6 +297,13 @@ function EditorPageContent() { () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), [entityCatalog, snapshotEntitiesAsEntities] ); + const [replaySelection, setReplaySelection] = useState<{ + stageId: number | null; + stepIndex: number | null; + }>({ + stageId: null, + stepIndex: null, + }); const entitiesRef = useRef(entities); useEffect(() => { entitiesRef.current = entities; @@ -382,17 +389,15 @@ function EditorPageContent() { return normalizeFeatureBindingIds(selectedFeature); }, [selectedFeature]); - const hoveredGeometryHighlight = useMemo(() => { - if (!hoveredGeometryId) return null; - const feature = editor.draft.features.find( - (item) => String(item.properties.id) === hoveredGeometryId - ); - if (!feature) return null; - return { - type: "FeatureCollection", - features: [feature], - } as FeatureCollection; - }, [editor.draft.features, hoveredGeometryId]); + const wikiChoices = useMemo(() => { + return (snapshotWikis || []) + .filter((wiki) => wiki && wiki.operation !== "delete") + .map((wiki) => ({ + id: String(wiki.id || ""), + label: `${(wiki.title || "").trim() || "Untitled wiki"} (${String(wiki.id || "")})`, + })) + .filter((wiki) => wiki.id.length > 0); + }, [snapshotWikis]); const wikiDirty = useMemo(() => { const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); @@ -440,6 +445,10 @@ function EditorPageContent() { + (entitiesDirty ? 1 : 0) + (entityWikiDirty ? 1 : 0) + (replayDirty ? 1 : 0); + const activeReplayStages = useMemo( + () => editor.activeReplayDraft?.detail || [], + [editor.activeReplayDraft?.detail] + ); const sectionCommands = useProjectCommands({ editor, @@ -459,7 +468,9 @@ function EditorPageContent() { // QUY TẮC: Geo chọn đầu tiên là geo main. const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; setReplayFeatureId(triggerId); + setReplaySelection({ stageId: null, stepIndex: null }); editor.switchReplayContext(triggerId, selectedFeatureIds); + setSelectedFeatureIds([]); } else if (m !== "replay") { if (mode === "replay") { editor.closeReplayContext(); @@ -467,10 +478,49 @@ function EditorPageContent() { } setReplayFeatureId(null); setHideOutside(false); + setReplaySelection({ stageId: null, stepIndex: null }); } internalSetMode(m); }, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]); + useEffect(() => { + if (!activeReplayStages.length) { + if (replaySelection.stageId != null || replaySelection.stepIndex != null) { + setReplaySelection({ stageId: null, stepIndex: null }); + } + return; + } + + const targetStage = + activeReplayStages.find((stage) => stage.id === replaySelection.stageId) || + activeReplayStages[0]; + const nextStageId = targetStage.id; + let nextStepIndex: number | null = null; + + if (targetStage.steps.length > 0) { + if ( + replaySelection.stageId === targetStage.id && + replaySelection.stepIndex != null && + replaySelection.stepIndex >= 0 && + replaySelection.stepIndex < targetStage.steps.length + ) { + nextStepIndex = replaySelection.stepIndex; + } else { + nextStepIndex = 0; + } + } + + if ( + nextStageId !== replaySelection.stageId || + nextStepIndex !== replaySelection.stepIndex + ) { + setReplaySelection({ + stageId: nextStageId, + stepIndex: nextStepIndex, + }); + } + }, [activeReplayStages, replaySelection.stageId, replaySelection.stepIndex]); + const effectiveGeometryVisibility = useMemo(() => { const visibility: Record = { ...geometryVisibility }; @@ -1097,7 +1147,6 @@ function EditorPageContent() { operation: "reference", title, doc: null, - updated_at: wiki.updated_at, }, ...prev, ]; @@ -1119,7 +1168,6 @@ function EditorPageContent() { id: entityItem.entity_id, name: (entityItem.name || "").trim() || entityItem.entity_id, description: (entityItem.description || "").trim() || null, - status: 1, geometry_count: 0, }; @@ -1226,7 +1274,6 @@ function EditorPageContent() { id: entityId, name, description, - status: 1, geometry_count: 0, }; @@ -1241,9 +1288,7 @@ function EditorPageContent() { source: "inline", operation: "create", name, - slug: null, description, - status: 1, }, ...prev, ]; @@ -1317,7 +1362,19 @@ function EditorPageContent() { ) : ( <> -
+ setReplaySelection({ stageId, stepIndex })} + onMutateReplay={editor.mutateActiveReplay} + onUndoReplay={editor.undo} + onExitReplay={() => setMode("select")} + /> { @@ -1373,6 +1430,7 @@ function EditorPageContent() {
{isBackgroundVisibilityReady ? ( )} - {mode !== "replay" && ( - - )} +
) : null} @@ -1692,7 +1748,18 @@ function EditorPageContent() { setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); }} /> -
+ String(id))} + currentTimelineYear={timelineDraftYear} + geometryChoices={geometryChoices} + wikiChoices={wikiChoices} + getCurrentMapViewState={() => mapHandleRef.current?.getViewState() ?? null} + onMutateReplay={editor.mutateActiveReplay} + /> )}
@@ -1794,9 +1861,7 @@ function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) id: String(e.id), source: e.source, name: typeof e.name === "string" ? e.name.trim() : "", - slug: typeof e.slug === "string" ? e.slug : null, description: e.description == null ? null : String(e.description), - status: typeof e.status === "number" ? e.status : null, })) .sort((a, b) => a.id.localeCompare(b.id)); return normalized; @@ -1821,28 +1886,33 @@ function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) { .filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0) .map((replay) => ({ geometry_id: replay.geometry_id, + target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare( + replay.target_geometry_ids, + replay.geometry_id + ), detail: Array.isArray(replay.detail) ? replay.detail : [], - replay_features: normalizeReplayFeatureCollection(replay.replay_features), })) .sort((a, b) => a.geometry_id.localeCompare(b.geometry_id)); } -function normalizeReplayFeatureCollection(input: FeatureCollection | null | undefined) { - const features = Array.isArray(input?.features) ? input.features : []; - return { - type: "FeatureCollection" as const, - features: features - .filter((feature) => feature && feature.properties && (typeof feature.properties.id === "string" || typeof feature.properties.id === "number")) - .map((feature) => ({ - type: "Feature" as const, - properties: { - ...feature.properties, - id: String(feature.properties.id), - }, - geometry: feature.geometry, - })) - .sort((a, b) => String(a.properties.id).localeCompare(String(b.properties.id))), +function normalizeReplayTargetGeometryIdsForCompare( + input: string[] | null | undefined, + geometryId: string +) { + const orderedIds: string[] = []; + const seen = new Set(); + + const pushId = (rawId: string | number | null | undefined) => { + if (rawId == null) return; + const id = String(rawId).trim(); + if (!id || seen.has(id)) return; + seen.add(id); + orderedIds.push(id); }; + + pushId(geometryId); + for (const rawId of input || []) pushId(rawId); + return orderedIds; } function normalizeGeoSearchGeometry(value: unknown): Geometry | null { diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 82d0ec0..49492f0 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -332,9 +332,6 @@ const Map = forwardRef(function Map({ userSelect: "none", }} > - - Hide Outside -
CurrentMapViewState | null; + onMutateReplay: (label: string, mutator: (draftReplay: BattleReplay) => void) => boolean; +}; + +type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function" | "use_narrow_function"; +type ActionValue = string | boolean | string[]; +type ActionFormValues = Record; +type AnyReplayAction = ReplayAction; + +type ActionFieldConfig = { + name: string; + label: string; + kind: + | "text" + | "textarea" + | "number" + | "boolean" + | "color" + | "select" + | "geometry" + | "geometry-multi" + | "wiki"; + placeholder?: string; + options?: Array<{ label: string; value: string }>; + visibleWhen?: (values: ActionFormValues) => boolean; +}; + +type ActionDefinition = { + label: string; + fields: ActionFieldConfig[]; + create: () => ReplayAction; + deserialize: (params: unknown[]) => ActionFormValues; + serialize: (values: ActionFormValues) => unknown[]; +}; + +type NarrativeActionDefinitionMap = Record>; +type UiEffectsDraftState = { + selected: Record; + wiki_id: string; + message: string; + header_id: string; + speed: string; +}; +type MapCameraOptionName = "center" | "zoom" | "bearing" | "pitch"; +type MapCameraDraftState = { + selected: Record; +}; +type CurrentMapViewState = { + center: { lng: number; lat: number }; + zoom: number; + pitch: number; + bearing: number; + projection: string; +}; + +const uiOptionChoices: Array<{ label: string; value: UIOptionName }> = [ + { label: "Timeline", value: "timeline" }, + { label: "Layer Panel", value: "layer_panel" }, + { label: "Wiki Panel", value: "wiki_panel" }, + { label: "Zoom Panel", value: "zoom_panel" }, + { label: "Wiki", value: "wiki" }, + { label: "Toast", value: "toast" }, + { label: "Wiki Header", value: "wiki_header" }, + { label: "Playback Speed", value: "playback_speed" }, +]; + +const uiSimpleOptionValues: UIOptionName[] = [ + "timeline", + "layer_panel", + "wiki_panel", + "zoom_panel", +]; + +const uiInputOptionValues: UIOptionName[] = [ + "wiki", + "toast", + "wiki_header", + "playback_speed", +]; + +const mapCameraOptionChoices: Array<{ label: string; value: MapCameraOptionName }> = [ + { label: "LngLat", value: "center" }, + { label: "Zoom", value: "zoom" }, + { label: "Bearing", value: "bearing" }, + { label: "Pitch", value: "pitch" }, +]; + +const sidebarStyle = { + background: "#111827", + color: "#e5e7eb", + borderLeft: "1px solid #1f2937", + padding: "12px", + height: "100vh", + overflowY: "auto" as const, +}; + +const inputStyle = { + width: "100%", + padding: "8px 10px", + borderRadius: 6, + border: "1px solid #334155", + background: "#0b1220", + color: "white", + boxSizing: "border-box" as const, + fontSize: 13, + outline: "none", +}; + +const buttonStyle = { + padding: "8px 10px", + borderRadius: 6, + border: "1px solid #334155", + background: "#111827", + color: "white", + cursor: "pointer", + fontWeight: 800, + fontSize: 12, +}; + +const narrativeActionDefinitions: NarrativeActionDefinitionMap = { + set_title: { + label: "Tiêu đề step", + fields: [{ name: "title", label: "Title", kind: "text", placeholder: "Tiêu đề" }], + create: () => ({ function_name: "set_title", params: [""] }), + deserialize: (params) => ({ title: asString(params[0]) }), + serialize: (values) => [asString(values.title)], + }, + set_descriptions: { + label: "Mô tả", + fields: [{ name: "text", label: "Text", kind: "textarea", placeholder: "Nội dung diễn giải" }], + create: () => ({ function_name: "set_descriptions", params: [""] }), + deserialize: (params) => ({ text: asString(params[0]) }), + serialize: (values) => [asString(values.text)], + }, + show_dialog_box: { + label: "Dialog box", + fields: [ + { name: "avatar", label: "Avatar", kind: "text", placeholder: "avatar url" }, + { name: "text", label: "Text", kind: "textarea", placeholder: "Lời thoại" }, + { + name: "side", + label: "Side", + kind: "select", + options: [ + { label: "Left", value: "left" }, + { label: "Right", value: "right" }, + ], + }, + { name: "speaker", label: "Speaker", kind: "text", placeholder: "Tên nhân vật" }, + ], + create: () => ({ function_name: "show_dialog_box", params: ["", "", "left", ""] }), + deserialize: (params) => ({ + avatar: asString(params[0]), + text: asString(params[1]), + side: normalizeSelectValue(asString(params[2]), "left"), + speaker: asString(params[3]), + }), + serialize: (values) => [ + asString(values.avatar), + asString(values.text), + normalizeSelectValue(asString(values.side), "left"), + asString(values.speaker), + ], + }, + display_historical_image: { + label: "Ảnh lịch sử", + fields: [ + { name: "url", label: "URL", kind: "text", placeholder: "https://..." }, + { name: "caption", label: "Caption", kind: "textarea", placeholder: "Chú thích" }, + ], + create: () => ({ function_name: "display_historical_image", params: ["", ""] }), + deserialize: (params) => ({ + url: asString(params[0]), + caption: asString(params[1]), + }), + serialize: (values) => compactTrailingUndefined([ + asString(values.url), + emptyToUndefined(asString(values.caption)), + ]), + }, + set_step_subtitle: { + label: "Phụ đề", + fields: [{ name: "subtitle", label: "Subtitle", kind: "textarea", placeholder: "Để trống để ẩn subtitle" }], + create: () => ({ function_name: "set_step_subtitle", params: [""] }), + deserialize: (params) => ({ subtitle: params[0] == null ? "" : asString(params[0]) }), + serialize: (values) => [emptyToNull(asString(values.subtitle))], + }, +}; + +export default function ReplayEffectsSidebar({ + width = 420, + replay, + selectedStageId, + selectedStepIndex, + selectedFeatureIds, + currentTimelineYear, + geometryChoices, + wikiChoices, + getCurrentMapViewState, + onMutateReplay, +}: Props) { + const stages = useMemo(() => replay?.detail || [], [replay?.detail]); + const selectedStage = + stages.find((stage) => stage.id === selectedStageId) || + stages[0] || + null; + const selectedStep = + selectedStage && + selectedStepIndex != null && + selectedStepIndex >= 0 && + selectedStepIndex < selectedStage.steps.length + ? selectedStage.steps[selectedStepIndex] + : null; + const mapCameraActions = useMemo( + () => + (selectedStep?.use_map_function.filter( + (action) => action.function_name === "set_camera_view" + ) || []) as ReplayAction<"set_camera_view">[], + [selectedStep?.use_map_function] + ); + const nonCameraMapActions = useMemo( + () => + (selectedStep?.use_map_function.filter( + (action) => action.function_name !== "set_camera_view" + ) || []) as ReplayAction[], + [selectedStep?.use_map_function] + ); + const geoActions = useMemo( + () => selectedStep?.use_geo_function || [], + [selectedStep?.use_geo_function] + ); + const selectedGeometryItems = useMemo(() => { + const seen = new Set(); + const byId = new Map(geometryChoices.map((choice) => [String(choice.id), choice])); + return selectedFeatureIds + .map((id) => String(id).trim()) + .filter((id) => { + if (!id.length || seen.has(id)) return false; + seen.add(id); + return true; + }) + .map((id) => byId.get(id) || { id, label: id }); + }, [geometryChoices, selectedFeatureIds]); + const selectedGeometryIds = useMemo( + () => selectedGeometryItems.map((item) => item.id), + [selectedGeometryItems] + ); + + const updateStep = (label: string, updater: (step: ReplayStep) => void) => { + if (!selectedStage || selectedStepIndex == null) return; + onMutateReplay(label, (draftReplay) => { + const stage = draftReplay.detail.find((item) => item.id === selectedStage.id); + if (!stage) return; + if (selectedStepIndex < 0 || selectedStepIndex >= stage.steps.length) return; + updater(stage.steps[selectedStepIndex]); + }); + }; + + const updateActionGroup = (groupKey: ActionGroupKey, nextActions: AnyReplayAction[], actionLabel: string) => { + updateStep(actionLabel, (step) => { + switch (groupKey) { + case "use_UI_function": + step.use_UI_function = nextActions as ReplayStep["use_UI_function"]; + return; + case "use_map_function": + step.use_map_function = nextActions as ReplayStep["use_map_function"]; + return; + case "use_geo_function": + step.use_geo_function = nextActions as ReplayStep["use_geo_function"]; + return; + case "use_narrow_function": + step.use_narrow_function = nextActions as ReplayStep["use_narrow_function"]; + return; + } + }); + }; + const appendMapActions = (nextActions: ReplayAction[], actionLabel: string) => { + if (!selectedStep || nextActions.length === 0) return; + updateActionGroup( + "use_map_function", + [...selectedStep.use_map_function, ...nextActions], + actionLabel + ); + }; + const appendGeoActions = (nextActions: ReplayAction[], actionLabel: string) => { + if (!selectedStep || nextActions.length === 0) return; + updateActionGroup( + "use_geo_function", + [...geoActions, ...nextActions], + actionLabel + ); + }; + + return ( + + ); +} + +function MapFunctionShortcutPanel({ + currentTimelineYear, + onAppendActions, +}: { + currentTimelineYear: number; + onAppendActions: (actions: ReplayAction[], label: string) => void; +}) { + const safeYear = Math.trunc(currentTimelineYear); + + return ( + +
+
+ + onAppendActions( + [{ function_name: "show_labels", params: [] }], + "Map: show labels" + ) + } + /> + + onAppendActions( + [{ function_name: "hide_labels", params: [] }], + "Map: hide labels" + ) + } + /> + + onAppendActions( + [{ function_name: "enable_timeline_filter", params: [] }], + "Map: enable timeline filter" + ) + } + /> + + onAppendActions( + [{ function_name: "disable_timeline_filter", params: [] }], + "Map: disable timeline filter" + ) + } + /> + + onAppendActions( + [{ function_name: "set_time_filter", params: [safeYear] }], + `Map: set timeline ${safeYear}` + ) + } + /> + + onAppendActions( + [{ function_name: "reset_camera_north", params: [] }], + "Map: reset camera north" + ) + } + /> +
+
+
+ ); +} + +function GeoFunctionShortcutPanel({ + selectedGeometries, + onAppendActions, +}: { + selectedGeometries: Choice[]; + onAppendActions: (actions: ReplayAction[], label: string) => void; +}) { + const selectedIds = selectedGeometries.map((item) => item.id); + const selectedCount = selectedIds.length; + const firstId = selectedIds[0] || ""; + const hasSelection = selectedCount > 0; + + return ( + +
+ {!hasSelection ? ( +
+ Chọn geo trực tiếp trên map replay rồi bấm action tương ứng. +
+ ) : null} +
+ + onAppendActions( + [{ function_name: "fly_to_geometries", params: [selectedIds] }], + `Geo: fly ${selectedCount} geo` + ) + } + /> + + onAppendActions( + [{ function_name: "follow_geometries_path", params: [selectedIds, 5000, 8, 50] }], + `Geo: follow path ${selectedCount} geo` + ) + } + /> + + onAppendActions( + [{ function_name: "show_geometries", params: [selectedIds] }], + `Geo: show ${selectedCount} geo` + ) + } + /> + + onAppendActions( + [{ function_name: "hide_geometries", params: [selectedIds] }], + `Geo: hide ${selectedCount} geo` + ) + } + /> + + onAppendActions( + selectedIds.map((id) => ({ + function_name: "pulse_geometry", + params: [id, "#f59e0b", 2, 1800], + })), + `Geo: pulse ${selectedCount} geo` + ) + } + /> + + onAppendActions( + selectedIds.map((id) => ({ + function_name: "animate_dashed_border", + params: [id, "#38bdf8", 2, 1, 3000], + })), + `Geo: dashed border ${selectedCount} geo` + ) + } + /> + + onAppendActions( + [{ function_name: "orbit_camera_around_geometry", params: [firstId, 8, 45, 1, 5000] }], + `Geo: orbit ${firstId || "main"}` + ) + } + /> + + onAppendActions( + selectedIds.map((id) => ({ + function_name: "show_geometry_label", + params: [id, "", "#ffffff", 14], + })), + `Geo: label ${selectedCount} geo` + ) + } + /> + + onAppendActions( + [{ function_name: "dim_other_geometries", params: [selectedIds, 0.18] }], + `Geo: dim others ngoài ${selectedCount} geo` + ) + } + /> + + onAppendActions( + [{ + function_name: "set_geometry_style", + params: [selectedIds, "#f97316", 0.35, "#fdba74", 2], + }], + `Geo: style ${selectedCount} geo` + ) + } + /> +
+
+
+ ); +} + +function ShortcutButton({ + label, + tone, + disabled = false, + onClick, +}: { + label: string; + tone: "slate" | "blue" | "teal" | "green" | "amber"; + disabled?: boolean; + onClick: () => void; +}) { + const backgrounds: Record = { + slate: "#334155", + blue: "#1d4ed8", + teal: "#0f766e", + green: "#166534", + amber: "#b45309", + }; + + return ( + + ); +} + +function MapCameraViewPanel({ + actions, + getCurrentMapViewState, + onApplyAction, +}: { + actions: ReplayAction<"set_camera_view">[]; + getCurrentMapViewState: () => CurrentMapViewState | null; + onApplyAction: ( + nextAction: ReplayAction<"set_camera_view"> | null, + label: string + ) => void; +}) { + const [draft, setDraft] = useState(() => + buildMapCameraDraftState(actions) + ); + const activeCount = mapCameraOptionChoices.filter( + (choice) => draft.selected[choice.value] + ).length; + + useEffect(() => { + setDraft(buildMapCameraDraftState(actions)); + }, [actions]); + + return ( + +
+ ({ + value: choice.value, + label: choice.label, + selected: draft.selected[choice.value], + }))} + onToggleOption={(option) => + setDraft((prev) => ({ + selected: { + ...prev.selected, + [option]: !prev.selected[option], + }, + })) + } + /> + +
+
+ ); +} + +function UiSimpleEffectsPanel({ + draft, + onToggleOption, + onApply, +}: { + draft: UiEffectsDraftState; + onToggleOption: (option: UIOptionName) => void; + onApply: () => void; +}) { + const activeCount = uiSimpleOptionValues.filter((option) => draft.selected[option]).length; + + return ( + +
+ + +
+
+ ); +} + +function UiInputEffectsPanel({ + draft, + wikiChoices, + onToggleOption, + onChangeDraft, + onApply, +}: { + draft: UiEffectsDraftState; + wikiChoices: Choice[]; + onToggleOption: (option: UIOptionName) => void; + onChangeDraft: (patch: Partial) => void; + onApply: () => void; +}) { + const activeCount = uiInputOptionValues.filter((option) => draft.selected[option]).length; + + return ( + +
+ + + {draft.selected.wiki ? ( + onChangeDraft({ wiki_id: asString(nextValue) })} + /> + ) : null} + + {draft.selected.toast ? ( + onChangeDraft({ message: asString(nextValue) })} + /> + ) : null} + + {draft.selected.wiki_header ? ( + onChangeDraft({ header_id: asString(nextValue) })} + /> + ) : null} + + {draft.selected.playback_speed ? ( + onChangeDraft({ speed: asString(nextValue) })} + /> + ) : null} + + +
+
+ ); +} + +function UiOptionToggleRow({ + optionValues, + draft, + onToggleOption, +}: { + optionValues: UIOptionName[]; + draft: UiEffectsDraftState; + onToggleOption: (option: UIOptionName) => void; +}) { + return ( + ({ + value: option, + label: uiOptionChoices.find((item) => item.value === option)?.label || option, + selected: draft.selected[option], + }))} + onToggleOption={onToggleOption} + /> + ); +} + +function SimpleOptionToggleRow({ + options, + onToggleOption, +}: { + options: Array<{ value: T; label: string; selected: boolean }>; + onToggleOption: (option: T) => void; +}) { + return ( +
+ {options.map((option) => { + const isSelected = option.selected; + return ( + + ); + })} +
+ ); +} + +function UiEffectsEditor({ + actions, + wikiChoices, + onApplyActions, +}: { + actions: ReplayAction[]; + wikiChoices: Choice[]; + onApplyActions: (nextActions: ReplayAction[], label: string) => void; +}) { + const [draft, setDraft] = useState(() => buildUiEffectsDraftState(actions)); + + useEffect(() => { + setDraft(buildUiEffectsDraftState(actions)); + }, [actions]); + + return ( + <> + + setDraft((prev) => ({ + ...prev, + selected: { + ...prev.selected, + [option]: !prev.selected[option], + }, + })) + } + onApply={() => + onApplyActions( + replaceUiActionsByGroup(actions, uiSimpleOptionValues, draft), + buildUiEffectsApplyLabel("UI Effects", draft, uiSimpleOptionValues) + ) + } + /> + + setDraft((prev) => ({ + ...prev, + selected: { + ...prev.selected, + [option]: !prev.selected[option], + }, + })) + } + onChangeDraft={(patch) => + setDraft((prev) => ({ + ...prev, + ...patch, + })) + } + onApply={() => + onApplyActions( + replaceUiActionsByGroup(actions, uiInputOptionValues, draft), + buildUiEffectsApplyLabel("UI Inputs", draft, uiInputOptionValues) + ) + } + /> + + ); +} + +function ActionGroupEditor({ + title, + groupKey, + groupLabel, + actions, + definitions, + geometryChoices, + wikiChoices, + createOnSelect = false, + emptyOptionLabel, + onUpdateActions, +}: { + title: string; + groupKey: ActionGroupKey; + groupLabel: string; + actions: ReplayAction[]; + definitions: Record>; + geometryChoices: Choice[]; + wikiChoices: Choice[]; + createOnSelect?: boolean; + emptyOptionLabel?: string; + onUpdateActions: (nextActions: ReplayAction[], label: string) => void; +}) { + const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]); + const [composerFunctionName, setComposerFunctionName] = useState( + createOnSelect ? "" : (functionNames[0] as T) + ); + const [composerDraftValues, setComposerDraftValues] = useState(() => + buildActionComposerDraft(definitions, createOnSelect ? "" : (functionNames[0] as T)) + ); + + const composerDefinition = composerFunctionName + ? definitions[composerFunctionName] + : null; + + const handleComposerFunctionChange = (nextFunctionName: T | "") => { + setComposerFunctionName(nextFunctionName); + setComposerDraftValues(buildActionComposerDraft(definitions, nextFunctionName)); + }; + + const handleApplyNewAction = () => { + if (!composerFunctionName) return; + const definition = definitions[composerFunctionName]; + if (!definition) return; + + onUpdateActions( + [ + ...actions, + { + function_name: composerFunctionName, + params: definition.serialize(composerDraftValues), + }, + ], + `${groupLabel}: thêm ${definition.label}` + ); + + if (createOnSelect) { + setComposerFunctionName(""); + setComposerDraftValues(buildActionComposerDraft(definitions, "")); + return; + } + + setComposerDraftValues(buildActionComposerDraft(definitions, composerFunctionName)); + }; + + return ( + +
+
+ +
+ + {composerDefinition ? ( +
+
+ Tạo action mới: {composerDefinition.label} +
+ + {composerDefinition.fields.length === 0 ? ( +
+ Action này không cần tham số. +
+ ) : ( +
+ {composerDefinition.fields + .filter((field) => + !field.visibleWhen || field.visibleWhen(composerDraftValues) + ) + .map((field) => ( + + setComposerDraftValues((prev) => ({ + ...prev, + [field.name]: nextValue, + })) + } + /> + ))} +
+ )} + + +
+ ) : null} + +
+
+ ); +} + +function buildActionComposerDraft( + definitions: Record>, + functionName: T | "" +): ActionFormValues { + if (!functionName) return {}; + const definition = definitions[functionName]; + if (!definition) return {}; + return definition.deserialize(definition.create().params); +} + +function FieldInput({ + field, + value, + geometryChoices, + wikiChoices, + onChange, +}: { + field: ActionFieldConfig; + value: ActionValue | undefined; + geometryChoices: Choice[]; + wikiChoices: Choice[]; + onChange: (nextValue: ActionValue) => void; +}) { + const baseLabel = ( +
+ {field.label} +
+ ); + + if (field.kind === "textarea") { + return ( +