From 08fd0a8c78faa0d50f1abafb86f59fc3430b2a27 Mon Sep 17 00:00:00 2001 From: thundertone Date: Sat, 17 Oct 2026 18:24:30 +0000 Subject: [PATCH 1/3] Make canonicalizing a canonical project a fixed point Canonicalizing an already canonical snapshot re-stamped the timestamps of localized entries and added keyType only on the second pass, and legacy property types (bool, int, float) were normalized only while validating. A revision's payload hash therefore differed after a round trip and other devices reported it as CORRUPT_REMOTE. Unchanged entries keep their timestamps, entries go through normalizeLocalizedStringEntry, and decorator/condition property types are normalized before hashing. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01U2LYX8SVLgzrkJt4GiVG1P --- .../persistence/canonicalIdempotence.test.js | 51 ++++++++++++++++++ src/lib/persistence/canonicalProject.js | 25 ++++++++- tests/e2e/copypaste.spec.js | 35 ++++++++++++ tests/e2e/previewFromNode.spec.js | 54 +++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/lib/persistence/canonicalIdempotence.test.js create mode 100644 tests/e2e/copypaste.spec.js create mode 100644 tests/e2e/previewFromNode.spec.js diff --git a/src/lib/persistence/canonicalIdempotence.test.js b/src/lib/persistence/canonicalIdempotence.test.js new file mode 100644 index 0000000..7b7807e --- /dev/null +++ b/src/lib/persistence/canonicalIdempotence.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { canonicalizeProject, hashProject, validateCanonicalProject } from './canonicalProject'; + +// A remote revision is accepted only if canonicalizing its snapshot reproduces its payload hash. +// Localized entries are stamped by prepareLocalizedNodesAndEntries, so this must not depend on the clock. +describe('canonicalizeProject', () => { + const START = '00000000-0000-0000-0000-000000000001'; + const stamp = '2026-01-02T03:04:05.000Z'; + const input = { + 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' }], + }; + + it('is idempotent: a canonical snapshot keeps its hash when canonicalized again later', async () => { + const first = await canonicalizeProject(input); + expect(first.localizedStrings.length).toBeGreaterThan(0); + await new Promise((resolve) => setTimeout(resolve, 25)); + const second = await canonicalizeProject(first); + expect(await hashProject(second)).toBe(await hashProject(first)); + }); + + it('still refreshes entries whose text changed', async () => { + const first = await canonicalizeProject(input); + const changed = structuredClone(first); + const entry = changed.localizedStrings.find((e) => e.values.en === 'Hello'); + entry.values.en = 'Hello again'; + await new Promise((resolve) => setTimeout(resolve, 25)); + const second = await canonicalizeProject(changed); + expect(second.localizedStrings.find((e) => e.key === entry.key).values.en).toBe('Hello again'); + }); +}); + +describe('legacy property types', () => { + it('normalizes legacy property types before hashing, so validation does not change the snapshot', async () => { + 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 }] }], + }); + expect(snapshot.decorators[0].properties.map((property) => property.type)).toEqual(['boolean', 'number']); + expect(snapshot.decorators[0].properties[0].defaultValue).toBe(true); + const hashBefore = await hashProject(snapshot); + expect(validateCanonicalProject(snapshot, { localization: false })).toEqual([]); + expect(await hashProject(snapshot)).toBe(hashBefore); // validation does not change the snapshot + }); +}); 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/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/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(); +}); From 9ddf63b3ecbbc68978469183e382c4b9200f7f07 Mon Sep 17 00:00:00 2001 From: thundertone Date: Sat, 17 Oct 2026 18:24:30 +0000 Subject: [PATCH 2/3] Copy, paste and duplicate nodes; preview from a node; clearer preview options - Ctrl/Cmd+C, V and D copy, paste and duplicate the selected nodes and the edges between them. Pasted nodes and rows get new ids and localization keys. Pasting into another project clears speakers and decorators, which belong to the source project. - Right-click a node, "Preview from here" starts the preview at that node. - Preview options show the action label and the line the player then says; options that end the conversation are listed last. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01U2LYX8SVLgzrkJt4GiVG1P --- .../dialogue/DialoguePreviewOverlay.jsx | 45 +++-- src/i18n/locales/cs.json | 3 + src/i18n/locales/de.json | 3 + src/i18n/locales/en.json | 1 + src/i18n/locales/es.json | 3 + src/i18n/locales/fr.json | 3 + src/i18n/locales/pl.json | 3 + .../$projectId/dialogue/$dialogueId/index.jsx | 162 +++++++++++++++++- tests/e2e/helpers/appHarness.js | 58 +++++++ 9 files changed, 267 insertions(+), 14 deletions(-) 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/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/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 }; +} From 13d56533b854481f96c0f31354fc4f187a5f5f8f Mon Sep 17 00:00:00 2001 From: thundertone Date: Sat, 17 Oct 2026 18:43:06 +0000 Subject: [PATCH 3/3] Test canonicalization as a Playwright module test The project has no unit test runner; use the module harness like the other regression specs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01U2LYX8SVLgzrkJt4GiVG1P --- .../persistence/canonicalIdempotence.test.js | 51 ----------------- tests/e2e/canonical-idempotence.spec.js | 55 +++++++++++++++++++ 2 files changed, 55 insertions(+), 51 deletions(-) delete mode 100644 src/lib/persistence/canonicalIdempotence.test.js create mode 100644 tests/e2e/canonical-idempotence.spec.js diff --git a/src/lib/persistence/canonicalIdempotence.test.js b/src/lib/persistence/canonicalIdempotence.test.js deleted file mode 100644 index 7b7807e..0000000 --- a/src/lib/persistence/canonicalIdempotence.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { canonicalizeProject, hashProject, validateCanonicalProject } from './canonicalProject'; - -// A remote revision is accepted only if canonicalizing its snapshot reproduces its payload hash. -// Localized entries are stamped by prepareLocalizedNodesAndEntries, so this must not depend on the clock. -describe('canonicalizeProject', () => { - const START = '00000000-0000-0000-0000-000000000001'; - const stamp = '2026-01-02T03:04:05.000Z'; - const input = { - 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' }], - }; - - it('is idempotent: a canonical snapshot keeps its hash when canonicalized again later', async () => { - const first = await canonicalizeProject(input); - expect(first.localizedStrings.length).toBeGreaterThan(0); - await new Promise((resolve) => setTimeout(resolve, 25)); - const second = await canonicalizeProject(first); - expect(await hashProject(second)).toBe(await hashProject(first)); - }); - - it('still refreshes entries whose text changed', async () => { - const first = await canonicalizeProject(input); - const changed = structuredClone(first); - const entry = changed.localizedStrings.find((e) => e.values.en === 'Hello'); - entry.values.en = 'Hello again'; - await new Promise((resolve) => setTimeout(resolve, 25)); - const second = await canonicalizeProject(changed); - expect(second.localizedStrings.find((e) => e.key === entry.key).values.en).toBe('Hello again'); - }); -}); - -describe('legacy property types', () => { - it('normalizes legacy property types before hashing, so validation does not change the snapshot', async () => { - 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 }] }], - }); - expect(snapshot.decorators[0].properties.map((property) => property.type)).toEqual(['boolean', 'number']); - expect(snapshot.decorators[0].properties[0].defaultValue).toBe(true); - const hashBefore = await hashProject(snapshot); - expect(validateCanonicalProject(snapshot, { localization: false })).toEqual([]); - expect(await hashProject(snapshot)).toBe(hashBefore); // validation does not change the snapshot - }); -}); 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); +});