Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions src/components/dialogue/DialoguePreviewOverlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function DialoguePreviewOverlay({
edges = [],
participants = [],
rootDialogueId = '',
startNodeId = '',
loadDialogueGraphForPreview,
onStop,
onNodeFocus,
Expand Down Expand Up @@ -173,10 +174,23 @@ export function DialoguePreviewOverlay({
[getGraphRuntime]
);

// Where the preview begins in the root dialogue: the chosen node, or the Start node's first valid output.
const getRootStartRef = useCallback(
(context) => {
const wanted = String(startNodeId || '').trim();
if (wanted) {
const graph = getGraphRuntime(safeRootDialogueId);
if (graph?.nodeMap.has(wanted)) return buildNodeRef(safeRootDialogueId, wanted);
}
return getStartPreviewNodeRef(safeRootDialogueId, context);
},
[startNodeId, safeRootDialogueId, getGraphRuntime, getStartPreviewNodeRef]
);

const selectedRouteNodeKeys = useMemo(() => {
const route = [];
const seen = new Set();
let cursorRef = getStartPreviewNodeRef(safeRootDialogueId, scenarioContext);
let cursorRef = getRootStartRef(scenarioContext);
let steps = 0;

while (cursorRef && steps < MAX_PREVIEW_STEPS) {
Expand Down Expand Up @@ -224,10 +238,10 @@ export function DialoguePreviewOverlay({

return route;
}, [
safeRootDialogueId,
scenarioContext,
graphCache,
getStartPreviewNodeRef,
getRootStartRef,
getTraversableNextNodeRefs,
selectedBranches,
]);
Expand Down Expand Up @@ -393,6 +407,8 @@ export function DialoguePreviewOverlay({
nodeRef: choiceRef,
id: choiceNode.id,
dialogueId: choiceRef.dialogueId,
// Action label (what the player picks) and the line the player then says.
spokenLine: String(getDialogueRowsForPreview(choiceNode)[0]?.text || '').trim(),
label:
choiceNode.data?.selectionTitle ||
choiceNode.data?.displayName ||
Expand Down Expand Up @@ -432,7 +448,12 @@ export function DialoguePreviewOverlay({
}
});

return options;
// Options that end the conversation (goodbye) go last; others keep their order.
const endsConversation = (option) => option.outcome === 'complete' || option.outcome === 'end';
return options
.map((option, index) => ({ option, index }))
.sort((a, b) => Number(endsConversation(a.option)) - Number(endsConversation(b.option)) || a.index - b.index)
.map(({ option }) => option);
},
[getGraphRuntime, buildChoiceOption]
);
Expand Down Expand Up @@ -702,7 +723,7 @@ export function DialoguePreviewOverlay({
);

const startScenarioPreview = useCallback(() => {
const startNodeRef = getStartPreviewNodeRef(safeRootDialogueId, scenarioContext);
const startNodeRef = getRootStartRef(scenarioContext);
if (!startNodeRef) {
setScenarioError(t('editor.preview.invalidNoPlayablePath'));
return;
Expand All @@ -711,7 +732,7 @@ export function DialoguePreviewOverlay({
setScenarioReady(true);
runtimeRef.current.stepCount = 0;
goToNode(startNodeRef);
}, [getStartPreviewNodeRef, goToNode, safeRootDialogueId, scenarioContext, t]);
}, [getRootStartRef, goToNode, scenarioContext, t]);

const preloadGraphCache = useCallback(async () => {
const rootId = String(safeRootDialogueId || '').trim();
Expand Down Expand Up @@ -760,9 +781,9 @@ export function DialoguePreviewOverlay({
// A playback session owns timers/audio. Changing volume or callback identities
// must not tear it down; StrictMode cleanup/setup must always start it afresh.
const sessionCallbacksRef = useRef(null);
sessionCallbacksRef.current = { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio };
sessionCallbacksRef.current = { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, getRootStartRef, preloadGraphCache, stopAudio };
useEffect(() => {
const { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio } = sessionCallbacksRef.current;
const { clearTimer, closePreview, goToNode, getRootStartRef, preloadGraphCache, stopAudio } = sessionCallbacksRef.current;
const runtime = runtimeRef.current;
sessionRef.current += 1;
if (!open) {
Expand Down Expand Up @@ -808,10 +829,7 @@ export function DialoguePreviewOverlay({
acc[rule.key] = true;
return acc;
}, {});
const initialStartNodeRef = getStartPreviewNodeRef(
safeRootDialogueId,
initialScenarioContext
);
const initialStartNodeRef = getRootStartRef(initialScenarioContext);

setScenarioRules(mergedScenarioRules);
setScenarioContext(initialScenarioContext);
Expand Down Expand Up @@ -1085,6 +1103,11 @@ export function DialoguePreviewOverlay({
>
<div className="w-full">
<div className="font-medium">{choice.label}</div>
{choice.spokenLine && choice.spokenLine !== choice.label && (
<div className="mt-0.5 text-sm italic text-muted-foreground">
&ldquo;{choice.spokenLine}&rdquo;
</div>
)}
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
{choice.outcome === 'end' && (
<span className="inline-flex items-center rounded-full border border-amber-500/35 bg-amber-500/10 px-2 py-0.5">
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@
"focusNode": "Zaměřit node",
"unsavedLeaveTitle": "Neuložené změny",
"unsavedLeaveDescription": "Máš neuložené změny. Opuštěním stránky budou ztraceny. Pokračovat?"
},
"preview": {
"startFromHere": "Náhled odtud"
}
},
"participants": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@
"recenterTooltip": "Gesamten Graph anzeigen",
"startFocusTooltip": "Startknoten fokussieren"
},
"preview": {
"startFromHere": "Vorschau ab hier"
},
"nodes": {
"start": "Start",
"startDescription": "Einstiegspunkt des Dialogs",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@
"title": "Dialogue Preview",
"start": "Preview",
"startTooltip": "Start dialogue preview",
"startFromHere": "Preview from here",
"stop": "Stop",
"continue": "Continue",
"waiting": "Waiting for next dialogue line...",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@
"focusNode": "Ir al nodo",
"unsavedLeaveTitle": "Cambios sin guardar",
"unsavedLeaveDescription": "Tienes cambios sin guardar. Salir los descartará. ¿Continuar?"
},
"preview": {
"startFromHere": "Vista previa desde aquí"
}
},
"participants": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@
"recenterTooltip": "Afficher tout le graphe",
"startFocusTooltip": "Centrer le nœud Start"
},
"preview": {
"startFromHere": "Aperçu à partir d'ici"
},
"nodes": {
"start": "Début",
"startDescription": "Point d'entrée du dialogue",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@
"recenterTooltip": "Pokaż cały graf",
"startFocusTooltip": "Przejdź do węzła Start"
},
"preview": {
"startFromHere": "Podgląd od tego miejsca"
},
"nodes": {
"start": "Start",
"startDescription": "Punkt wejścia dialogu",
Expand Down
25 changes: 24 additions & 1 deletion src/lib/persistence/canonicalProject.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { AUTHORING_TABLES } from './schema';
import {
allocateDialogueLocalizationSlug,
normalizeLocalizedStringEntry,
normalizeProjectLocalizationConfig,
prepareLocalizedNodesAndEntries,
validateLocalizedEntriesForDialogue,
} from '@/lib/localization/stringTable';
import { validateCategoryTree, normalizeParticipant, validateDefinition } from '@/lib/domainIntegrity';
import { validateCategoryTree, normalizeParticipant, validateDefinition, normalizePropertyType, normalizePropertyDefault } from '@/lib/domainIntegrity';
import { isValidRowTiming } from '@/lib/rowTiming';

export const PROJECT_FORMAT = 'mountea.project.v3';
Expand Down Expand Up @@ -76,6 +77,17 @@ function repairLegacyLocalization(snapshot) {
locale: defaultLocale,
existingEntries,
});
// prepareLocalizedNodesAndEntries stamps every entry with "now". Unchanged entries keep their timestamps, otherwise
// canonicalizing a canonical snapshot again would change its hash (and a remote revision would look corrupt).
const previousByKey = new Map(existingEntries.map((entry) => [entry.key, entry]));
prepared.entries = prepared.entries.map((entry) => {
const previous = previousByKey.get(entry.key);
const stable = previous && JSON.stringify(previous.values) === JSON.stringify(entry.values)
? { ...entry, createdAt: previous.createdAt || entry.createdAt, modifiedAt: previous.modifiedAt || entry.modifiedAt }
: entry;
// Same shape a later pass would produce (adds keyType), so canonicalizing twice is a fixed point.
return normalizeLocalizedStringEntry(stable);
});
const preparedKeys = new Set(prepared.entries.map((entry) => entry.key));
const unusedEntries = existingEntries.filter((entry) => entry.key && !preparedKeys.has(entry.key));
snapshot.nodes = (snapshot.nodes || []).filter((node) => node.dialogueId !== dialogueId).concat(prepared.nodes);
Expand Down Expand Up @@ -154,6 +166,17 @@ export async function canonicalizeProject(input, { allowIncompleteMedia = false
snapshot[table] = (input[table] || []).map((record) => ({ ...record }));
}
snapshot.dialogues = snapshot.dialogues.map((row) => { const next = { ...row }; for (const field of LOCAL_FIELDS) delete next[field]; return next; });
// Legacy property types (bool, int, float and this fork's "name") are normalized here, not only while validating,
// so a snapshot hashes the same before and after validation.
for (const table of ['decorators', 'conditions']) {
snapshot[table] = snapshot[table].map((definition) => ({
...definition,
properties: (Array.isArray(definition.properties) ? definition.properties : []).map((property) => {
const type = normalizePropertyType(property?.type);
return { ...property, type, defaultValue: normalizePropertyDefault(type, property?.defaultValue) };
}),
}));
}
snapshot.participants = await Promise.all(snapshot.participants.map(async (participant) => {
if (!participant.thumbnail) return participant;
const path = `participants.${participant.id}.thumbnail`, thumbnail = participant.thumbnail;
Expand Down
Loading