diff --git a/src/components/dialogue/DialoguePreviewOverlay.jsx b/src/components/dialogue/DialoguePreviewOverlay.jsx
index aa6175a..33f3b0d 100644
--- a/src/components/dialogue/DialoguePreviewOverlay.jsx
+++ b/src/components/dialogue/DialoguePreviewOverlay.jsx
@@ -76,6 +76,7 @@ export function DialoguePreviewOverlay({
edges = [],
participants = [],
rootDialogueId = '',
+ startNodeId = '',
loadDialogueGraphForPreview,
onStop,
onNodeFocus,
@@ -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) {
@@ -224,10 +238,10 @@ export function DialoguePreviewOverlay({
return route;
}, [
- safeRootDialogueId,
scenarioContext,
graphCache,
getStartPreviewNodeRef,
+ getRootStartRef,
getTraversableNextNodeRefs,
selectedBranches,
]);
@@ -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 ||
@@ -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]
);
@@ -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;
@@ -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();
@@ -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) {
@@ -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);
@@ -1085,6 +1103,11 @@ export function DialoguePreviewOverlay({
>
{choice.label}
+ {choice.spokenLine && choice.spokenLine !== choice.label && (
+
+ “{choice.spokenLine}”
+
+ )}
{choice.outcome === 'end' && (
diff --git a/src/i18n/locales/cs.json b/src/i18n/locales/cs.json
index 8d2e9b8..e85bf7f 100644
--- a/src/i18n/locales/cs.json
+++ b/src/i18n/locales/cs.json
@@ -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": {
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 8b07886..c908c50 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -179,6 +179,9 @@
"recenterTooltip": "Gesamten Graph anzeigen",
"startFocusTooltip": "Startknoten fokussieren"
},
+ "preview": {
+ "startFromHere": "Vorschau ab hier"
+ },
"nodes": {
"start": "Start",
"startDescription": "Einstiegspunkt des Dialogs",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index ed4afcc..d146f02 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -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...",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index d634c85..90168b1 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -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": {
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 1d50a74..a8850f3 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -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",
diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json
index f1da136..30af21d 100644
--- a/src/i18n/locales/pl.json
+++ b/src/i18n/locales/pl.json
@@ -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",
diff --git a/src/lib/persistence/canonicalProject.js b/src/lib/persistence/canonicalProject.js
index 2c8bd9c..f15f367 100644
--- a/src/lib/persistence/canonicalProject.js
+++ b/src/lib/persistence/canonicalProject.js
@@ -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';
@@ -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);
@@ -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;
diff --git a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx
index 71a4422..e65861a 100644
--- a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx
+++ b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx
@@ -146,6 +146,17 @@ const initialEdges = [];
const noop = () => {};
+// Copy/paste clipboard for nodes. Module level so it also works between dialogues.
+let nodeClipboard = null; // { projectId, nodes: [], edges: [], pasteCount: 0 }
+
+const cloneData = (value) => {
+ try {
+ return structuredClone(value);
+ } catch {
+ return JSON.parse(JSON.stringify(value));
+ }
+};
+
const NodeContextMenuContext = createContext({
onDeleteNode: noop,
onContextMenuAction: noop,
@@ -164,7 +175,7 @@ const NodeContextMenuProvider = ({ value, children }) => {
const withNodeContextMenu = (NodeComponent) => {
const WrappedNode = (props) => {
const { t } = useTranslation();
- const { onDeleteNode, onContextMenuAction } = useNodeContextMenu();
+ const { onDeleteNode, onContextMenuAction, onPreviewFromNode } = useNodeContextMenu();
const isPlaceholder = props.type === 'placeholderNode';
const canDelete = !isPlaceholder && props.id !== START_NODE_ID;
@@ -196,6 +207,17 @@ const withNodeContextMenu = (NodeComponent) => {
{t('common.delete')}
Del
+ {props.id !== START_NODE_ID && (
+ {
+ event.preventDefault();
+ onContextMenuAction?.();
+ onPreviewFromNode?.(props.id);
+ }}
+ >
+ {t('editor.preview.startFromHere')}
+
+ )}
);
@@ -341,6 +363,7 @@ function DialogueEditorPage() {
const [isCascadeDeleteOpen, setIsCascadeDeleteOpen] = useState(false);
const [isEdgeCascadeDeleteOpen, setIsEdgeCascadeDeleteOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
+ const [previewStartNodeId, setPreviewStartNodeId] = useState('');
const [previewActiveNodeRef, setPreviewActiveNodeRef] = useState(null);
const previewOriginRef = useRef(null);
const graphLoadKeyRef = useRef('');
@@ -1394,7 +1417,7 @@ function DialogueEditorPage() {
setPreviewActiveNodeRef(nodeRef);
}, [dialogueId]);
- const handleStartPreview = useCallback(() => {
+ const handleStartPreview = useCallback((startFromNodeId) => {
if (deviceType !== 'desktop') return;
const missingRequiredNodes = getMissingRequiredNodes();
@@ -1449,6 +1472,8 @@ function DialogueEditorPage() {
dialogueId,
nodeId: START_NODE_ID,
};
+ // The toolbar button passes a click event; only a string id means "start from this node".
+ setPreviewStartNodeId(typeof startFromNodeId === 'string' ? startFromNodeId : '');
setIsPreviewOpen(true);
}, [deviceType, dialogueId, getMissingRequiredNodes, nodes, edges, t]);
@@ -2003,6 +2028,110 @@ function DialogueEditorPage() {
redo();
}, [redo]);
+ // Copy selected nodes (multi-select with Shift-drag) and the edges between them.
+ const handleCopyNodes = useCallback(() => {
+ const picked = nodes.filter(
+ (n) => n.type !== 'placeholderNode' && n.id !== START_NODE_ID && (n.selected || n.id === selectedNodeId)
+ );
+ if (picked.length === 0) return false;
+ const ids = new Set(picked.map((n) => n.id));
+ const innerEdges = edges.filter(
+ (e) => ids.has(e.source) && ids.has(e.target) && !e.data?.isPlaceholder
+ );
+ nodeClipboard = {
+ projectId,
+ nodes: picked.map((n) => ({ id: n.id, type: n.type, position: { ...n.position }, data: cloneData(n.data) })),
+ edges: innerEdges.map((e) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle, targetHandle: e.targetHandle })),
+ pasteCount: 0,
+ };
+ return true;
+ }, [nodes, edges, selectedNodeId, projectId]);
+
+ // Paste: new ids everywhere, localization keys are regenerated on save, placed with an offset.
+ const handlePasteNodes = useCallback(() => {
+ if (!nodeClipboard || nodeClipboard.nodes.length === 0) return false;
+ nodeClipboard.pasteCount += 1;
+ const offset = 40 * nodeClipboard.pasteCount;
+ const idMap = new Map(nodeClipboard.nodes.map((n) => [n.id, uuidv4()]));
+
+ const newNodes = [];
+ const newPlaceholders = [];
+ const newEdges = [];
+ for (const source of nodeClipboard.nodes) {
+ const newId = idMap.get(source.id);
+ const data = cloneData(source.data);
+ delete data.displayNameKey;
+ delete data.selectionTitleKey;
+ delete data.localizationNodeToken;
+ if (nodeClipboard.projectId !== projectId) {
+ // Speakers and decorator definitions belong to the source project.
+ data.participant = '';
+ delete data.participantId;
+ data.decorators = [];
+ if (Array.isArray(data.dialogueRows)) data.dialogueRows = data.dialogueRows.map((row) => { const next = { ...row }; delete next.participantId; return next; });
+ }
+ if (Array.isArray(data.dialogueRows)) {
+ data.dialogueRows = data.dialogueRows.map((row) => {
+ const next = { ...row, id: uuidv4() };
+ delete next.textKey;
+ delete next.localizationRowToken;
+ return next;
+ });
+ }
+ if (typeof data.targetNode === 'string' && idMap.has(data.targetNode)) {
+ data.targetNode = idMap.get(data.targetNode);
+ }
+ newNodes.push({
+ id: newId,
+ type: source.type,
+ position: { x: source.position.x + offset, y: source.position.y + offset },
+ data,
+ selected: true,
+ });
+
+ if (getNodeDefinition(source.type)?.canHaveOutputs ?? true) {
+ const placeholderId = uuidv4();
+ newPlaceholders.push({
+ id: placeholderId,
+ type: 'placeholderNode',
+ data: { label: 'Add Node', onClick: handlePlaceholderClick, parentNodeId: newId },
+ position: { x: source.position.x + offset, y: source.position.y + offset + 250 },
+ });
+ newEdges.push({
+ id: `${newId}-${placeholderId}`,
+ source: newId,
+ target: placeholderId,
+ type: 'default',
+ style: { strokeWidth: 1, stroke: '#94a3b8', strokeDasharray: '5,5' },
+ data: { isPlaceholder: true },
+ });
+ }
+ }
+ for (const edge of nodeClipboard.edges) {
+ const source = idMap.get(edge.source);
+ const target = idMap.get(edge.target);
+ newEdges.push({
+ id: `${source}-${target}`,
+ source,
+ target,
+ sourceHandle: edge.sourceHandle,
+ targetHandle: edge.targetHandle,
+ type: 'default',
+ markerEnd: { type: MarkerType.ArrowClosed },
+ });
+ }
+
+ const nextNodes = [...nodes.map((n) => ({ ...n, selected: false })), ...newNodes, ...newPlaceholders];
+ const nextEdges = [...edges, ...newEdges];
+ setNodes(nextNodes);
+ setEdges(nextEdges);
+ saveToHistory(nextNodes, nextEdges);
+ markUnsaved();
+ setSelectedEdge(null);
+ setSelectedNodeId(newNodes.length === 1 ? newNodes[0].id : null);
+ return true;
+ }, [nodes, edges, projectId, setNodes, setEdges, saveToHistory, markUnsaved, handlePlaceholderClick]);
+
// Handle keyboard events
useEffect(() => {
const handleKeyDown = (event) => {
@@ -2010,6 +2139,29 @@ function DialogueEditorPage() {
return;
}
+ // Copy / paste nodes (not while typing, so text copy keeps working)
+ if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
+ const el = event.target;
+ const typing = el?.tagName === 'INPUT' || el?.tagName === 'TEXTAREA' || el?.isContentEditable;
+ const hasTextSelection = !!window.getSelection()?.toString();
+ const key = event.key.toLowerCase();
+ if (!typing && key === 'c' && !hasTextSelection) {
+ if (handleCopyNodes()) event.preventDefault();
+ return;
+ }
+ if (!typing && key === 'v') {
+ if (handlePasteNodes()) event.preventDefault();
+ return;
+ }
+ if (!typing && key === 'd') {
+ if (handleCopyNodes()) {
+ event.preventDefault();
+ handlePasteNodes();
+ }
+ return;
+ }
+ }
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
handleSave();
@@ -2072,6 +2224,8 @@ function DialogueEditorPage() {
handleSave,
handleUndo,
handleRedo,
+ handleCopyNodes,
+ handlePasteNodes,
isPreviewOpen,
]);
@@ -2345,8 +2499,9 @@ function DialogueEditorPage() {
() => ({
onDeleteNode: deleteNodeById,
onContextMenuAction: () => { lastContextMenuActionRef.current = Date.now(); },
+ onPreviewFromNode: (nodeId) => handleStartPreview(String(nodeId)),
}),
- [deleteNodeById]
+ [deleteNodeById, handleStartPreview]
);
if (!dialogue || !project) {
@@ -2499,6 +2654,7 @@ function DialogueEditorPage() {
edges={edges}
participants={participants}
rootDialogueId={dialogueId}
+ startNodeId={previewStartNodeId}
loadDialogueGraphForPreview={loadDialogueGraphForPreview}
onStop={handlePreviewStop}
onNodeFocus={handlePreviewNodeFocus}
diff --git a/tests/e2e/canonical-idempotence.spec.js b/tests/e2e/canonical-idempotence.spec.js
new file mode 100644
index 0000000..2e08e1e
--- /dev/null
+++ b/tests/e2e/canonical-idempotence.spec.js
@@ -0,0 +1,55 @@
+import { test, expect } from '@playwright/test';
+import { openModuleHarness } from './helpers/moduleHarness';
+
+test.beforeEach(async ({ page }) => { await openModuleHarness(page); });
+
+// A remote revision is accepted only if canonicalizing its snapshot reproduces its payload hash.
+// Localized entries are stamped while canonicalizing, so the result must not depend on the clock.
+test('canonicalizing a canonical snapshot keeps its hash', async ({ page }) => {
+ const result = await page.evaluate(async () => {
+ const { canonicalizeProject, hashProject } = await import('/src/lib/persistence/canonicalProject.js');
+ const START = '00000000-0000-0000-0000-000000000001', stamp = '2026-01-02T03:04:05.000Z';
+ const first = await canonicalizeProject({
+ project: { id: 'p1', name: 'P', createdAt: stamp, modifiedAt: stamp, localization: { defaultLocale: 'en', supportedLocales: ['en'] } },
+ dialogues: [{ id: 'd1', projectId: 'p1', name: 'D', createdAt: stamp, modifiedAt: stamp }],
+ nodes: [
+ { id: START, dialogueId: 'd1', type: 'startNode', position: { x: 0, y: 0 }, data: { displayName: 'Start' } },
+ { id: 'n1', dialogueId: 'd1', type: 'leadNode', position: { x: 0, y: 100 }, data: { displayName: 'Hello', dialogueRows: [{ id: 'r1', text: 'Hi there', duration: 3 }] } },
+ ],
+ edges: [{ id: 'e1', dialogueId: 'd1', source: START, target: 'n1' }],
+ });
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ const second = await canonicalizeProject(first);
+ const changed = structuredClone(first);
+ const entry = changed.localizedStrings.find((item) => item.values.en === 'Hello');
+ entry.values.en = 'Hello again';
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ const refreshed = await canonicalizeProject(changed);
+ return {
+ entries: first.localizedStrings.length,
+ stable: await hashProject(second) === await hashProject(first),
+ refreshed: refreshed.localizedStrings.find((item) => item.key === entry.key).values.en,
+ };
+ });
+ expect(result.entries).toBeGreaterThan(0);
+ expect(result.stable).toBe(true);
+ expect(result.refreshed).toBe('Hello again');
+});
+
+test('legacy property types are normalized before hashing, so validation does not change the snapshot', async ({ page }) => {
+ const result = await page.evaluate(async () => {
+ const { canonicalizeProject, hashProject, validateCanonicalProject } = await import('/src/lib/persistence/canonicalProject.js');
+ const stamp = '2026-01-02T03:04:05.000Z';
+ const snapshot = await canonicalizeProject({
+ project: { id: 'p2', name: 'P', createdAt: stamp, modifiedAt: stamp },
+ decorators: [{ id: 'dec1', projectId: 'p2', name: 'Item', properties: [{ name: 'on', type: 'bool', defaultValue: 'true' }, { name: 'count', type: 'int', defaultValue: 2 }] }],
+ });
+ const before = await hashProject(snapshot);
+ const problems = validateCanonicalProject(snapshot, { localization: false });
+ return { types: snapshot.decorators[0].properties.map((property) => property.type), flag: snapshot.decorators[0].properties[0].defaultValue, problems, unchanged: before === await hashProject(snapshot) };
+ });
+ expect(result.types).toEqual(['boolean', 'number']);
+ expect(result.flag).toBe(true);
+ expect(result.problems).toEqual([]);
+ expect(result.unchanged).toBe(true);
+});
diff --git a/tests/e2e/copypaste.spec.js b/tests/e2e/copypaste.spec.js
new file mode 100644
index 0000000..c64fd36
--- /dev/null
+++ b/tests/e2e/copypaste.spec.js
@@ -0,0 +1,35 @@
+import { test, expect } from '@playwright/test';
+import { seedLocalState, uniqueToken, openDashboard, createProject, openDialoguesSection, createDialogue } from './helpers/appHarness';
+
+test('nodes can be copied and pasted with the keyboard', async ({ page }) => {
+ await seedLocalState(page);
+ await openDashboard(page);
+ await createProject(page, uniqueToken('proj'));
+ await openDialoguesSection(page);
+ await createDialogue(page, uniqueToken('dlg'));
+
+ const toolbar = page.locator('[data-tour="node-toolbar"]');
+ await toolbar.locator('button.cursor-move').first().click();
+
+ const nodes = page.locator('.react-flow__node-leadNode');
+ await expect(nodes).toHaveCount(1);
+ await nodes.first().click();
+
+ await page.keyboard.press('Control+c');
+ await page.keyboard.press('Control+v');
+ await expect(nodes).toHaveCount(2);
+
+ await page.keyboard.press('Control+v');
+ await expect(nodes).toHaveCount(3);
+
+ // Duplicate (Ctrl+D) the selected node.
+ await page.keyboard.press('Control+d');
+ await expect(nodes).toHaveCount(4);
+
+ // Saving must not hit a localization key collision.
+ await page.keyboard.press('Control+s');
+ await page.waitForTimeout(1500);
+ await expect(page.getByText(/Failed to Save/i)).toHaveCount(0);
+ await page.reload();
+ await expect(page.locator('.react-flow__node-leadNode')).toHaveCount(4);
+});
diff --git a/tests/e2e/helpers/appHarness.js b/tests/e2e/helpers/appHarness.js
index 4047ab9..d85e35e 100644
--- a/tests/e2e/helpers/appHarness.js
+++ b/tests/e2e/helpers/appHarness.js
@@ -64,3 +64,61 @@ export async function openDialogueSettings(page) {
await expect(page).toHaveURL(/#\/projects\/[^/]+\/dialogue\/[^/]+\/settings\/?$/);
await expect(page.getByRole('heading', { name: /dialogue settings/i }).first()).toBeVisible();
}
+
+export const START_NODE_ID = '00000000-0000-0000-0000-000000000001';
+
+// Builds the graph of the currently open dialogue through the app's own stores (the same path the editor
+// uses), then reloads the editor. Needs the Vite dev server: modules are imported by path, so this does not
+// work against the production bundle.
+// `nodes`: [{ id, type, data }] (text is plain), `edges`: [[source, target]] where "start" is the Start node.
+// Every node gets participant "Tester" unless it sets its own.
+export async function seedGraph(page, { nodes = [], edges = [] }) {
+ await page.evaluate(
+ async ({ nodes: seedNodes, edges: seedEdges, startId }) => {
+ const [, projectId, dialogueId] = window.location.hash.match(/projects\/([^/]+)\/dialogue\/([^/?]+)/);
+ const { useCategoryStore } = await import('/src/stores/categoryStore.js');
+ const { useParticipantStore } = await import('/src/stores/participantStore.js');
+ const { useDialogueStore } = await import('/src/stores/dialogueStore.js');
+ const category = await useCategoryStore.getState().createCategory({ projectId, name: 'Folk' });
+ const participant = await useParticipantStore.getState().createParticipant({ projectId, name: 'Tester', category: category.name, categoryId: category.id });
+ const toNode = (node, index) => ({
+ position: { x: 300, y: 150 * (index + 1) },
+ ...node,
+ data: {
+ label: node.id,
+ displayName: node.id,
+ participant: participant.name,
+ participantId: participant.id,
+ decorators: [],
+ hasAudio: false,
+ ...node.data,
+ },
+ });
+ const start = {
+ id: startId,
+ type: 'startNode',
+ position: { x: 0, y: 0 },
+ deletable: false,
+ data: { label: 'Dialogue entry point', displayName: 'Start', decorators: [], dialogueRows: [], hasAudio: false },
+ };
+ const edges = seedEdges.map(([source, target]) => ({
+ id: `${source}-${target}`,
+ source: source === 'start' ? startId : source,
+ target,
+ type: 'conditionEdge',
+ data: { conditions: { mode: 'all', rules: [] } },
+ }));
+ await useDialogueStore.getState().saveDialogueGraph(dialogueId, [start, ...seedNodes.map(toNode)], edges, { x: 0, y: 0, zoom: 1 }, {});
+ },
+ { nodes, edges, startId: START_NODE_ID }
+ );
+ await page.reload();
+ await expect(page.locator('[data-tour="canvas"]')).toBeVisible();
+ // The editor shows a loading overlay for a moment; wait until the seeded graph is on screen.
+ await expect(page.locator('.react-flow__node').first()).toBeVisible();
+ await expect(page.getByText(/preparing dialogue graph/i)).toHaveCount(0, { timeout: 15000 });
+}
+
+export function textRow(id, text, duration = 5) {
+ return { id, text, duration };
+}
diff --git a/tests/e2e/previewFromNode.spec.js b/tests/e2e/previewFromNode.spec.js
new file mode 100644
index 0000000..f5399aa
--- /dev/null
+++ b/tests/e2e/previewFromNode.spec.js
@@ -0,0 +1,54 @@
+import { test, expect } from '@playwright/test';
+import {
+ seedLocalState,
+ uniqueToken,
+ openDashboard,
+ createProject,
+ openDialoguesSection,
+ createDialogue,
+ seedGraph,
+ textRow,
+} from './helpers/appHarness';
+
+// The preview line is the only whitespace-pre-wrap paragraph; the canvas nodes show the same text too.
+const previewLine = (page, text) => page.locator('p.whitespace-pre-wrap', { hasText: text });
+
+async function openGraph(page) {
+ await seedLocalState(page);
+ await openDashboard(page);
+ await createProject(page, uniqueToken('proj'));
+ await openDialoguesSection(page);
+ await createDialogue(page, uniqueToken('dlg'));
+ await seedGraph(page, {
+ nodes: [
+ { id: 'npc-one', type: 'leadNode', data: { dialogueRows: [textRow('r1', 'ALPHA first line')] } },
+ { id: 'npc-two', type: 'leadNode', data: { dialogueRows: [textRow('r2', 'BRAVO second line')] } },
+ ],
+ edges: [
+ ['start', 'npc-one'],
+ ['npc-one', 'npc-two'],
+ ],
+ });
+ await expect(page.locator('.react-flow__node-leadNode')).toHaveCount(2);
+}
+
+test('node context menu offers "Preview from here"', async ({ page }) => {
+ await openGraph(page);
+ await page.locator('.react-flow__node[data-id="npc-two"]').click({ button: 'right' });
+ await expect(page.getByRole('menuitem', { name: /preview from here/i })).toBeVisible();
+});
+
+test('"Preview from here" starts the preview at the chosen node', async ({ page }) => {
+ await openGraph(page);
+ await page.locator('.react-flow__node[data-id="npc-two"]').click({ button: 'right' });
+ await page.getByRole('menuitem', { name: /preview from here/i }).click();
+
+ await expect(previewLine(page, 'BRAVO second line')).toBeVisible();
+ await expect(previewLine(page, 'ALPHA first line')).toHaveCount(0);
+});
+
+test('regular Preview starts at the first node after Start', async ({ page }) => {
+ await openGraph(page);
+ await page.getByRole('button', { name: /^preview$/i }).first().click();
+ await expect(previewLine(page, 'ALPHA first line')).toBeVisible();
+});