From 0bedbaa7114f798843e8db19464f9132c1044fd5 Mon Sep 17 00:00:00 2001 From: Vincent Pretre Date: Thu, 26 Feb 2026 11:15:50 +0100 Subject: [PATCH 01/12] =?UTF-8?q?=E2=9C=A8=20feat(frontend):=20add=20Story?= =?UTF-8?q?book=20configuration=20with=20UnifiedMarkdownViewer=20story?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Storybook setup for frontend app with dedicated Vite config to avoid React Router plugin conflicts. Includes example story for UnifiedMarkdownViewer component demonstrating markdown diff highlighting. Co-Authored-By: Claude Sonnet 4.5 --- apps/frontend/.storybook/main.ts | 17 ++++ apps/frontend/.storybook/preview.tsx | 35 ++++++++ apps/frontend/.storybook/vite.config.ts | 35 ++++++++ apps/frontend/project.json | 17 ++++ .../UnifiedMarkdownViewer.stories.tsx | 79 +++++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 apps/frontend/.storybook/main.ts create mode 100644 apps/frontend/.storybook/preview.tsx create mode 100644 apps/frontend/.storybook/vite.config.ts create mode 100644 apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts new file mode 100644 index 000000000..af123abf2 --- /dev/null +++ b/apps/frontend/.storybook/main.ts @@ -0,0 +1,17 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +import path from 'path'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: [], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: path.resolve(__dirname, 'vite.config.ts'), + }, + }, + }, +}; + +export default config; diff --git a/apps/frontend/.storybook/preview.tsx b/apps/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..969f3026d --- /dev/null +++ b/apps/frontend/.storybook/preview.tsx @@ -0,0 +1,35 @@ +import type { Preview } from '@storybook/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, +}); + +const preview: Preview = { + decorators: [ + (Story) => ( + + + + + + ), + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/apps/frontend/.storybook/vite.config.ts b/apps/frontend/.storybook/vite.config.ts new file mode 100644 index 000000000..9b6086c95 --- /dev/null +++ b/apps/frontend/.storybook/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import path from 'path'; + +export default defineConfig(() => { + // Determine edition mode (defaults to OSS if not explicitly set to 'proprietary') + const isOssMode = process.env.PACKMIND_EDITION !== 'proprietary'; + + // Configure resolve aliases based on edition + const resolveAliases = isOssMode + ? { + '@packmind/proprietary/frontend': path.resolve( + __dirname, + '../src/domain/editions/stubs', + ), + } + : { + '@packmind/proprietary/frontend': path.resolve(__dirname, '../src'), + }; + + return { + root: path.resolve(__dirname, '..'), + cacheDir: '../../../node_modules/.vite/apps/frontend-storybook', + assetsInclude: ['**/*.svg', '**/*.png'], + define: { + __PACKMIND_EDITION__: JSON.stringify( + process.env.PACKMIND_EDITION || 'oss', + ), + }, + resolve: { + alias: resolveAliases, + }, + plugins: [nxViteTsPaths()], + }; +}); diff --git a/apps/frontend/project.json b/apps/frontend/project.json index 5f462e0ee..791cf0943 100644 --- a/apps/frontend/project.json +++ b/apps/frontend/project.json @@ -26,6 +26,23 @@ "options": { "command": "nx dev frontend" } + }, + "storybook": { + "executor": "nx:run-commands", + "continuous": true, + "options": { + "cwd": "apps/frontend", + "command": "storybook dev -p 6006" + } + }, + "build-storybook": { + "executor": "nx:run-commands", + "cache": true, + "outputs": ["{projectRoot}/storybook-static"], + "options": { + "cwd": "apps/frontend", + "command": "storybook build" + } } } } diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx new file mode 100644 index 000000000..26746593f --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { UnifiedMarkdownViewer } from './UnifiedMarkdownViewer'; + +const meta: Meta = { + title: 'ChangeProposals/UnifiedMarkdownViewer', + component: UnifiedMarkdownViewer, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const SimpleTextChange: Story = { + args: { + oldValue: 'This was my content', + newValue: 'This is my updated content', + proposalNumbers: [1], + }, +}; + +export const MultipleProposals: Story = { + args: { + oldValue: 'This is my content', + newValue: 'This is my updated content', + proposalNumbers: [1, 2, 3], + }, +}; + +export const MarkdownFormatting: Story = { + args: { + oldValue: `# Heading + +This is a paragraph with bold text.`, + newValue: `# Updated Heading + +This is a paragraph with **bold** text.`, + proposalNumbers: [1], + }, +}; + +export const CodeBlock: Story = { + args: { + oldValue: `# Example + +\`\`\`javascript +function hello() { + console.log('Hello'); +} +\`\`\``, + newValue: `# Example + +\`\`\`javascript +function hello(name) { + console.log('Hello, ' + name); +} +\`\`\``, + proposalNumbers: [1], + }, +}; + +export const ListChanges: Story = { + args: { + oldValue: `# Todo List + +- Item 1 +- Item 2 +- Item 3`, + newValue: `# Todo List + +- Item 1 +- Updated Item 2 +- Item 3 +- Item 4`, + proposalNumbers: [1], + }, +}; From dd0edc9bcb4b685dbded11a643bd58a4f0d4760e Mon Sep 17 00:00:00 2001 From: Vincent Pretre Date: Thu, 26 Feb 2026 12:00:20 +0100 Subject: [PATCH 02/12] WIP: use a single markdown viewer to show diff/unified views --- .../UnifiedMarkdownViewer.stories.tsx | 10 +- .../components/UnifiedMarkdownViewer.tsx | 8 +- .../utils/buildInlineDiffMarkdown.ts | 13 ++ .../change-proposals/utils/markdownDiff.ts | 129 +++++++++++++++++- .../components/editor/DiffMarkdownEditor.tsx | 45 +++++- 5 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx index 26746593f..633a4c25a 100644 --- a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx +++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx @@ -8,6 +8,14 @@ const meta: Meta = { layout: 'padded', }, tags: ['autodocs'], + argTypes: { + displayMode: { + control: 'radio', + options: ['unified', 'diff'], + description: 'Display mode for the markdown viewer', + value: 'unified', + }, + }, }; export default meta; @@ -33,7 +41,7 @@ export const MarkdownFormatting: Story = { args: { oldValue: `# Heading -This is a paragraph with bold text.`, +This is a paragraph with plain text.`, newValue: `# Updated Heading This is a paragraph with **bold** text.`, diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx index 915eb0146..e4e6defbb 100644 --- a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx +++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { PMBox } from '@packmind/ui'; import { MilkdownProvider } from '@milkdown/react'; -import { DiffMarkdownEditor } from '../../../shared/components/editor/DiffMarkdownEditor'; +import { + DiffMarkdownEditor, + IDiffMarkdownEditorProps, +} from '../../../shared/components/editor/DiffMarkdownEditor'; interface UnifiedMarkdownViewerProps { oldValue: string; newValue: string; proposalNumbers: number[]; + displayMode?: IDiffMarkdownEditorProps['displayMode']; } /** @@ -18,6 +22,7 @@ export function UnifiedMarkdownViewer({ oldValue, newValue, proposalNumbers, + displayMode, }: UnifiedMarkdownViewerProps) { return ( @@ -26,6 +31,7 @@ export function UnifiedMarkdownViewer({ oldValue={oldValue} newValue={newValue} proposalNumbers={proposalNumbers} + displayMode={displayMode ?? 'unified'} paddingVariant="none" /> diff --git a/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts b/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts new file mode 100644 index 000000000..85688573d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts @@ -0,0 +1,13 @@ +import { buildDiffHtml } from './markdownDiff'; + +/** + * Builds inline diff markdown content for the "diff" display mode. + * Uses buildDiffHtml to generate HTML with and tags + * showing additions and deletions inline. + */ +export function buildInlineDiffMarkdown( + oldValue: string, + newValue: string, +): string { + return buildDiffHtml(oldValue, newValue); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/markdownDiff.ts b/apps/frontend/src/domain/change-proposals/utils/markdownDiff.ts index 1fc0cc683..53255e44b 100644 --- a/apps/frontend/src/domain/change-proposals/utils/markdownDiff.ts +++ b/apps/frontend/src/domain/change-proposals/utils/markdownDiff.ts @@ -1,5 +1,5 @@ import { marked, Token, Tokens, TokensList } from 'marked'; -import { diffArrays, diffWords } from 'diff'; +import { diffArrays, diffWords, Change } from 'diff'; function toTokensList(tokens: Token[]): TokensList { const list = tokens as TokensList; @@ -15,6 +15,67 @@ function renderInlineContent(text: string): string { return marked.parseInline(text) as string; } +/** + * Merge adjacent diff changes that are markdown syntax markers with their words. + * For example: [added: '**'], [unchanged: 'word'], [added: '**'] + * becomes: [added: '**word**'] + */ +function mergeMarkdownSyntax(changes: Change[]): Change[] { + const merged: Change[] = []; + let i = 0; + + while (i < changes.length) { + const current = changes[i]; + + // Check if this looks like a markdown marker start + if ( + (current.added || current.removed) && + /^[*_`~]+$/.test(current.value.trim()) + ) { + // Look ahead to find the matching closing marker + let j = i + 1; + let accumulatedValue = current.value; + let foundClosing = false; + + while (j < changes.length) { + const next = changes[j]; + accumulatedValue += next.value; + + // Check if this is the closing marker + if ( + (next.added || next.removed) && + next.added === current.added && + next.removed === current.removed && + /^[*_`~]+$/.test(next.value.trim()) + ) { + foundClosing = true; + // Merge all changes from i to j into one + merged.push({ + added: current.added, + removed: current.removed, + value: accumulatedValue, + count: 1, + }); + i = j + 1; + break; + } + j++; + } + + if (!foundClosing) { + // No closing marker found, keep original + merged.push(current); + i++; + } + } else { + merged.push(current); + i++; + } + } + + return merged; +} + function renderListAllMarked(list: Tokens.List, tag: 'del' | 'ins'): string { const listTag = list.ordered ? 'ol' : 'ul'; const items = list.items @@ -108,7 +169,9 @@ function renderWordDiffInBlock( blockTag: string, ): string { const changes = diffWords(oldToken.text, newToken.text); - const inlineHtml = changes + // Merge markdown syntax markers with their words + const mergedChanges = mergeMarkdownSyntax(changes); + const inlineHtml = mergedChanges .map((change) => { if (change.added) return `${renderInlineContent(change.value)}`; @@ -193,6 +256,7 @@ function renderModifiedPair(oldToken: Token, newToken: Token): string { } export const markdownDiffCss = { + // Diff highlighting '& ins, & .diff-ins': { backgroundColor: 'var(--Palette-Semantic-Green800)', padding: '0 2px', @@ -204,6 +268,52 @@ export const markdownDiffCss = { padding: '0 2px', borderRadius: '2px', }, + // Markdown typography + '& h1': { + fontSize: '2em', + fontWeight: 'bold', + marginTop: '0.67em', + marginBottom: '0.67em', + }, + '& h2': { + fontSize: '1.5em', + fontWeight: 'bold', + marginTop: '0.83em', + marginBottom: '0.83em', + }, + '& h3': { + fontSize: '1.17em', + fontWeight: 'bold', + marginTop: '1em', + marginBottom: '1em', + }, + '& strong': { + fontWeight: 'bold', + }, + '& em': { + fontStyle: 'italic', + }, + '& p': { + marginTop: '1em', + marginBottom: '1em', + }, + '& code': { + fontFamily: 'monospace', + backgroundColor: 'var(--Palette-Neutral-300)', + padding: '2px 4px', + borderRadius: '3px', + }, + '& pre': { + backgroundColor: 'var(--Palette-Neutral-200)', + padding: '1em', + borderRadius: '4px', + overflow: 'auto', + }, + '& ul, & ol': { + marginTop: '1em', + marginBottom: '1em', + paddingLeft: '2em', + }, }; export function buildDiffHtml(oldValue: string, newValue: string): string { @@ -237,7 +347,22 @@ export function buildDiffHtml(oldValue: string, newValue: string): string { parts.push( renderModifiedPair(oldTokens[oldIdx++], newTokens[newIdx++]), ); + } else if (count === nextCount) { + // Same number of tokens changed - try to pair them up for word-level diffs + for (let i = 0; i < count; i++) { + const oldToken = oldTokens[oldIdx++]; + const newToken = newTokens[newIdx++]; + // If both tokens are the same type, do word-level diff + if (oldToken.type === newToken.type) { + parts.push(renderModifiedPair(oldToken, newToken)); + } else { + // Different types - render as complete replacement + parts.push(renderDeletedToken(oldToken)); + parts.push(renderAddedToken(newToken)); + } + } } else { + // Different number of tokens - render all deletions then all additions for (let i = 0; i < count; i++) { parts.push(renderDeletedToken(oldTokens[oldIdx++])); } diff --git a/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx b/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx index 9641a32a6..dff7dc030 100644 --- a/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx +++ b/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx @@ -4,8 +4,10 @@ import '@packmind/assets/milkdown.theme'; import { PMBox } from '@packmind/ui'; import React, { useEffect, useRef, useMemo, useState } from 'react'; import { buildUnifiedMarkdownDiff } from '../../../domain/change-proposals/utils/buildUnifiedMarkdownDiff'; +import { buildInlineDiffMarkdown } from '../../../domain/change-proposals/utils/buildInlineDiffMarkdown'; +import { markdownDiffCss } from '../../../domain/change-proposals/utils/markdownDiff'; -interface IDiffMarkdownEditorProps { +export interface IDiffMarkdownEditorProps { /** Original markdown content before changes */ oldValue: string; /** New markdown content after changes */ @@ -14,6 +16,7 @@ interface IDiffMarkdownEditorProps { proposalNumbers: number[]; /** Padding variant for the editor */ paddingVariant?: 'default' | 'none'; + displayMode: 'unified' | 'diff'; } /** @@ -28,22 +31,32 @@ export const DiffMarkdownEditor: React.FC = ({ oldValue, newValue, proposalNumbers, + displayMode, paddingVariant = 'default', }) => { const editorRef = useRef(null); const [isEditorReady, setIsEditorReady] = useState(false); + // Build blocks for unified mode (for highlighting) const blocks = useMemo( () => buildUnifiedMarkdownDiff(oldValue, newValue), [oldValue, newValue], ); + // Compute editor content based on display mode + const editorContent = useMemo(() => { + if (displayMode === 'diff') { + return buildInlineDiffMarkdown(oldValue, newValue); + } + return newValue; + }, [displayMode, oldValue, newValue]); + useEditor((root) => { editorRef.current = root as HTMLDivElement; const crepe = new Crepe({ root, - defaultValue: newValue, + defaultValue: editorContent, features: { [Crepe.Feature.ImageBlock]: false, [Crepe.Feature.Latex]: false, @@ -60,8 +73,9 @@ export const DiffMarkdownEditor: React.FC = ({ return crepe; }); - // Apply highlights to changed content after editor renders + // Apply highlights to changed content after editor renders (unified mode only) useEffect(() => { + if (displayMode !== 'unified') return; // Skip for diff mode if (!isEditorReady || !editorRef.current || blocks.length === 0) return; const changedBlocks = blocks.filter((b) => b.isChanged); @@ -176,10 +190,11 @@ export const DiffMarkdownEditor: React.FC = ({ waitForProseMirror.disconnect(); }; } - }, [blocks, proposalNumbers, isEditorReady]); + }, [blocks, proposalNumbers, isEditorReady, displayMode]); - // Add hover handlers for tooltips + // Add hover handlers for tooltips (unified mode only) useEffect(() => { + if (displayMode !== 'unified') return; // Skip for diff mode if (!isEditorReady || !editorRef.current) return; const handleMouseEnter = (e: MouseEvent) => { @@ -276,8 +291,24 @@ export const DiffMarkdownEditor: React.FC = ({ editor.removeEventListener('mouseout', handleMouseLeave); document.querySelectorAll('.diff-tooltip').forEach((el) => el.remove()); }; - }, [isEditorReady]); - + }, [isEditorReady, displayMode, proposalNumbers]); + + // For diff mode, render HTML directly without Milkdown + if (displayMode === 'diff') { + return ( + + ); + } + + // For unified mode, use Milkdown with highlights return ( From 4618219774ef9cd2850c92e37793b2e0463ec600 Mon Sep 17 00:00:00 2001 From: Vincent Pretre Date: Thu, 26 Feb 2026 14:56:11 +0100 Subject: [PATCH 03/12] Add plain mode in markdown viewer to simply show the new value --- .../UnifiedMarkdownViewer.stories.tsx | 6 ++- .../components/editor/DiffMarkdownEditor.tsx | 43 +++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx index 633a4c25a..68a9ec235 100644 --- a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx +++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx @@ -11,7 +11,7 @@ const meta: Meta = { argTypes: { displayMode: { control: 'radio', - options: ['unified', 'diff'], + options: ['unified', 'diff', 'plain'], description: 'Display mode for the markdown viewer', value: 'unified', }, @@ -41,7 +41,9 @@ export const MarkdownFormatting: Story = { args: { oldValue: `# Heading -This is a paragraph with plain text.`, +This is a paragraph with plain text. + +This used to be a line.`, newValue: `# Updated Heading This is a paragraph with **bold** text.`, diff --git a/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx b/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx index dff7dc030..5096d056e 100644 --- a/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx +++ b/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx @@ -16,16 +16,17 @@ export interface IDiffMarkdownEditorProps { proposalNumbers: number[]; /** Padding variant for the editor */ paddingVariant?: 'default' | 'none'; - displayMode: 'unified' | 'diff'; + /** Display mode: 'unified' (highlights with tooltips), 'diff' (inline +/-), 'plain' (clean view) */ + displayMode: 'unified' | 'diff' | 'plain'; } /** - * A specialized MarkdownEditor that displays diff highlighting and tooltips - * for changed content. Uses a single Milkdown editor with highlights applied - * via DOM manipulation after render. + * A specialized MarkdownEditor that supports three display modes: + * - 'unified': Displays the new content with highlights and tooltips showing what changed + * - 'diff': Displays inline diff with additions and deletions using HTML rendering + * - 'plain': Displays the new content without any diff highlighting (clean view) * - * This component is read-only and designed for viewing unified diff views - * of change proposals. + * This component is read-only and designed for viewing change proposals. */ export const DiffMarkdownEditor: React.FC = ({ oldValue, @@ -48,6 +49,7 @@ export const DiffMarkdownEditor: React.FC = ({ if (displayMode === 'diff') { return buildInlineDiffMarkdown(oldValue, newValue); } + // For 'unified' and 'plain' modes, use newValue return newValue; }, [displayMode, oldValue, newValue]); @@ -75,7 +77,28 @@ export const DiffMarkdownEditor: React.FC = ({ // Apply highlights to changed content after editor renders (unified mode only) useEffect(() => { - if (displayMode !== 'unified') return; // Skip for diff mode + // Helper function to remove all highlights + const removeHighlights = () => { + if (!editorRef.current) return; + const proseMirrorEditor = editorRef.current.querySelector('.ProseMirror'); + if (!proseMirrorEditor) return; + + const highlightedElements = proseMirrorEditor.querySelectorAll( + '.milkdown-diff-highlight', + ); + highlightedElements.forEach((element) => { + (element as HTMLElement).classList.remove('milkdown-diff-highlight'); + (element as HTMLElement).removeAttribute('data-diff-html'); + (element as HTMLElement).removeAttribute('data-proposal-numbers'); + }); + }; + + // If not in unified mode, remove any existing highlights and return + if (displayMode !== 'unified') { + removeHighlights(); + return; + } + if (!isEditorReady || !editorRef.current || blocks.length === 0) return; const changedBlocks = blocks.filter((b) => b.isChanged); @@ -166,6 +189,7 @@ export const DiffMarkdownEditor: React.FC = ({ return () => { clearTimeout(initialCheck); observer.disconnect(); + removeHighlights(); }; } else { // ProseMirror element not found yet - wait for it to be added @@ -188,13 +212,14 @@ export const DiffMarkdownEditor: React.FC = ({ return () => { waitForProseMirror.disconnect(); + removeHighlights(); }; } }, [blocks, proposalNumbers, isEditorReady, displayMode]); // Add hover handlers for tooltips (unified mode only) useEffect(() => { - if (displayMode !== 'unified') return; // Skip for diff mode + if (displayMode !== 'unified') return; // Skip for diff and plain modes if (!isEditorReady || !editorRef.current) return; const handleMouseEnter = (e: MouseEvent) => { @@ -308,7 +333,7 @@ export const DiffMarkdownEditor: React.FC = ({ ); } - // For unified mode, use Milkdown with highlights + // For unified and plain modes, use Milkdown with highlights (unified) or without (plain) return ( From 1eb2ccc32826b34af4108a2588517b78fce0a431 Mon Sep 17 00:00:00 2001 From: Vincent Pretre Date: Thu, 26 Feb 2026 16:59:12 +0100 Subject: [PATCH 04/12] =?UTF-8?q?=E2=9C=A8=20feat(frontend):=20implement?= =?UTF-8?q?=20block-based=20markdown=20diff=20with=20similarity=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored markdown diffing system to use structured block parsing with intelligent similarity matching for improved word-level diffs and better list item handling. Key improvements: - Parse markdown into typed blocks (heading, paragraph, list, code) with status tracking - Match similar blocks using word overlap algorithm for accurate word-level diffs - Rebuild markdown/HTML from blocks for unified, diff, and plain display modes - Highlight changed list items individually in unified view with tooltips - Comprehensive test coverage (26 tests) with single-assertion pattern Technical changes: - Add markdownBlockDiff.ts: Block parser with similarity-based matching (30% threshold) - Add rebuildMarkdownFromBlocks.ts: Rebuild logic for different display modes - Update DiffMarkdownEditor: Collect highlight targets from blocks and list items - Add ComplexChanges story demonstrating the full user story example Co-Authored-By: Claude Sonnet 4.5 --- .../UnifiedMarkdownViewer.stories.tsx | 50 +- .../utils/markdownBlockDiff.spec.ts | 355 ++++++++++++++ .../utils/markdownBlockDiff.ts | 437 ++++++++++++++++++ .../utils/rebuildMarkdownFromBlocks.spec.ts | 217 +++++++++ .../utils/rebuildMarkdownFromBlocks.ts | 130 ++++++ .../components/editor/DiffMarkdownEditor.tsx | 74 ++- 6 files changed, 1238 insertions(+), 25 deletions(-) create mode 100644 apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts create mode 100644 apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.ts create mode 100644 apps/frontend/src/domain/change-proposals/utils/rebuildMarkdownFromBlocks.spec.ts create mode 100644 apps/frontend/src/domain/change-proposals/utils/rebuildMarkdownFromBlocks.ts diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx index 68a9ec235..e28f6307d 100644 --- a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx +++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx @@ -39,14 +39,27 @@ export const MultipleProposals: Story = { export const MarkdownFormatting: Story = { args: { - oldValue: `# Heading + oldValue: `# My title -This is a paragraph with plain text. +This was my content -This used to be a line.`, - newValue: `# Updated Heading +There was a line here. -This is a paragraph with **bold** text.`, +## My sub-heading + +My list: + - one item + - another item`, + newValue: `# My title + +This is my updated content + +## My sub-heading + +My list: + - first item + - a new item + - another item`, proposalNumbers: [1], }, }; @@ -87,3 +100,30 @@ export const ListChanges: Story = { proposalNumbers: [1], }, }; + +export const ComplexChanges: Story = { + args: { + oldValue: `# My title + +This was my content + +There was a line here. + +## My sub-heading + +My list: + - one item + - another item`, + newValue: `# My title + +This is my updated content + +## My sub-heading + +My list: + - first item + - a new item + - another item`, + proposalNumbers: [1, 2], + }, +}; diff --git a/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts b/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts new file mode 100644 index 000000000..91408af4e --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts @@ -0,0 +1,355 @@ +import { parseAndDiffMarkdown } from './markdownBlockDiff'; + +describe('markdownBlockDiff', () => { + describe('parseAndDiffMarkdown', () => { + it('parses and diffs a simple text change', () => { + const oldValue = 'This was my content'; + const newValue = 'This is my updated content'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'paragraph', + content: 'This is my updated content', + status: 'updated', + diffContent: expect.stringMatching( + /This was<\/del>is<\/ins> my updated <\/ins>content/, + ), + }), + ]); + }); + + it('handles heading blocks with level tracking', () => { + const oldValue = '# My title'; + const newValue = '# My updated title'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'heading', + level: '#', + content: 'My updated title', + status: 'updated', + diffContent: expect.stringContaining('updated '), + }), + ]); + }); + + it('preserves unchanged blocks', () => { + const markdown = '# My title\n\nSome content that stays the same'; + + const result = parseAndDiffMarkdown(markdown, markdown); + + expect(result).toEqual([ + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ status: 'unchanged' }), + ]); + }); + + it('marks added blocks correctly', () => { + const oldValue = '# Title'; + const newValue = '# Title\n\nNew paragraph'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'heading', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'paragraph', + content: 'New paragraph', + status: 'added', + diffContent: 'New paragraph', + }), + ]), + ); + }); + + it('marks deleted blocks correctly', () => { + const oldValue = '# Title\n\nParagraph to delete'; + const newValue = '# Title'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'heading', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'paragraph', + content: 'Paragraph to delete', + status: 'deleted', + diffContent: 'Paragraph to delete', + }), + ]), + ); + }); + + it('handles the complex user story example correctly', () => { + const oldValue = `# My title + +This was my content + +There was a line here. + +## My sub-heading + +My list: + - one item + - another item`; + + const newValue = `# My title + +This is my updated content + +## My sub-heading + +My list: + - first item + - a new item + - another item`; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'heading', + level: '#', + content: 'My title', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'heading', + level: '##', + content: 'My sub-heading', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'paragraph', + content: 'This is my updated content', + status: 'updated', + diffContent: expect.stringMatching( + /was<\/del>.*is<\/ins>/, + ), + }), + expect.objectContaining({ + type: 'paragraph', + content: 'There was a line here.', + status: 'deleted', + diffContent: 'There was a line here.', + }), + expect.objectContaining({ + type: 'list', + status: 'updated', + items: expect.arrayContaining([ + expect.objectContaining({ + content: 'one item', + status: 'deleted', + }), + expect.objectContaining({ + content: 'first item', + status: 'added', + }), + expect.objectContaining({ + content: 'a new item', + status: 'added', + }), + expect.objectContaining({ + content: 'another item', + status: 'unchanged', + }), + ]), + }), + ]), + ); + }); + + it('handles multiple heading levels', () => { + const oldValue = '# H1\n## H2\n### H3'; + const newValue = '# H1\n## Updated H2\n### H3'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'heading', + level: '#', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'heading', + level: '##', + content: 'Updated H2', + status: 'updated', + }), + expect.objectContaining({ + type: 'heading', + level: '###', + status: 'unchanged', + }), + ]); + }); + + it('computes word-level diffs for code blocks', () => { + const oldValue = '```js\nconst x = 1;\n```'; + const newValue = '```js\nconst x = 2;\n```'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'code', + status: 'updated', + diffContent: expect.stringMatching(/1<\/del>.*2<\/ins>/), + }), + ]); + }); + + it('tracks list item additions', () => { + const oldValue = '- item 1\n- item 2'; + const newValue = '- item 1\n- item 2\n- item 3'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'list', + status: 'updated', + items: [ + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ + status: 'added', + diffContent: 'item 3', + }), + ], + }), + ]); + }); + + it('tracks list item deletions', () => { + const oldValue = '- item 1\n- item 2\n- item 3'; + const newValue = '- item 1\n- item 3'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'list', + status: 'updated', + items: [ + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ + status: 'deleted', + diffContent: 'item 2', + }), + expect.objectContaining({ status: 'unchanged' }), + ], + }), + ]); + }); + + it('returns empty array for empty markdown', () => { + const result = parseAndDiffMarkdown('', ''); + + expect(result).toEqual([]); + }); + + it('handles transition from empty to content', () => { + const result = parseAndDiffMarkdown('', '# New heading'); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'heading', + status: 'added', + }), + ]); + }); + + it('escapes HTML to prevent XSS', () => { + const oldValue = 'Content with '; + const newValue = 'Content with
safe
'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result[0].diffContent).toEqual( + expect.not.stringContaining('