From b3c60e60e551257119fee0df8c08bc373f19f539 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 00:26:03 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20Flowchart=20QA=20issues=20(T9:=20FC-01?= =?UTF-8?q?=E2=80=9306,=20PF-03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FC-01: the canvas controls, minimap, attribution and edge labels follow the theme. On phones the minimap is hidden, the controls sit in a row under the canvas, and the toolbar shows icons only. The attribution moves to the top right. Node and badge colours pass 4.5:1 contrast. FC-02: edge labels follow the language, including charts saved with the old English labels, and the attribution link has a localized name. FC-03: starter node text is stored with its translation key and shown in the current language. Text the student writes is never translated. FC-04: "Build from current case" maps the thesis, contentions with evidence, predicted attacks and counters onto the canvas, from a case in the same debate. Switching debates puts the template back. Generate's import uses the same mapping and the same debate check, and a case restored by Undo is not sent to the Flowchart or written over a recorded row. The Save dialog asks for a flowchart title. FC-05: an empty node label or save title shows an inline message. FC-06: each new chart fits the canvas with padding, and the chart refits when the canvas width changes. PF-03: no Flowchart leak; the growth comes from the delayed Jam recorder iframe. Layout reads on the page now come from a ResizeObserver. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 2 +- .../features/case-generation.md | 2 + .../recipes/case-generation.spec.ts | 30 ++ src/index.css | 42 ++ src/lib/flowchart.ts | 289 ++++++++++ src/locales/en.json | 20 +- src/locales/es.json | 20 +- src/locales/hu.json | 20 +- src/pages/FlowchartPage.tsx | 506 +++++++++++------- src/pages/GeneratePage.tsx | 127 +---- src/test/lib/flowchart.test.ts | 105 ++++ src/test/pages/FlowchartPage.test.tsx | 94 ++++ src/test/pages/GeneratePage.test.tsx | 71 ++- 13 files changed, 998 insertions(+), 330 deletions(-) create mode 100644 src/lib/flowchart.ts create mode 100644 src/test/lib/flowchart.test.ts create mode 100644 src/test/pages/FlowchartPage.test.tsx diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 340ad2948..1a05678d6 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -40,7 +40,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- ## Features - [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), and a second account on the same browser (`recipes/auth.spec.ts`). -- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`). +- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, building a flowchart from the case, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`). - [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`). - [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, and with web search (`recipes/ai-tools.spec.ts`). - [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index 15b9a2727..c2dfbdfea 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -11,6 +11,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). +- `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per contention (claim and evidence), the predicted attacks, and the counters, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. ## How to get to it (user POV) @@ -33,6 +34,7 @@ Preconditions: - **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. - **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily Access Paused`. `fake.requests()` has no `generate` entry. - **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). +- **Flowchart.** After generating in the debate, open `/app/flowchart` and click the button named `Build from current case` (Hungarian: `Létrehozás a jelenlegi esetből`). It appears only when the case belongs to the Current Debate, or to one-off work while no debate is chosen. The contention claims are visible, and an edge is a button named `From Thesis to Argument 1`. - **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes. ## Gotchas diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index fa85e2f08..e09c0baf8 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -119,6 +119,36 @@ test("gen-save: a one-off case is saved under a title and reopens from History o await reopenFromHistory(browser, user, title, output.caseStructure.thesis, evidence, "02", "one-off"); }); +test("gen-flowchart: the Flowchart builds from the Current Debate's case, and its app text follows the language", async ({ signedIn: page, evidence }) => { + const { output } = await generate(page); + await page.goto("/app/flowchart"); + // The template stays the blank start; the case is one click away. + await expect(page.getByText("Your Thesis", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Build from current case" }).click(); + await expect(page.getByText(output.caseStructure.thesis).first()).toBeVisible(); + for (const contention of output.contentions) await expect(page.getByText(contention.claim).first()).toBeVisible(); + await expect(page.getByText("Your Thesis", { exact: true })).toHaveCount(0); + // Every node fits the canvas once it is built. + await expect.poll(() => page.evaluate(() => { + const canvas = document.querySelector(".react-flow")!.getBoundingClientRect(); + return [...document.querySelectorAll(".react-flow__node")].filter((node) => { + const box = node.getBoundingClientRect(); + return box.left < canvas.left - 1 || box.right > canvas.right + 1 || box.top < canvas.top - 1 || box.bottom > canvas.bottom + 1; + }).length; + })).toBe(0); + await expect(page.getByRole("button", { name: "From Thesis to Argument 1" })).toHaveCount(1); + await evidence.shot("01-built-from-case"); + + // Node titles the app wrote follow the language on the same canvas; the case's own text does not change. + await page.getByRole("button", { name: "Language", exact: true }).click(); + await page.getByRole("radiogroup", { name: "Interface language" }).getByRole("radio", { name: "Magyar" }).click(); + await page.keyboard.press("Escape"); + await expect(page.getByText("1. érv", { exact: true })).toBeVisible(); + await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(0); + await expect(page.getByText(output.caseStructure.thesis).first()).toBeVisible(); + await evidence.shot("02-built-from-case-hu"); +}); + test("gen-research: generation researches the web and cites what it found", async ({ signedIn: page, evidence }) => { const { body } = await generate(page); await evidence.json("01-generate-response", body); diff --git a/src/index.css b/src/index.css index a8998d9f7..22befb693 100644 --- a/src/index.css +++ b/src/index.css @@ -501,6 +501,48 @@ All colors MUST be HSL. transition: transform 250ms var(--motion-ease-out-strong); } +/* React Flow's controls, minimap, attribution, and edge labels are white in its own + stylesheet; here they follow the theme. Its stylesheet loads after this one, so + each rule carries the extra .react-flow class. */ +.react-flow .react-flow__controls { + box-shadow: 0 0 0 1px hsl(var(--border)); +} + +.react-flow .react-flow__controls-button { + background: hsl(var(--card)); + color: hsl(var(--card-foreground)); + border-bottom-color: hsl(var(--border)); +} + +.react-flow .react-flow__controls-button:hover { + background: hsl(var(--accent)); +} + +.react-flow .react-flow__minimap { + background-color: hsl(var(--card)); + box-shadow: 0 0 0 1px hsl(var(--border)); +} + +.react-flow .react-flow__minimap-mask { + fill: hsl(var(--muted) / 0.6); +} + +.react-flow .react-flow__attribution { + background: hsl(var(--card) / 0.8); +} + +.react-flow .react-flow__attribution a { + color: hsl(var(--muted-foreground)); +} + +.react-flow .react-flow__edge-textbg { + fill: hsl(var(--card)); +} + +.react-flow .react-flow__edge-text { + fill: hsl(var(--card-foreground)); +} + /* React Flow's 26px canvas controls grow to touch size on phones. Its stylesheet loads after this one with a content-box 16px + padding, so this needs the extra class. */ @media (pointer: coarse) { diff --git a/src/lib/flowchart.ts b/src/lib/flowchart.ts new file mode 100644 index 000000000..d9cede990 --- /dev/null +++ b/src/lib/flowchart.ts @@ -0,0 +1,289 @@ +// src/lib/flowchart.ts +import { MarkerType, type Edge, type Node } from 'reactflow'; +import type { TFunction } from 'i18next'; +import i18n from '@/i18n'; +import { APP_LANGUAGES, type AppLanguage } from '@/lib/languages'; +import type { GenerateOutput } from '@/lib/ai/schemas/generate'; +import type { FlowchartData } from '@/lib/stores/session-store'; + +export type NodeType = 'thesis' | 'contention' | 'attack' | 'rebuttal' | 'dropped'; +export type EdgeKind = 'support' | 'attack' | 'rebuttal'; + +export interface FlowNodeData { + label: string; + description?: string; + type: NodeType; + strength?: 'strong' | 'moderate' | 'weak'; + /** + * Text the app wrote rather than the student, such as the starter nodes. It is + * kept as a translation key and shown in the current language; `label` and + * `description` hold the text in the language it was created in, as a fallback. + * Null marks the student's own text, which is never translated. Absent means a + * chart saved before this was recorded. + */ + labelKey?: string | null; + labelNumber?: number; + descriptionKey?: string | null; +} + +// Only these keys are ever translated, so a saved chart cannot name arbitrary catalog text. +const APP_TEXT_KEYS = new Set([ + 'flowchart.starter.thesisLabel', + 'flowchart.starter.thesisDesc', + 'flowchart.starter.argument1Label', + 'flowchart.starter.argument1Desc', + 'flowchart.starter.argument2Label', + 'flowchart.starter.argument2Desc', + 'flowchart.starter.argument3Label', + 'flowchart.starter.argument3Desc', + 'flowchart.starter.opponentAttackLabel', + 'flowchart.starter.opponentAttackDesc', + 'flowchart.starter.rebuttalLabel', + 'flowchart.starter.rebuttalDesc', + 'flowchart.fromCase.thesis', + 'flowchart.fromCase.argument', + 'flowchart.fromCase.attack', + 'flowchart.fromCase.rebuttal', +]); + +type AppText = { key: string; n?: number }; + +// Charts saved before nodes recorded their keys hold the starter text in the language +// they were made in, and Generate's old import wrote English titles such as "Argument 2". +let starterTexts: Map | undefined; +const OLD_IMPORT_TITLE = /^(Argument|Attack|Rebuttal) (\d+)$/; +function savedAppText(text: string | undefined, titles: boolean): AppText | undefined { + if (!text) return undefined; + if (!starterTexts) { + starterTexts = new Map(); + for (const language of Object.keys(APP_LANGUAGES) as AppLanguage[]) { + const fixed = i18n.getFixedT(language); + for (const key of APP_TEXT_KEYS) if (key.startsWith('flowchart.starter.')) starterTexts.set(String(fixed(key)), key); + } + } + const starter = starterTexts.get(text); + if (starter) return { key: starter }; + if (!titles) return undefined; + if (text === 'Thesis') return { key: 'flowchart.fromCase.thesis' }; + const numbered = OLD_IMPORT_TITLE.exec(text); + return numbered ? { key: `flowchart.fromCase.${numbered[1].toLowerCase()}`, n: Number(numbered[2]) } : undefined; +} + +// A key counts only while the stored text is still that key's text in some language: +// an app version that predates keys edits the text and leaves the key behind. +function keptAppText(key: string | null, n: number | undefined, text: string | undefined): AppText | undefined { + if (!key || !APP_TEXT_KEYS.has(key)) return undefined; + const languages = Object.keys(APP_LANGUAGES) as AppLanguage[]; + return languages.some((language) => String(i18n.getFixedT(language)(key, { n })) === text) ? { key, n } : undefined; +} + +function labelText(data: FlowNodeData): AppText | undefined { + if (data.labelKey === undefined) return savedAppText(data.label, true); + return keptAppText(data.labelKey, data.labelNumber, data.label); +} + +function descriptionText(data: FlowNodeData): AppText | undefined { + if (data.descriptionKey === undefined) return savedAppText(data.description, false); + return keptAppText(data.descriptionKey, undefined, data.description); +} + +/** A node the student wrote: its text stays as typed in every language. */ +export const studentNodeData = (data: Omit): FlowNodeData => + ({ ...data, labelKey: null, descriptionKey: null }); + +const translate = (text: AppText | undefined, t: TFunction): string | undefined => + text ? String(t(text.key, { n: text.n })) : undefined; + +export const nodeLabel = (data: FlowNodeData, t: TFunction): string => + translate(labelText(data), t) ?? data.label; + +export const nodeDescription = (data: FlowNodeData, t: TFunction): string => + translate(descriptionText(data), t) ?? data.description ?? ''; + +/** Applies an edit; app text the student left unchanged stays app text. */ +export function editNodeData(data: FlowNodeData, edit: Omit, t: TFunction): FlowNodeData { + const label = edit.label === nodeLabel(data, t) ? labelText(data) : undefined; + const description = (edit.description ?? '') === nodeDescription(data, t) ? descriptionText(data) : undefined; + return { + ...studentNodeData(edit), + ...(label ? { labelKey: label.key, ...(label.n !== undefined ? { labelNumber: label.n } : {}), label: data.label } : {}), + ...(description ? { descriptionKey: description.key, description: data.description } : {}), + }; +} + +const EDGE_LABEL_KEYS: Record = { + support: 'flowchart.edgeSupport', + attack: 'flowchart.edgeAttack', + rebuttal: 'flowchart.edgeRebuttal', +}; + +// Charts saved before edges recorded their kind carry these English labels. +const LEGACY_EDGE_LABELS: Record = { Support: 'support', Attack: 'attack', Rebuttal: 'rebuttal' }; + +/** An edge's label in the current language, from its kind. */ +export function edgeLabel(edge: Edge, t: TFunction): Edge['label'] { + const kind: EdgeKind | undefined = (edge.data as { kind?: EdgeKind } | undefined)?.kind + ?? (typeof edge.label === 'string' ? LEGACY_EDGE_LABELS[edge.label] : undefined); + return kind && kind in EDGE_LABEL_KEYS ? String(t(EDGE_LABEL_KEYS[kind])) : edge.label; +} + +const EDGE_STYLES: Record = { + support: { stroke: '#10b981' }, + attack: { stroke: '#ef4444' }, + // Rebuttals are dashed so they read apart from support without motion. + rebuttal: { stroke: '#3b82f6', dash: '6 4' }, +}; + +export function edgeOfKind(kind: EdgeKind, source: string, target: string, t: TFunction): Edge { + const style = EDGE_STYLES[kind]; + return { + id: `e-${source}-${target}`, + source, + target, + label: String(t(EDGE_LABEL_KEYS[kind])), + data: { kind }, + style: { stroke: style.stroke, strokeWidth: 2, ...(style.dash ? { strokeDasharray: style.dash } : {}) }, + markerEnd: { type: MarkerType.ArrowClosed }, + }; +} + +function appNode(id: string, x: number, y: number, type: NodeType, t: TFunction, text: { + labelKey: string; labelNumber?: number; descriptionKey?: string; description?: string; strength?: FlowNodeData['strength']; +}): Node { + return { + id, + type: 'custom', + position: { x, y }, + data: { + type, + label: String(t(text.labelKey, { n: text.labelNumber })), + labelKey: text.labelKey, + ...(text.labelNumber !== undefined ? { labelNumber: text.labelNumber } : {}), + description: text.descriptionKey ? String(t(text.descriptionKey)) : text.description, + // The case's own text is the student's, not the app's. + descriptionKey: text.descriptionKey ?? null, + ...(text.strength ? { strength: text.strength } : {}), + }, + }; +} + +/** The blank-start template. */ +export function starterFlowchart(t: TFunction): FlowchartData { + const starter = (name: string) => ({ labelKey: `flowchart.starter.${name}Label`, descriptionKey: `flowchart.starter.${name}Desc` }); + return { + nodes: [ + appNode('thesis', 400, 50, 'thesis', t, starter('thesis')), + appNode('c1', 150, 200, 'contention', t, { ...starter('argument1'), strength: 'strong' }), + appNode('c2', 400, 200, 'contention', t, { ...starter('argument2'), strength: 'moderate' }), + appNode('c3', 650, 200, 'contention', t, { ...starter('argument3'), strength: 'weak' }), + appNode('a1', 150, 350, 'attack', t, starter('opponentAttack')), + appNode('r1', 150, 500, 'rebuttal', t, starter('rebuttal')), + ], + edges: [ + { id: 'e-thesis-c1', source: 'thesis', target: 'c1', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-thesis-c2', source: 'thesis', target: 'c2', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-thesis-c3', source: 'thesis', target: 'c3', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-c1-a1', source: 'c1', target: 'a1', style: { stroke: EDGE_STYLES.attack.stroke }, markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-a1-r1', source: 'a1', target: 'r1', style: { stroke: EDGE_STYLES.rebuttal.stroke, strokeDasharray: EDGE_STYLES.rebuttal.dash }, markerEnd: { type: MarkerType.ArrowClosed } }, + ], + }; +} + +/** A generated case has what a flowchart needs: a thesis and at least one contention. */ +export const caseHasFlowchart = (result: GenerateOutput | null | undefined): result is GenerateOutput => + Boolean(result?.caseStructure?.thesis && result.contentions?.length); + +const NODE_GAP_X = 300; +const ROW_GAP_Y = 150; +const PER_ROW = 4; + +// Each tier wraps into rows of four, so a large case still fits a phone at a readable zoom. +function placeTier(count: number, top: number) { + const centerX = 400; + const position = (i: number) => { + const row = Math.floor(i / PER_ROW); + const inRow = Math.min(PER_ROW, count - row * PER_ROW); + return { x: centerX + ((i % PER_ROW) - (inRow - 1) / 2) * NODE_GAP_X, y: top + row * ROW_GAP_Y }; + }; + return { position, bottom: top + Math.max(1, Math.ceil(count / PER_ROW)) * ROW_GAP_Y + 50 }; +} + +const words = (text: string) => new Set(text.toLowerCase().match(/\p{L}{4,}/gu) ?? []); + +/** + * The predicted argument a counter answers: the one its target quotes, else the one + * sharing the most words with it, else the argument at the same position. + */ +function answeredAttack(target: string | undefined, attacks: string[], index: number): number { + const wanted = target?.trim().toLowerCase() ?? ''; + const candidates = attacks.map((argument, i) => ({ i, text: argument.trim().toLowerCase() })).filter(({ text }) => text); + if (wanted) { + const exact = candidates.find(({ text }) => text === wanted) + ?? candidates.find(({ text }) => wanted.length >= 12 && (text.includes(wanted) || (text.length >= 12 && wanted.includes(text)))); + if (exact) return exact.i; + const targetWords = words(wanted); + // Two shared words, so one filler word such as "their" decides nothing. + let best = { i: -1, shared: 1 }; + for (const { i, text } of candidates) { + const shared = [...words(text)].filter((word) => targetWords.has(word)).length; + if (shared > best.shared) best = { i, shared }; + } + if (best.i >= 0) return best.i; + } + return attacks[index]?.trim() ? index : -1; +} + +/** + * Maps a generated case onto the canvas: the thesis, one node per contention with + * its claim and evidence, the predicted opposing arguments, and the counters. + */ +export function flowchartFromCase(result: GenerateOutput, t: TFunction): FlowchartData { + const nodes: Node[] = []; + const edges: Edge[] = []; + + nodes.push(appNode('thesis', 400, 50, 'thesis', t, { labelKey: 'flowchart.fromCase.thesis', description: result.caseStructure.thesis })); + + const contentions = result.contentions; + const contentionTier = placeTier(contentions.length, 230); + contentions.forEach((contention, i) => { + const id = `c-${i}`; + const { x, y } = contentionTier.position(i); + nodes.push(appNode(id, x, y, 'contention', t, { + labelKey: 'flowchart.fromCase.argument', + labelNumber: i + 1, + description: [contention.claim, contention.evidence].filter((part) => part?.trim()).join('\n'), + strength: 'strong', + })); + edges.push({ id: `e-thesis-${id}`, source: 'thesis', target: id, markerEnd: { type: MarkerType.ArrowClosed } }); + }); + + const attacks = result.predictedOpponentCase?.likelyArguments ?? []; + const attackTier = placeTier(attacks.length, contentionTier.bottom); + attacks.forEach((argument, i) => { + const id = `a-${i}`; + const { x, y } = attackTier.position(i); + nodes.push(appNode(id, x, y, 'attack', t, { + labelKey: 'flowchart.fromCase.attack', + labelNumber: i + 1, + description: argument, + })); + edges.push(edgeOfKind('attack', contentions.length ? `c-${i % contentions.length}` : 'thesis', id, t)); + }); + + // A counter answers one of their arguments, so it hangs off that attack. + const counters = result.counters ?? []; + const counterTier = placeTier(counters.length, attackTier.bottom); + counters.forEach((counter, i) => { + const id = `r-${i}`; + const attackIndex = answeredAttack(counter.targetArgument, attacks, i); + const { x, y } = counterTier.position(i); + nodes.push(appNode(id, x, y, 'rebuttal', t, { + labelKey: 'flowchart.fromCase.rebuttal', + labelNumber: i + 1, + description: counter.rebuttal || counter.counterpoint, + })); + edges.push(edgeOfKind('rebuttal', attackIndex >= 0 ? `a-${attackIndex}` : 'thesis', id, t)); + }); + + return { nodes, edges }; +} diff --git a/src/locales/en.json b/src/locales/en.json index ad12179f1..7c2544462 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -388,7 +388,8 @@ "undone": "Reverted", "undoneDesc": "Loaded previous generation.", "saveFailedDesc": "The case could not be saved. Please try again.", - "working": "Writing your case…" + "working": "Writing your case…", + "flowchartOtherDebate": "The Flowchart takes only a case generated in the current debate. Generate the case here to send it." }, "attack": { "emptyTitle": "No Mercy Mode", @@ -1527,7 +1528,7 @@ "typeField": "Type", "strengthField": "Strength", "labelPlaceholder": "Short label...", - "titlePlaceholder": "Node title...", + "titlePlaceholder": "Flowchart title...", "descPlaceholder": "Description...", "typeArgument": "Argument", "typeAttack": "Attack", @@ -1578,7 +1579,20 @@ "deletedNamed": "Deleted {{title}}", "deleteNamed": "Delete {{title}}", "restoreFailed": "Could not restore the flowchart", - "nodeDeletedNamed": "Deleted {{label}}" + "nodeDeletedNamed": "Deleted {{label}}", + "titleRequired": "Add a title.", + "labelRequired": "Add a label.", + "buildFromCase": "Build from current case", + "builtFromCase": "Built from your current case", + "canvasControls": "Canvas controls", + "attribution": "React Flow attribution", + "fromCase": { + "thesis": "Thesis", + "argument": "Argument {{n}}", + "attack": "Attack {{n}}", + "rebuttal": "Rebuttal {{n}}" + }, + "resetForDebate": "You switched debates, so the flowchart went back to the template. Unsaved changes were not kept." }, "historyPage": { "title": "History", diff --git a/src/locales/es.json b/src/locales/es.json index 2168cca84..37209cf01 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -388,7 +388,8 @@ "undone": "Cambio deshecho", "undoneDesc": "Se cargó la generación anterior.", "saveFailedDesc": "No se pudo guardar el caso. Inténtalo de nuevo.", - "working": "Escribiendo tu caso…" + "working": "Escribiendo tu caso…", + "flowchartOtherDebate": "El diagrama solo acepta un caso generado en el debate actual. Genera el caso aquí para enviarlo." }, "attack": { "emptyTitle": "Modo sin piedad", @@ -1527,7 +1528,7 @@ "typeField": "Tipo", "strengthField": "Fuerza", "labelPlaceholder": "Etiqueta corta...", - "titlePlaceholder": "Título del nodo...", + "titlePlaceholder": "Título del diagrama...", "descPlaceholder": "Descripción...", "typeArgument": "Argumento", "typeAttack": "Ataque", @@ -1578,7 +1579,20 @@ "deletedNamed": "Se eliminó {{title}}", "deleteNamed": "Eliminar {{title}}", "restoreFailed": "No se pudo restaurar el diagrama", - "nodeDeletedNamed": "Se eliminó {{label}}" + "nodeDeletedNamed": "Se eliminó {{label}}", + "titleRequired": "Añade un título.", + "labelRequired": "Añade una etiqueta.", + "buildFromCase": "Crear desde el caso actual", + "builtFromCase": "Creado a partir de tu caso actual", + "canvasControls": "Controles del lienzo", + "attribution": "Atribución de React Flow", + "fromCase": { + "thesis": "Tesis", + "argument": "Argumento {{n}}", + "attack": "Ataque {{n}}", + "rebuttal": "Refutación {{n}}" + }, + "resetForDebate": "Cambiaste de debate, así que el diagrama volvió a la plantilla. Los cambios sin guardar no se conservaron." }, "historyPage": { "title": "Historial", diff --git a/src/locales/hu.json b/src/locales/hu.json index abc15bf14..a8ce56444 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -388,7 +388,8 @@ "undone": "Visszaállítva", "undoneDesc": "Az előző generálás betöltve.", "saveFailedDesc": "Nem sikerült menteni az esetet. Próbáld újra.", - "working": "Az eseted megírása…" + "working": "Az eseted megírása…", + "flowchartOtherDebate": "A folyamatábra csak a jelenlegi vitában létrehozott esetet fogadja. Hozd létre itt az esetet, hogy elküldhesd." }, "attack": { "emptyTitle": "Kegyetlen mód", @@ -1527,7 +1528,7 @@ "typeField": "Típus", "strengthField": "Erősség", "labelPlaceholder": "Rövid címke...", - "titlePlaceholder": "Csomópont címe...", + "titlePlaceholder": "A folyamatábra címe...", "descPlaceholder": "Leírás...", "typeArgument": "Érv", "typeAttack": "Támadás", @@ -1578,7 +1579,20 @@ "deletedNamed": "Törölve: {{title}}", "deleteNamed": "{{title}} törlése", "restoreFailed": "Nem sikerült visszaállítani a folyamatábrát", - "nodeDeletedNamed": "Törölve: {{label}}" + "nodeDeletedNamed": "Törölve: {{label}}", + "titleRequired": "Adj meg egy címet.", + "labelRequired": "Adj meg egy címkét.", + "buildFromCase": "Létrehozás a jelenlegi esetből", + "builtFromCase": "A folyamatábra elkészült a jelenlegi esetedből", + "canvasControls": "Vászonvezérlők", + "attribution": "React Flow forrásmegjelölés", + "fromCase": { + "thesis": "Tézis", + "argument": "{{n}}. érv", + "attack": "{{n}}. támadás", + "rebuttal": "{{n}}. cáfolat" + }, + "resetForDebate": "Vitát váltottál, ezért a folyamatábra visszaállt a sablonra. A nem mentett módosítások elvesztek." }, "historyPage": { "title": "Előzmények", diff --git a/src/pages/FlowchartPage.tsx b/src/pages/FlowchartPage.tsx index 73197a5cd..18c466afa 100644 --- a/src/pages/FlowchartPage.tsx +++ b/src/pages/FlowchartPage.tsx @@ -1,9 +1,8 @@ // src/pages/FlowchartPage.tsx -import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import ReactFlow, { Node, - Edge, Controls, ControlButton, Background, @@ -12,13 +11,14 @@ import ReactFlow, { useEdgesState, addEdge, Connection, - MarkerType, BackgroundVariant, Handle, Position, useReactFlow, useStore, useStoreApi, + ReactFlowProvider, + type FitViewOptions, type ReactFlowInstance, } from 'reactflow'; import 'reactflow/dist/style.css'; @@ -62,33 +62,37 @@ import { Minus, Maximize, Lock, - Unlock + Unlock, + FileInput, } from 'lucide-react'; import { toPng } from 'html-to-image'; import { formatRelativeDate } from '@/lib/date-format'; import { toast } from 'sonner'; -import type { TFunction } from 'i18next'; import { CurrentDebateStrip } from '@/components/features/debate/CurrentDebateStrip'; import { useDebateStore } from '@/lib/stores/debate-store'; import { WorkspaceToolScope, useWorkspaceToolScope } from '@/components/features/workspace/WorkspaceToolScope'; - -// Custom node types -type NodeType = 'thesis' | 'contention' | 'attack' | 'rebuttal' | 'dropped'; -type EdgeKind = 'support' | 'attack' | 'rebuttal'; - -interface FlowNodeData { - label: string; - description?: string; - type: NodeType; - strength?: 'strong' | 'moderate' | 'weak'; -} - +import { + caseHasFlowchart, + edgeLabel, + edgeOfKind, + editNodeData, + flowchartFromCase, + nodeDescription, + nodeLabel, + starterFlowchart, + studentNodeData, + type EdgeKind, + type FlowNodeData, + type NodeType, +} from '@/lib/flowchart'; + +// Shades dark enough for white text at 4.5:1. const nodeColors: Record = { thesis: { bg: 'bg-primary', border: 'border-primary', text: 'text-primary-foreground' }, - contention: { bg: 'bg-emerald-500', border: 'border-emerald-500', text: 'text-white' }, - attack: { bg: 'bg-red-500', border: 'border-red-500', text: 'text-white' }, - rebuttal: { bg: 'bg-blue-500', border: 'border-blue-500', text: 'text-white' }, - dropped: { bg: 'bg-amber-500', border: 'border-amber-500', text: 'text-white' }, + contention: { bg: 'bg-emerald-700', border: 'border-emerald-700', text: 'text-white' }, + attack: { bg: 'bg-red-700', border: 'border-red-700', text: 'text-white' }, + rebuttal: { bg: 'bg-blue-700', border: 'border-blue-700', text: 'text-white' }, + dropped: { bg: 'bg-amber-700', border: 'border-amber-700', text: 'text-white' }, }; const nodeIcons: Record = { @@ -105,12 +109,14 @@ const strengthTranslationKeys = { weak: 'flowchart.strengthWeak', }; -// React Flow 11 writes its keyboard help for nodes and edges in English, with no -// prop to change it (v12's ariaLabelConfig has one). That text is static, so -// React never writes it again after mount; the translated help takes its place. +// React Flow 11 writes its keyboard help for nodes and edges, and the name of its +// attribution link, in English, with no prop to change them (v12's ariaLabelConfig +// has one). That text is static, so React never writes it again after mount; the +// translated text takes its place. function LocalizedA11yDescriptions(): null { const { t } = useTranslation(); const rfId = useStore((state) => state.rfId); + const domNode = useStore((state) => state.domNode); useEffect(() => { const replace = (id: string, text: string) => { const element = document.getElementById(id); @@ -118,13 +124,14 @@ function LocalizedA11yDescriptions(): null { }; replace(`react-flow__node-desc-${rfId}`, t('flowchart.a11yNodeHelp')); replace(`react-flow__edge-desc-${rfId}`, t('flowchart.a11yEdgeHelp')); - }, [rfId, t]); + domNode?.querySelector('.react-flow__attribution a')?.setAttribute('aria-label', t('flowchart.attribution')); + }, [rfId, domNode, t]); return null; } // React Flow's own control buttons have fixed English names, so the canvas // renders the same four with translated ones. -function LocalizedControls() { +function useCanvasControls() { const { t } = useTranslation(); const { zoomIn, zoomOut, fitView } = useReactFlow(); const store = useStoreApi(); @@ -136,36 +143,70 @@ function LocalizedControls() { nodesConnectable: !isInteractive, elementsSelectable: !isInteractive, }); + return [ + { key: 'zoom-in', label: t('flowchart.zoomIn'), icon: , onClick: () => zoomIn(), disabled: maxZoomReached }, + { key: 'zoom-out', label: t('flowchart.zoomOut'), icon: , onClick: () => zoomOut(), disabled: minZoomReached }, + { key: 'fit', label: t('flowchart.fitView'), icon: , onClick: () => fitView(FIT_VIEW) }, + { key: 'lock', label: t('flowchart.toggleInteractivity'), icon: isInteractive ? : , onClick: toggleInteractivity, pressed: !isInteractive }, + ]; +} +// Over the canvas from the small breakpoint up. +function LocalizedControls() { + const controls = useCanvasControls(); return ( - - zoomIn()} disabled={maxZoomReached} title={t('flowchart.zoomIn')} aria-label={t('flowchart.zoomIn')}> - - - zoomOut()} disabled={minZoomReached} title={t('flowchart.zoomOut')} aria-label={t('flowchart.zoomOut')}> - - - fitView()} title={t('flowchart.fitView')} aria-label={t('flowchart.fitView')}> - - - - {isInteractive ? : } - + + {controls.map((control) => ( + + {control.icon} + + ))} ); } +// On phones the canvas is too narrow to float controls over it without covering +// nodes, so the same buttons sit in a row under it. +function CanvasControlsBar() { + const { t } = useTranslation(); + const controls = useCanvasControls(); + return ( +
+ {controls.map((control) => ( + + ))} +
+ ); +} + +const FIT_VIEW: FitViewOptions = { padding: 0.2 }; + +// Toolbar buttons show only their icon on phones; the text stays as their name. +const COMPACT_LABEL = 'sr-only sm:not-sr-only'; + // 12px handles get an invisible 44px hit area on touch. const HANDLE_CLASS = "!bg-white/50 !w-3 !h-3 !border-2 !border-white/80 pointer-coarse:after:absolute pointer-coarse:after:-inset-4 pointer-coarse:after:content-['']"; -// Edges are static; rebuttals are dashed so they read apart from support without motion. -const REBUTTAL_DASH = '6 4'; - // Custom node component function CustomNode({ data }: { data: FlowNodeData }) { const { t } = useTranslation(); @@ -184,22 +225,22 @@ function CustomNode({ data }: { data: FlowNodeData }) {
{nodeIcons[data.type]} - + {t(typeLabelKey)}
-
{data.label}
- {data.description && ( -
{data.description}
+
{nodeLabel(data, t)}
+ {nodeDescription(data, t) && ( +
{nodeDescription(data, t)}
)} {data.strength && ( {t(strengthTranslationKeys[data.strength])} @@ -214,62 +255,6 @@ const nodeTypes = { custom: CustomNode, }; -const edgeStyles: Record = { - support: { label: 'Support', stroke: '#10b981' }, - attack: { label: 'Attack', stroke: '#ef4444' }, - rebuttal: { label: 'Rebuttal', stroke: '#3b82f6', dash: REBUTTAL_DASH }, -}; - -// Initial sample data -function createInitialNodes(t: TFunction): Node[] { - return [ - { - id: 'thesis', - type: 'custom', - position: { x: 400, y: 50 }, - data: { label: t('flowchart.starter.thesisLabel'), description: t('flowchart.starter.thesisDesc'), type: 'thesis' }, - }, - { - id: 'c1', - type: 'custom', - position: { x: 150, y: 200 }, - data: { label: t('flowchart.starter.argument1Label'), description: t('flowchart.starter.argument1Desc'), type: 'contention', strength: 'strong' }, - }, - { - id: 'c2', - type: 'custom', - position: { x: 400, y: 200 }, - data: { label: t('flowchart.starter.argument2Label'), description: t('flowchart.starter.argument2Desc'), type: 'contention', strength: 'moderate' }, - }, - { - id: 'c3', - type: 'custom', - position: { x: 650, y: 200 }, - data: { label: t('flowchart.starter.argument3Label'), description: t('flowchart.starter.argument3Desc'), type: 'contention', strength: 'weak' }, - }, - { - id: 'a1', - type: 'custom', - position: { x: 150, y: 350 }, - data: { label: t('flowchart.starter.opponentAttackLabel'), description: t('flowchart.starter.opponentAttackDesc'), type: 'attack' }, - }, - { - id: 'r1', - type: 'custom', - position: { x: 150, y: 500 }, - data: { label: t('flowchart.starter.rebuttalLabel'), description: t('flowchart.starter.rebuttalDesc'), type: 'rebuttal' }, - }, - ]; -} - -const initialEdges: Edge[] = [ - { id: 'e-thesis-c1', source: 'thesis', target: 'c1', markerEnd: { type: MarkerType.ArrowClosed } }, - { id: 'e-thesis-c2', source: 'thesis', target: 'c2', markerEnd: { type: MarkerType.ArrowClosed } }, - { id: 'e-thesis-c3', source: 'thesis', target: 'c3', markerEnd: { type: MarkerType.ArrowClosed } }, - { id: 'e-c1-a1', source: 'c1', target: 'a1', style: { stroke: '#ef4444' }, markerEnd: { type: MarkerType.ArrowClosed } }, - { id: 'e-a1-r1', source: 'a1', target: 'r1', style: { stroke: '#3b82f6', strokeDasharray: REBUTTAL_DASH }, markerEnd: { type: MarkerType.ArrowClosed } }, -]; - export default function FlowchartPage() { const workspace = useWorkspaceToolScope('flowchart'); const { t } = useTranslation(); @@ -278,21 +263,30 @@ export default function FlowchartPage() { setPendingFlowchartData, saveFlowchart, savedFlowcharts: allSavedFlowcharts, - deleteSavedFlowchart + deleteSavedFlowchart, + generateResult, + generateResultDebateId, } = useSessionStore(); // Saved flowcharts follow the Current Debate; one-off mode shows one-off flowcharts. const flowchartScope = useDebateStore((state) => state.oneOff ? null : state.currentDebateId); const savedFlowcharts = allSavedFlowcharts.filter((item) => (item.debateId ?? null) === flowchartScope); - - const [nodes, setNodes, onNodesChange] = useNodesState(createInitialNodes(t)); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - // Screen readers name an edge by the nodes it joins, not by internal ids. - // Only the canvas gets these; saved charts keep plain edges. + // The case on the Generate page, when it belongs to the same debate as these flowcharts. + // A case restored before results recorded their debate is not offered. + const currentCase = !workspace.context && caseHasFlowchart(generateResult) && generateResultDebateId !== undefined && generateResultDebateId === flowchartScope + ? generateResult + : null; + + const [starter] = useState(() => starterFlowchart(t)); + const [nodes, setNodes, onNodesChange] = useNodesState(starter.nodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(starter.edges); + // Screen readers name an edge by the nodes it joins, not by internal ids, and edge + // labels follow the language. Only the canvas gets these; saved charts keep plain edges. // Keyed on ids and titles, so dragging a node does not rebuild every edge. - const nodeTitlesKey = JSON.stringify(nodes.map((node) => [node.id, (node.data as FlowNodeData | undefined)?.label ?? ''])); + const nodeTitlesKey = JSON.stringify(nodes.map((node) => [node.id, node.data ? nodeLabel(node.data, t) : ''])); const nodeTitles = useMemo(() => new Map(JSON.parse(nodeTitlesKey) as Array<[string, string]>), [nodeTitlesKey]); const labelledEdges = useMemo(() => edges.map((edge) => ({ ...edge, + label: edgeLabel(edge, t), ariaLabel: t('flowchart.edgeAriaLabel', { source: nodeTitles.get(edge.source) || edge.source, target: nodeTitles.get(edge.target) || edge.target, @@ -302,18 +296,58 @@ export default function FlowchartPage() { // Save/Load dialog states const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [saveTitle, setSaveTitle] = useState(''); + const [saveTitleError, setSaveTitleError] = useState(false); + const saveTitleRef = useRef(null); const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false); - // Node ids repeat across charts, so a node's Undo belongs to the chart it was deleted - // from: loading another chart retires that Undo. + // Each chart gets a fresh canvas, which fits it once its nodes have sizes. + const [chartKey, setChartKey] = useState(0); + const [flowInstance, setFlowInstance] = useState(null); + const flowInstanceRef = useRef(null); + flowInstanceRef.current = flowInstance; + // The canvas changing width (a rotated phone, a resized window) fits the chart again. + // A new chart remounts the canvas element, so the observer follows the key. + const canvasRef = useRef(null); + const canvasSize = useRef({ width: 0, height: 0 }); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + let frame = 0; + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + const widthChanged = canvasSize.current.width !== 0 && Math.round(canvasSize.current.width) !== Math.round(width); + canvasSize.current = { width, height }; + // Only width: a phone's browser bar changes the height while scrolling. + if (!widthChanged) return; + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => void flowInstanceRef.current?.fitView(FIT_VIEW)); + }); + observer.observe(canvas); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, [chartKey]); + + // Node ids repeat across charts, so an Undo (a deleted node, a built case) belongs to + // the chart it came from: loading another chart retires that Undo. const chartVersion = useRef(0); - const nodeUndoToast = useRef(null); - const replaceChart = useCallback((data: FlowchartData) => { + const undoToasts = useRef(new Set()); + // The debate (null: one-off work) a loaded, imported, or built chart belongs to; + // undefined while the canvas holds the template. + const chartScope = useRef(undefined); + const currentScope = useRef(flowchartScope); + useLayoutEffect(() => { + currentScope.current = flowchartScope; + }, [flowchartScope]); + const replaceChart = useCallback((data: FlowchartData, scope: string | null | undefined = currentScope.current) => { chartVersion.current += 1; - if (nodeUndoToast.current !== null) toast.dismiss(nodeUndoToast.current); - nodeUndoToast.current = null; + undoToasts.current.forEach((id) => toast.dismiss(id)); + undoToasts.current.clear(); + chartScope.current = scope; setNodes(data.nodes); setEdges(data.edges); + setChartKey((key) => key + 1); }, [setNodes, setEdges]); useEffect(() => { @@ -328,33 +362,29 @@ export default function FlowchartPage() { const [newNodeDescription, setNewNodeDescription] = useState(''); const [newNodeType, setNewNodeType] = useState('contention'); const [newNodeStrength, setNewNodeStrength] = useState<'strong' | 'moderate' | 'weak'>('moderate'); + const [newNodeLabelError, setNewNodeLabelError] = useState(false); + const newNodeLabelRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); - const [flowInstance, setFlowInstance] = useState(null); - const canvasRef = useRef(null); const [layoutAnimating, setLayoutAnimating] = useState(false); const [editingNodeId, setEditingNodeId] = useState(null); const [selectedEdgeType, setSelectedEdgeType] = useState('support'); const onConnect = useCallback( (connection: Connection) => { - const edgeStyle = edgeStyles[selectedEdgeType]; - setEdges((eds) => - addEdge( - { - ...connection, - label: edgeStyle.label, - style: { stroke: edgeStyle.stroke, strokeWidth: 2, ...(edgeStyle.dash ? { strokeDasharray: edgeStyle.dash } : {}) }, - markerEnd: { type: MarkerType.ArrowClosed }, - }, - eds - ) - ); + if (!connection.source || !connection.target) return; + // addEdge names the edge after its endpoints and handles, and skips duplicates. + const { label, data, style, markerEnd } = edgeOfKind(selectedEdgeType, connection.source, connection.target, t); + setEdges((eds) => addEdge({ ...connection, label, data, style, markerEnd }, eds)); }, - [selectedEdgeType, setEdges] + [selectedEdgeType, setEdges, t] ); const handleSaveNode = useCallback(() => { - if (!newNodeLabel.trim()) return; + if (!newNodeLabel.trim()) { + setNewNodeLabelError(true); + newNodeLabelRef.current?.focus(); + return; + } if (editingNodeId) { setNodes((nds) => @@ -362,13 +392,12 @@ export default function FlowchartPage() { if (node.id === editingNodeId) { return { ...node, - data: { - ...node.data, + data: editNodeData(node.data, { label: newNodeLabel, description: newNodeDescription, type: newNodeType, strength: newNodeType === 'contention' ? newNodeStrength : undefined, - }, + }, t), }; } return node; @@ -377,20 +406,21 @@ export default function FlowchartPage() { setEditingNodeId(null); } else { // A new node appears in the middle of what the user is looking at. - const rect = canvasRef.current?.getBoundingClientRect(); - const centre = rect && flowInstance - ? flowInstance.screenToFlowPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) + const { width, height } = canvasSize.current; + const viewport = flowInstance?.getViewport(); + const centre = viewport && width + ? { x: (width / 2 - viewport.x) / viewport.zoom, y: (height / 2 - viewport.y) / viewport.zoom } : { x: 400, y: 250 }; const newNode: Node = { id: `node-${Date.now()}`, type: 'custom', position: { x: centre.x - 100, y: centre.y - 40 }, - data: { + data: studentNodeData({ label: newNodeLabel, description: newNodeDescription, type: newNodeType, strength: newNodeType === 'contention' ? newNodeStrength : undefined, - }, + }), }; setNodes((nds) => [...nds, newNode]); } @@ -398,10 +428,14 @@ export default function FlowchartPage() { setNewNodeLabel(''); setNewNodeDescription(''); setIsAddDialogOpen(false); - }, [newNodeLabel, newNodeDescription, newNodeType, newNodeStrength, editingNodeId, setNodes, flowInstance]); + }, [newNodeLabel, newNodeDescription, newNodeType, newNodeStrength, editingNodeId, setNodes, flowInstance, t]); const handleSaveFlowchart = async () => { - if (!saveTitle.trim()) return; + if (!saveTitle.trim()) { + setSaveTitleError(true); + saveTitleRef.current?.focus(); + return; + } try { await saveFlowchart(saveTitle, { nodes, edges }, workspace.context ? null : undefined); @@ -415,6 +449,35 @@ export default function FlowchartPage() { } }; + // Building from the case replaces the canvas, so Undo puts the previous chart back. + const buildFromCase = () => { + if (!currentCase) return; + const previous = { nodes, edges }; + const previousScope = chartScope.current; + replaceChart(flowchartFromCase(currentCase, t)); + setSelectedNode(null); + const version = chartVersion.current; + undoToasts.current.add(toast.success(t('flowchart.builtFromCase'), { + action: { + label: t('common.undo', 'Undo'), + onClick: () => { + if (chartVersion.current !== version) return; + replaceChart(previous, previousScope); + setSelectedNode(null); + }, + }, + })); + }; + + // A chart belongs to its debate: switching debates puts the template back rather + // than leave that chart on the canvas, to be saved into another debate. + useEffect(() => { + if (chartScope.current === undefined || chartScope.current === flowchartScope) return; + replaceChart(starterFlowchart(t), undefined); + setSelectedNode(null); + toast(t('flowchart.resetForDebate')); + }, [flowchartScope, replaceChart, t]); + const handleLoadFlowchart = (data: FlowchartData) => { replaceChart(data); setSelectedNode(null); @@ -454,6 +517,7 @@ export default function FlowchartPage() { const openAddDialog = () => { setEditingNodeId(null); + setNewNodeLabelError(false); setNewNodeLabel(''); setNewNodeDescription(''); setNewNodeType('contention'); @@ -463,14 +527,15 @@ export default function FlowchartPage() { const openEditDialog = useCallback((node: Node) => { setEditingNodeId(node.id); - setNewNodeLabel(node.data.label); - setNewNodeDescription(node.data.description || ''); + setNewNodeLabelError(false); + setNewNodeLabel(nodeLabel(node.data, t)); + setNewNodeDescription(nodeDescription(node.data, t)); setNewNodeType(node.data.type); if (node.data.strength) { setNewNodeStrength(node.data.strength); } setIsAddDialogOpen(true); - }, []); + }, [t]); // Deleting a node acts at once; Undo puts the node and its edges back. const deleteSelectedNode = useCallback(() => { @@ -482,7 +547,7 @@ export default function FlowchartPage() { setEdges((eds) => eds.filter((e) => e.source !== selectedNode && e.target !== selectedNode)); setSelectedNode(null); const version = chartVersion.current; - nodeUndoToast.current = toast(t('flowchart.nodeDeletedNamed', { label: removedNode.data.label, defaultValue: 'Deleted {{label}}' }), { + const undo = toast(t('flowchart.nodeDeletedNamed', { label: nodeLabel(removedNode.data, t), defaultValue: 'Deleted {{label}}' }), { action: { label: t('common.undo', 'Undo'), onClick: () => { @@ -492,6 +557,7 @@ export default function FlowchartPage() { }, }, }); + undoToasts.current.add(undo); }, [selectedNode, nodes, edges, setNodes, setEdges, t]); const autoLayout = useCallback(() => { @@ -523,15 +589,15 @@ export default function FlowchartPage() { }, [nodes, setNodes, flowInstance]); const exportToPng = useCallback(() => { - const flowElement = document.querySelector('.react-flow') as HTMLElement; + const flowElement = canvasRef.current?.querySelector('.react-flow'); if (!flowElement) return; + // The canvas size comes from its ResizeObserver, so exporting forces no layout. + const background = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); toPng(flowElement, { - backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--background') - ? `hsl(${getComputedStyle(document.documentElement).getPropertyValue('--background')})` - : '#ffffff', - width: flowElement.offsetWidth, - height: flowElement.offsetHeight, + backgroundColor: background ? `hsl(${background})` : '#ffffff', + width: Math.round(canvasSize.current.width), + height: Math.round(canvasSize.current.height), }).then((dataUrl) => { const link = document.createElement('a'); link.download = 'debate-flowchart.png'; @@ -565,8 +631,8 @@ export default function FlowchartPage() { @@ -620,11 +686,17 @@ export default function FlowchartPage() { {/* Save Dialog */} - + { + setIsSaveDialogOpen(open); + if (open) setSaveTitleError(false); + }} + > @@ -637,10 +709,21 @@ export default function FlowchartPage() { setSaveTitle(e.target.value)} + aria-invalid={saveTitleError} + aria-describedby={saveTitleError ? 'flowchart-save-title-error' : undefined} + onChange={(e) => { + setSaveTitle(e.target.value); + if (saveTitleError) setSaveTitleError(false); + }} /> + {saveTitleError && ( + + )} @@ -649,7 +732,7 @@ export default function FlowchartPage() { -
+
@@ -718,26 +812,6 @@ export default function FlowchartPage() {
- {selectedNode && selectedNode !== 'thesis' && ( - <> - - - - )} - + + {currentCase && ( + + )}