diff --git a/package-lock.json b/package-lock.json index 60e8903d..2b45c8be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@tiptap/starter-kit": "^3.18.0", "@tiptap/suggestion": "^3.18.0", "clsx": "^2.1.1", + "prosemirror-changeset": "^2.4.0", "react": "^19.1.0", "react-dom": "^19.1.0", "sonner": "^2.0.7", @@ -3926,9 +3927,9 @@ } }, "node_modules/prosemirror-changeset": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", - "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" diff --git a/package.json b/package.json index fb3f773f..19d85659 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@tiptap/starter-kit": "^3.18.0", "@tiptap/suggestion": "^3.18.0", "clsx": "^2.1.1", + "prosemirror-changeset": "^2.4.0", "react": "^19.1.0", "react-dom": "^19.1.0", "sonner": "^2.0.7", diff --git a/src/App.css b/src/App.css index 3e59a951..5f242aee 100644 --- a/src/App.css +++ b/src/App.css @@ -17,6 +17,9 @@ --color-border-solid: #d6d3d1; /* stone-300 - for table borders */ --color-accent: #1c1917; --color-selection: rgba(250, 204, 21, 0.4); /* Tailwind yellow-400 */ + --color-ai-diff-add: #34d399; + --color-ai-diff-modify: #f59e0b; + --color-ai-diff-delete: #ef4444; /* Editor font settings - simplified (overridden by ThemeContext) */ --editor-font-family: @@ -49,6 +52,9 @@ --color-border-solid: #57534e; /* stone-600 - for table borders */ --color-accent: #fafaf9; --color-selection: rgba(253, 224, 71, 0.35); /* Tailwind yellow-300 */ + --color-ai-diff-add: #6ee7b7; + --color-ai-diff-modify: #fbbf24; + --color-ai-diff-delete: #f87171; } /* Register theme colors with Tailwind */ @@ -243,6 +249,7 @@ html.dark { .ProseMirror { direction: var(--editor-direction, ltr); max-width: var(--editor-max-width, 48rem) !important; + --ai-diff-indicator-left: 0.5rem; } /* Editor font customization - simplified */ @@ -423,6 +430,153 @@ html.dark { margin-bottom: var(--editor-paragraph-spacing); } +.ai-diff-indicator-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + pointer-events: none; + z-index: 1; +} + +.ai-diff-indicator-marker { + position: absolute; + border: 0; + padding: 0; + width: 3px; + border-radius: 9999px; + pointer-events: auto; + cursor: pointer; + transition: + width 120ms ease, + filter 120ms ease; +} + +.ai-diff-indicator-marker:hover { + width: 5px; +} + +.ai-diff-indicator-marker--active { + width: 5px; + filter: brightness(1.15); +} + +.ai-diff-indicator-marker--add { + background: var(--color-ai-diff-add); +} + +.ai-diff-indicator-marker--modify { + background: var(--color-ai-diff-modify); +} + +.ai-diff-deletion-divider { + position: absolute; + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.35rem 0; + transform: translateY(-40%); +} + +.ai-diff-deletion-divider::before, +.ai-diff-deletion-divider::after { + content: ""; + height: 1px; + flex: 1; + background: var(--color-ai-diff-delete); +} + +.ai-diff-deletion-divider__label { + color: var(--color-ai-diff-delete); + font-size: 0.75rem; + line-height: 1; + font-weight: 600; + background: var(--color-bg); + padding: 0 0.375rem; +} + +.ai-diff-deletion-anchor-block { + margin-top: 2.25rem !important; +} + +.ai-diff-word-add { + background: color-mix( + in oklab, + var(--color-ai-diff-add) 26%, + transparent 74% + ); + border-radius: 0.125rem; +} + +.ai-diff-word-delete { + display: inline; + color: var(--color-ai-diff-delete); + background: color-mix( + in oklab, + var(--color-ai-diff-delete) 24%, + transparent 76% + ); + border-radius: 0.125rem; + text-decoration: line-through; + padding: 0 0.12em; + margin-right: 0.08em; + white-space: pre-wrap; +} + +.ai-diff-word-delete--code { + font-family: + ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, "Courier New", + monospace; + font-size: 0.92em; + line-height: 1.3; + white-space: pre; + border: 1px solid color-mix(in oklab, var(--color-ai-diff-delete) 35%, transparent 65%); +} + +.ai-diff-table-delete-cell { + background: color-mix( + in oklab, + var(--color-ai-diff-delete) 12%, + var(--color-bg) 88% + ) !important; +} + +.ai-diff-table-delete-cell--header { + background: color-mix( + in oklab, + var(--color-ai-diff-delete) 16%, + var(--color-bg-muted) 84% + ) !important; +} + +.ai-diff-table-delete-cell > .ai-diff-word-delete { + margin-right: 0; +} + +.ai-diff-menu { + position: absolute; + pointer-events: auto; + z-index: 2; +} + +.ai-diff-menu-trigger { + width: 1.3rem; + height: 1.3rem; + border-radius: 0.35rem; + border: 1px solid var(--color-border); + background: var(--color-bg); + color: var(--color-text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 120ms ease; +} + +.ai-diff-menu-trigger:hover { + color: var(--color-text); + background: var(--color-bg-muted); +} + .prose ul, .prose ol { margin-top: 0; @@ -969,4 +1123,8 @@ table.not-prose th { page-break-inside: avoid; break-inside: avoid; } + + .ai-diff-indicator-overlay { + display: none !important; + } } diff --git a/src/App.tsx b/src/App.tsx index 40927148..ef66b1de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ import { import { getCurrentWindow } from "@tauri-apps/api/window"; import * as aiService from "./services/ai"; import type { AiProvider } from "./services/ai"; +import { createAiDiffSession, type AiDiffSession } from "./lib/diff"; +import { useWaitForUpdatedEditorSnapshot } from "./hooks/useWaitForUpdatedEditorSnapshot"; // Detect preview mode from URL search params function getWindowMode(): { @@ -59,9 +61,14 @@ function AppContent() { const [sidebarVisible, setSidebarVisible] = useState(true); const [aiModalOpen, setAiModalOpen] = useState(false); const [aiEditing, setAiEditing] = useState(false); + const [aiDiffSession, setAiDiffSession] = useState( + null, + ); const [focusMode, setFocusMode] = useState(false); const [aiProvider, setAiProvider] = useState("claude"); const editorRef = useRef(null); + const waitForUpdatedEditorSnapshot = + useWaitForUpdatedEditorSnapshot(editorRef); const toggleSidebar = useCallback(() => { setSidebarVisible((prev) => !prev); @@ -100,9 +107,18 @@ function AppContent() { toast.error("No note selected"); return; } + if (!editorRef.current) { + toast.error("Editor not ready"); + return; + } + setAiDiffSession(null); setAiEditing(true); + // Capture snapshot of the original document + const beforeJson = editorRef.current.getJSON(); + const beforeSerialized = JSON.stringify(beforeJson); + try { const result = aiProvider === "codex" @@ -112,8 +128,17 @@ function AppContent() { // Reload the current note from disk await reloadCurrentNote(); + const afterJson = await waitForUpdatedEditorSnapshot(beforeSerialized); + + const nextAiDiffSession = createAiDiffSession({ + schema: editorRef.current.state.schema, + before: beforeJson, + after: afterJson, + }); + // Show results if (result.success) { + setAiDiffSession(nextAiDiffSession); // Close modal after success setAiModalOpen(false); @@ -144,9 +169,13 @@ function AppContent() { setAiEditing(false); } }, - [aiProvider, currentNote, reloadCurrentNote], + [aiProvider, currentNote, reloadCurrentNote, waitForUpdatedEditorSnapshot], ); + useEffect(() => { + setAiDiffSession(null); + }, [selectedNoteId]); + // Memoize display items to prevent unnecessary recalculations const displayItems = useMemo(() => { return searchQuery.trim() ? searchResults : notes; @@ -380,6 +409,8 @@ function AppContent() { onToggleSidebar={toggleSidebar} sidebarVisible={sidebarVisible && !focusMode} focusMode={focusMode} + aiDiffSession={aiDiffSession} + onAiDiffSessionChange={setAiDiffSession} onEditorReady={(editor) => { editorRef.current = editor; }} diff --git a/src/components/editor/DiffIndicator.tsx b/src/components/editor/DiffIndicator.tsx new file mode 100644 index 00000000..ba6c5092 --- /dev/null +++ b/src/components/editor/DiffIndicator.tsx @@ -0,0 +1,111 @@ +import type { RefObject } from "react"; +import type { Editor as TiptapEditor } from "@tiptap/react"; +import type { AiDiffIndicatorType, AiDiffSession } from "../../lib/diff"; +import { DiffMenu } from "./DiffMenu"; +import { useDiffIndicatorState } from "./diff-indicator/useDiffIndicatorState"; + +export { + AiDiffIndicatorExtension, + aiDiffIndicatorPluginKey, +} from "./diff-indicator/extension"; + +interface DiffIndicatorProps { + editor: TiptapEditor | null; + aiDiffSession?: AiDiffSession | null; + scrollContainerRef?: RefObject; + onRejectBlock?: (blockId: string) => void; +} + +function getMarkerClassName( + indicatorType: AiDiffIndicatorType, + isActive: boolean, +): string { + const activeClass = isActive ? " ai-diff-indicator-marker--active" : ""; + return `ai-diff-indicator-marker ai-diff-indicator-marker--${indicatorType}${activeClass}`; +} + +function getMarkerAriaLabel(indicatorType: AiDiffIndicatorType): string { + return indicatorType === "add" + ? "Show added content diff" + : "Show modified content diff"; +} + +function getMarkerTitle(indicatorType: AiDiffIndicatorType): string { + return indicatorType === "add" + ? "Show block additions" + : "Show block word diff"; +} + +export function DiffIndicator({ + editor, + aiDiffSession, + scrollContainerRef, + onRejectBlock, +}: DiffIndicatorProps) { + const { + markers, + deletionDividers, + leftPx, + overlayHeight, + activeBlockId, + activeMarker, + toggleMarker, + clearActiveBlock, + } = useDiffIndicatorState({ + editor, + aiDiffSession, + scrollContainerRef, + }); + + if (markers.length === 0 && deletionDividers.length === 0) return null; + + return ( +
+ {deletionDividers.map((divider, index) => ( +
+ Deleted +
+ ))} + {markers.map((marker) => { + const isActive = activeBlockId === marker.blockId; + return ( +
+ ); +} diff --git a/src/components/editor/DiffMenu.tsx b/src/components/editor/DiffMenu.tsx new file mode 100644 index 00000000..2f9ef16b --- /dev/null +++ b/src/components/editor/DiffMenu.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { MoreVerticalIcon, XIcon } from "../icons"; + +interface DiffMenuProps { + top: number; + left: number; + onReject: () => void; +} + +export function DiffMenu({ top, left, onReject }: DiffMenuProps) { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + e.preventDefault()} + > + { + onReject(); + setOpen(false); + }} + > + + Reject changes + + + + +
+ ); +} diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 94a4003c..c8d5ee8d 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -41,6 +41,7 @@ import { Frontmatter } from "./Frontmatter"; import { LinkEditor } from "./LinkEditor"; import { SearchToolbar } from "./SearchToolbar"; import { SlashCommand } from "./SlashCommand"; +import { DiffIndicator, AiDiffIndicatorExtension } from "./DiffIndicator"; import { Wikilink, type WikilinkStorage } from "./Wikilink"; import { WikilinkSuggestion } from "./WikilinkSuggestion"; import { cn } from "../../lib/utils"; @@ -49,6 +50,10 @@ import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; import { downloadPdf, downloadMarkdown } from "../../services/pdf"; import type { Settings } from "../../types/note"; +import { + createAiDiffSession, + type AiDiffSession, +} from "../../lib/diff"; import { BoldIcon, ItalicIcon, @@ -368,6 +373,8 @@ interface EditorProps { onToggleSidebar?: () => void; sidebarVisible?: boolean; focusMode?: boolean; + aiDiffSession?: AiDiffSession | null; + onAiDiffSessionChange?: (next: AiDiffSession | null) => void; previewMode?: PreviewModeData; onEditorReady?: (editor: TiptapEditor | null) => void; onSaveToFolder?: () => void; @@ -378,6 +385,8 @@ export function Editor({ onToggleSidebar, sidebarVisible, focusMode, + aiDiffSession, + onAiDiffSessionChange, onEditorReady, previewMode, onSaveToFolder, @@ -680,6 +689,7 @@ export function Editor({ matches: [], currentIndex: 0, }), + AiDiffIndicatorExtension, SlashCommand, Wikilink, WikilinkSuggestion, @@ -1378,6 +1388,65 @@ export function Editor({ } }, [editor, currentNote, getMarkdown]); + const handleRejectAiDiffBlock = useCallback( + (blockId: string) => { + if (!editor || !aiDiffSession) return; + + const targetBlock = aiDiffSession.blocks.find((block) => block.id === blockId); + if (!targetBlock) return; + + const editorDom = editor.view.dom as HTMLElement; + const escapedId = + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape(blockId) + : blockId.replace(/"/g, '\\"'); + const blockSelector = `.ai-diff-indicator-block[data-ai-diff-block-id="${escapedId}"]`; + const blockElement = editorDom.querySelector(blockSelector); + + if (!blockElement) { + toast.error("Could not find the selected diff block"); + return; + } + + try { + const from = editor.view.posAtDOM(blockElement, 0); + const to = editor.view.posAtDOM( + blockElement, + blockElement.childNodes.length, + ); + + if (to <= from) return; + + let tr = editor.state.tr; + if (targetBlock.originalBlock) { + const originalNode = editor.state.schema.nodeFromJSON( + targetBlock.originalBlock, + ); + tr = tr.replaceWith(from, to, originalNode); + } else if (targetBlock.indicatorType === "add") { + tr = tr.delete(from, to); + } else { + toast.error("Original content is unavailable for this block"); + return; + } + + editor.view.dispatch(tr); + + const nextSession = createAiDiffSession({ + schema: editor.state.schema, + before: aiDiffSession.before, + after: editor.getJSON(), + }); + + onAiDiffSessionChange?.(nextSession); + } catch (error) { + console.error("Failed to reject AI diff block:", error); + toast.error("Failed to reject changes for this block"); + } + }, + [editor, aiDiffSession, onAiDiffSessionChange], + ); + // Toggle source mode const toggleSourceMode = useCallback(() => { if (!editor) return; @@ -1746,6 +1815,12 @@ export function Editor({ ) : ( <> + {searchOpen && (
diff --git a/src/components/editor/diff-indicator/decorations.ts b/src/components/editor/diff-indicator/decorations.ts new file mode 100644 index 00000000..5a163a26 --- /dev/null +++ b/src/components/editor/diff-indicator/decorations.ts @@ -0,0 +1,552 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { AiDiffBlock, AiDiffSession } from "../../../lib/diff"; + +const CODE_BLOCK_DELETE_TEXT_LIMIT = 240; +const DEFAULT_DELETE_TEXT_LIMIT = 80; +const TABLE_BLOCK_DELETE_TEXT_LIMIT = 360; +const TABLE_ALIGNMENT_EPSILON = 1e-6; + +function clampPosition(value: number, size: number): number { + return Math.max(0, Math.min(value, size)); +} + +function createDeletedWidget( + text: string, + isCodeLikeBlock: boolean, +): HTMLElement { + const span = document.createElement("span"); + span.className = isCodeLikeBlock + ? "ai-diff-word-delete ai-diff-word-delete--code" + : "ai-diff-word-delete"; + span.textContent = text; + return span; +} + +function isInlineWidgetAnchor(doc: ProseMirrorNode, position: number): boolean { + const resolved = doc.resolve(position); + return resolved.parent.isTextblock && resolved.parent.inlineContent; +} + +function findNearestInlineWidgetAnchor( + doc: ProseMirrorNode, + preferredPosition: number, + minPosition: number, + maxPosition: number, +): number | null { + const min = Math.max(0, minPosition); + const max = Math.min(maxPosition, doc.content.size); + if (max < min) return null; + + const preferred = Math.max(min, Math.min(preferredPosition, max)); + if (isInlineWidgetAnchor(doc, preferred)) return preferred; + + const maxSearchSteps = Math.min(160, max - min); + for (let step = 1; step <= maxSearchSteps; step += 1) { + const backward = preferred - step; + if (backward >= min && isInlineWidgetAnchor(doc, backward)) { + return backward; + } + + const forward = preferred + step; + if (forward <= max && isInlineWidgetAnchor(doc, forward)) { + return forward; + } + } + + if (isInlineWidgetAnchor(doc, min)) return min; + if (isInlineWidgetAnchor(doc, max)) return max; + return null; +} + +type TableCellSnapshot = { + text: string; + isHeader: boolean; +}; + +type TableSnapshot = { + rows: TableCellSnapshot[][]; +}; + +type TableNodeInfo = { + node: ProseMirrorNode; + pos: number; +}; + +function createDeletedTableCellWidget( + text: string, + isHeader: boolean, +): HTMLElement { + const cell = document.createElement(isHeader ? "th" : "td"); + cell.className = isHeader + ? "ai-diff-table-delete-cell ai-diff-table-delete-cell--header" + : "ai-diff-table-delete-cell"; + cell.setAttribute("contenteditable", "false"); + + const content = document.createElement("span"); + content.className = "ai-diff-word-delete"; + content.textContent = text.length > 0 ? text : " "; + cell.appendChild(content); + return cell; +} + +function getTableNodeInfo( + doc: ProseMirrorNode, + blockFrom: number, + blockTo: number, +): TableNodeInfo | null { + const direct = doc.nodeAt(blockFrom); + if (direct?.type.name === "table") { + return { node: direct, pos: blockFrom }; + } + + let found: TableNodeInfo | null = null; + doc.nodesBetween(blockFrom, blockTo, (node, pos) => { + if (node.type.name === "table") { + found = { node, pos }; + return false; + } + return true; + }); + + return found; +} + +function parseOriginalTableSnapshot( + doc: ProseMirrorNode, + activeBlock: AiDiffBlock, +): TableSnapshot | null { + if (!activeBlock.originalBlock) return null; + + try { + const originalNode = doc.type.schema.nodeFromJSON(activeBlock.originalBlock); + if (originalNode.type.name !== "table") return null; + return extractTableSnapshot(originalNode); + } catch { + return null; + } +} + +function extractTableSnapshot(tableNode: ProseMirrorNode): TableSnapshot { + const rows: TableCellSnapshot[][] = []; + + tableNode.forEach((rowNode) => { + if (rowNode.type.name !== "tableRow") { + rows.push([]); + return; + } + + const rowCells: TableCellSnapshot[] = []; + rowNode.forEach((cellNode) => { + if ( + cellNode.type.name !== "tableCell" && + cellNode.type.name !== "tableHeader" + ) { + return; + } + + rowCells.push({ + text: cellNode.textContent, + isHeader: cellNode.type.name === "tableHeader", + }); + }); + + rows.push(rowCells); + }); + + return { rows }; +} + +function getMaxColumnCount(snapshot: TableSnapshot): number { + return snapshot.rows.reduce((max, row) => Math.max(max, row.length), 0); +} + +function normalizeTableCellText(value: string): string { + return value.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function tokenizeNormalizedText(value: string): Set { + if (!value) return new Set(); + return new Set(value.split(" ").filter(Boolean)); +} + +function computeCellTextSimilarity(beforeText: string, afterText: string): number { + if (beforeText === afterText) { + return beforeText.length > 0 ? 1 : 0.35; + } + if (!beforeText || !afterText) return 0; + if (beforeText.includes(afterText) || afterText.includes(beforeText)) { + return 0.62; + } + + const beforeTokens = tokenizeNormalizedText(beforeText); + const afterTokens = tokenizeNormalizedText(afterText); + if (beforeTokens.size === 0 || afterTokens.size === 0) return 0; + + let intersection = 0; + const [smaller, larger] = + beforeTokens.size <= afterTokens.size + ? [beforeTokens, afterTokens] + : [afterTokens, beforeTokens]; + for (const token of smaller) { + if (larger.has(token)) intersection += 1; + } + + const union = beforeTokens.size + afterTokens.size - intersection; + if (union <= 0) return 0; + return (intersection / union) * 0.85; +} + +function getColumnVector(snapshot: TableSnapshot, columnIndex: number): string[] { + return snapshot.rows.map((row) => + normalizeTableCellText(row[columnIndex]?.text ?? ""), + ); +} + +function computeColumnSimilarity( + beforeSnapshot: TableSnapshot, + afterSnapshot: TableSnapshot, + beforeColumnIndex: number, + afterColumnIndex: number, +): number { + const beforeVector = getColumnVector(beforeSnapshot, beforeColumnIndex); + const afterVector = getColumnVector(afterSnapshot, afterColumnIndex); + const rowCount = Math.max(beforeVector.length, afterVector.length); + if (rowCount === 0) return 1; + + let score = 0; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + score += computeCellTextSimilarity( + beforeVector[rowIndex] ?? "", + afterVector[rowIndex] ?? "", + ); + } + + return score / rowCount; +} + +function alignTableColumns( + beforeSnapshot: TableSnapshot, + afterSnapshot: TableSnapshot, +): { beforeToAfter: Map; deletedBeforeColumnIndexes: number[] } { + const beforeCount = getMaxColumnCount(beforeSnapshot); + const afterCount = getMaxColumnCount(afterSnapshot); + const beforeToAfter = new Map(); + + if (beforeCount <= afterCount) { + return { beforeToAfter, deletedBeforeColumnIndexes: [] }; + } + + if (afterCount === 0) { + const deletedBeforeColumnIndexes = + Array.from({ length: beforeCount }, (_, index) => index); + return { beforeToAfter, deletedBeforeColumnIndexes }; + } + + const similarityCache = new Map(); + const getSimilarity = (beforeIndex: number, afterIndex: number): number => { + const key = `${beforeIndex}:${afterIndex}`; + const cached = similarityCache.get(key); + if (cached !== undefined) return cached; + + const similarity = computeColumnSimilarity( + beforeSnapshot, + afterSnapshot, + beforeIndex, + afterIndex, + ); + similarityCache.set(key, similarity); + return similarity; + }; + + const dp = Array.from({ length: beforeCount + 1 }, () => + new Array(afterCount + 1).fill(0), + ); + + for (let beforeIndex = beforeCount - 1; beforeIndex >= 0; beforeIndex -= 1) { + for (let afterIndex = afterCount - 1; afterIndex >= 0; afterIndex -= 1) { + const match = + getSimilarity(beforeIndex, afterIndex) + + dp[beforeIndex + 1][afterIndex + 1]; + const skipBefore = dp[beforeIndex + 1][afterIndex]; + const skipAfter = dp[beforeIndex][afterIndex + 1]; + dp[beforeIndex][afterIndex] = Math.max(match, skipBefore, skipAfter); + } + } + + let beforeIndex = 0; + let afterIndex = 0; + while (beforeIndex < beforeCount && afterIndex < afterCount) { + const similarity = getSimilarity(beforeIndex, afterIndex); + const match = similarity + dp[beforeIndex + 1][afterIndex + 1]; + const skipBefore = dp[beforeIndex + 1][afterIndex]; + const skipAfter = dp[beforeIndex][afterIndex + 1]; + + if ( + match >= skipBefore - TABLE_ALIGNMENT_EPSILON && + match >= skipAfter - TABLE_ALIGNMENT_EPSILON + ) { + beforeToAfter.set(beforeIndex, afterIndex); + beforeIndex += 1; + afterIndex += 1; + continue; + } + + if (skipBefore >= skipAfter) { + beforeIndex += 1; + } else { + afterIndex += 1; + } + } + + const deletedBeforeColumnIndexes = Array.from( + { length: beforeCount }, + (_, index) => index, + ).filter((index) => !beforeToAfter.has(index)); + + return { beforeToAfter, deletedBeforeColumnIndexes }; +} + +function resolveDeletedColumnInsertIndex( + deletedBeforeColumnIndex: number, + sortedMatchedBeforeIndexes: number[], + beforeToAfter: Map, +): number { + for (const matchedBeforeIndex of sortedMatchedBeforeIndexes) { + if (matchedBeforeIndex > deletedBeforeColumnIndex) { + return beforeToAfter.get(matchedBeforeIndex) ?? 0; + } + } + + for (let index = sortedMatchedBeforeIndexes.length - 1; index >= 0; index -= 1) { + const matchedBeforeIndex = sortedMatchedBeforeIndexes[index]; + if (matchedBeforeIndex < deletedBeforeColumnIndex) { + return (beforeToAfter.get(matchedBeforeIndex) ?? 0) + 1; + } + } + + return 0; +} + +function buildTableDeletedColumnDecorations( + doc: ProseMirrorNode, + activeBlock: AiDiffBlock, + blockFrom: number, + blockTo: number, +): Decoration[] { + const tableInfo = getTableNodeInfo(doc, blockFrom, blockTo); + if (!tableInfo) return []; + + const afterSnapshot = extractTableSnapshot(tableInfo.node); + const beforeSnapshot = parseOriginalTableSnapshot(doc, activeBlock); + if (!beforeSnapshot) return []; + + const { beforeToAfter, deletedBeforeColumnIndexes } = alignTableColumns( + beforeSnapshot, + afterSnapshot, + ); + if (deletedBeforeColumnIndexes.length === 0) return []; + + const sortedMatchedBeforeIndexes = Array.from(beforeToAfter.keys()).sort( + (a, b) => a - b, + ); + const sortedDeletedBeforeIndexes = [...deletedBeforeColumnIndexes].sort( + (a, b) => a - b, + ); + + const decorations: Decoration[] = []; + let rowIndex = 0; + + tableInfo.node.forEach((rowNode, rowOffset) => { + if (rowNode.type.name !== "tableRow") return; + + const rowPos = tableInfo.pos + 1 + rowOffset; + const rowCellOffsets: number[] = []; + rowNode.forEach((_cellNode, cellOffset) => { + rowCellOffsets.push(cellOffset); + }); + const rowCellCount = rowCellOffsets.length; + + for ( + let deletedOrder = 0; + deletedOrder < sortedDeletedBeforeIndexes.length; + deletedOrder += 1 + ) { + const deletedBeforeIndex = sortedDeletedBeforeIndexes[deletedOrder]; + const insertIndex = resolveDeletedColumnInsertIndex( + deletedBeforeIndex, + sortedMatchedBeforeIndexes, + beforeToAfter, + ); + const clampedInsertIndex = Math.max(0, Math.min(insertIndex, rowCellCount)); + const anchorPos = + clampedInsertIndex < rowCellCount + ? rowPos + 1 + rowCellOffsets[clampedInsertIndex] + : rowPos + rowNode.nodeSize - 1; + + const beforeCell = beforeSnapshot.rows[rowIndex]?.[deletedBeforeIndex]; + const isHeader = beforeCell?.isHeader ?? false; + const cellText = beforeCell?.text ?? ""; + + decorations.push( + Decoration.widget( + anchorPos, + () => createDeletedTableCellWidget(cellText, isHeader), + { + side: -100 + deletedOrder, + ignoreSelection: true, + key: `table-column-delete-${activeBlock.id}-${rowIndex}-${deletedBeforeIndex}`, + }, + ), + ); + } + + rowIndex += 1; + }); + + return decorations; +} + +function buildAiDiffBlockDecorations( + doc: ProseMirrorNode, + aiDiffSession: AiDiffSession | null | undefined, +): Decoration[] { + if (!aiDiffSession || aiDiffSession.blocks.length === 0) return []; + + const docSize = doc.content.size; + + return aiDiffSession.blocks + .map((block) => { + const from = clampPosition(block.from, docSize); + const to = clampPosition(block.to, docSize); + if (to <= from) return null; + + const classes: string[] = []; + if (block.indicatorType) { + classes.push( + "ai-diff-indicator-block", + `ai-diff-indicator-block--${block.indicatorType}`, + ); + } + if (block.hasDeletionAnchor) { + classes.push("ai-diff-deletion-anchor-block"); + } + + if (classes.length === 0) return null; + + return Decoration.node(from, to, { + class: classes.join(" "), + "data-ai-diff-block-id": block.id, + }); + }) + .filter((decoration): decoration is Decoration => decoration !== null); +} + +function buildAiWordDiffDecorations( + doc: ProseMirrorNode, + aiDiffSession: AiDiffSession | null | undefined, + activeBlockId: string | null, +): Decoration[] { + if (!aiDiffSession || !activeBlockId) return []; + + const activeBlock = aiDiffSession.blocks.find( + (block) => block.id === activeBlockId && !!block.indicatorType, + ); + if (!activeBlock) return []; + + const isCodeLikeBlock = + activeBlock.blockType === "codeBlock" || + activeBlock.blockType === "frontmatter"; + const isTableBlock = activeBlock.blockType === "table"; + + const docSize = doc.content.size; + const blockFrom = clampPosition(activeBlock.from, docSize); + const blockTo = clampPosition(activeBlock.to, docSize); + if (blockTo <= blockFrom) return []; + + const blockContentFrom = Math.min(blockTo, blockFrom + 1); + const blockContentTo = Math.max(blockContentFrom, blockTo - 1); + const decorations: Decoration[] = []; + const tableDeletedColumnDecorations = isTableBlock + ? buildTableDeletedColumnDecorations(doc, activeBlock, blockFrom, blockTo) + : []; + + for (const changeIndex of activeBlock.relatedChangeIndexes) { + const change = aiDiffSession.changes[changeIndex]; + if (!change) continue; + + const insertedFrom = clampPosition(change.fromB, docSize); + const insertedTo = clampPosition(change.toB, docSize); + const from = Math.max(insertedFrom, blockContentFrom); + const to = Math.min(insertedTo, blockContentTo); + + if (to > from) { + decorations.push( + Decoration.inline(from, to, { + class: "ai-diff-word-add", + }), + ); + } + + const maxDeleteTextLength = isCodeLikeBlock + ? CODE_BLOCK_DELETE_TEXT_LIMIT + : isTableBlock + ? TABLE_BLOCK_DELETE_TEXT_LIMIT + : DEFAULT_DELETE_TEXT_LIMIT; + const shouldRenderDeletedWidget = + change.deletedText.length > 0 && + change.deletedText.length <= maxDeleteTextLength && + (isCodeLikeBlock || isTableBlock || !change.deletedText.includes("\n")); + + if (!shouldRenderDeletedWidget) continue; + if (isTableBlock) continue; + + const preferredAnchor = Math.max( + blockContentFrom, + Math.min(clampPosition(change.fromB, docSize), blockContentTo), + ); + const inlineAnchor = findNearestInlineWidgetAnchor( + doc, + preferredAnchor, + blockContentFrom, + blockContentTo, + ); + + if (inlineAnchor !== null) { + decorations.push( + Decoration.widget( + inlineAnchor, + () => createDeletedWidget(change.deletedText, isCodeLikeBlock), + { + side: -1, + ignoreSelection: true, + }, + ), + ); + } + } + + decorations.push(...tableDeletedColumnDecorations); + return decorations; +} + +export function createAiDiffBlockDecorationSet( + doc: ProseMirrorNode, + aiDiffSession: AiDiffSession | null | undefined, +): DecorationSet { + return DecorationSet.create( + doc, + buildAiDiffBlockDecorations(doc, aiDiffSession), + ); +} + +export function createAiWordDiffDecorationSet( + doc: ProseMirrorNode, + aiDiffSession: AiDiffSession | null | undefined, + activeBlockId: string | null, +): DecorationSet { + return DecorationSet.create( + doc, + buildAiWordDiffDecorations(doc, aiDiffSession, activeBlockId), + ); +} diff --git a/src/components/editor/diff-indicator/extension.ts b/src/components/editor/diff-indicator/extension.ts new file mode 100644 index 00000000..2868e958 --- /dev/null +++ b/src/components/editor/diff-indicator/extension.ts @@ -0,0 +1,60 @@ +import { Extension } from "@tiptap/core"; +import type { Editor as TiptapEditor } from "@tiptap/react"; +import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; +import { DecorationSet } from "@tiptap/pm/view"; + +type DecorationPluginMeta = { + decorationSet: DecorationSet; +}; + +export const aiDiffIndicatorPluginKey = new PluginKey( + "aiDiffIndicator", +); +export const aiDiffWordDiffPluginKey = new PluginKey( + "aiDiffWordDiff", +); + +function getDecorationSetFromMeta( + transaction: Transaction, + key: PluginKey, +): DecorationSet | null { + const meta = transaction.getMeta(key) as DecorationPluginMeta | undefined; + return meta?.decorationSet ?? null; +} + +function createDecorationPlugin(key: PluginKey): Plugin { + return new Plugin({ + key, + state: { + init: () => DecorationSet.empty, + apply: (transaction, oldSet) => { + const nextSet = getDecorationSetFromMeta(transaction, key); + if (nextSet) return nextSet; + return oldSet.map(transaction.mapping, transaction.doc); + }, + }, + props: { + decorations: (state) => key.getState(state), + }, + }); +} + +export function dispatchDecorationSet( + editor: TiptapEditor, + key: PluginKey, + decorationSet: DecorationSet, +): void { + const transaction = editor.state.tr.setMeta(key, { decorationSet }); + editor.view.dispatch(transaction); +} + +export const AiDiffIndicatorExtension = Extension.create({ + name: "aiDiffIndicator", + + addProseMirrorPlugins() { + return [ + createDecorationPlugin(aiDiffIndicatorPluginKey), + createDecorationPlugin(aiDiffWordDiffPluginKey), + ]; + }, +}); diff --git a/src/components/editor/diff-indicator/layout.ts b/src/components/editor/diff-indicator/layout.ts new file mode 100644 index 00000000..83d8c5b6 --- /dev/null +++ b/src/components/editor/diff-indicator/layout.ts @@ -0,0 +1,134 @@ +import type { AiDiffIndicatorType } from "../../../lib/diff"; +import type { DeletionDividerLayout, MarkerLayout } from "./types"; + +const MARKER_VERTICAL_PADDING = 2; +const MARKER_MIN_HEIGHT = 10; +const DELETION_DIVIDER_INSET = 25; +const DELETION_DIVIDER_MIN_WIDTH = 140; + +function parseCssLengthToPx(value: string, baseFontSizePx: number): number { + const raw = value.trim(); + if (!raw) return 0; + + if (raw.endsWith("rem")) { + return Number.parseFloat(raw) * baseFontSizePx; + } + if (raw.endsWith("px")) { + return Number.parseFloat(raw); + } + + const numeric = Number.parseFloat(raw); + return Number.isFinite(numeric) ? numeric : 0; +} + +function resolveIndicatorType(target: HTMLElement): AiDiffIndicatorType { + return target.classList.contains("ai-diff-indicator-block--add") + ? "add" + : "modify"; +} + +function computeDeletionDividerFrame( + editorDom: HTMLElement, + scrollContainer: HTMLDivElement, +): { left: number; width: number } { + const editorRect = editorDom.getBoundingClientRect(); + const scrollRect = scrollContainer.getBoundingClientRect(); + const width = Math.max( + DELETION_DIVIDER_MIN_WIDTH, + editorRect.width - DELETION_DIVIDER_INSET * 2, + ); + const left = editorRect.left - scrollRect.left + DELETION_DIVIDER_INSET; + + return { left, width }; +} + +export function computeIndicatorLeftPx( + editorDom: HTMLElement, + scrollContainer: HTMLDivElement, +): number { + const editorRect = editorDom.getBoundingClientRect(); + const scrollRect = scrollContainer.getBoundingClientRect(); + const editorFontSize = + Number.parseFloat(getComputedStyle(editorDom).fontSize) || 16; + const offsetPx = parseCssLengthToPx( + getComputedStyle(editorDom).getPropertyValue("--ai-diff-indicator-left"), + editorFontSize, + ); + + return editorRect.left - scrollRect.left + offsetPx; +} + +export function collectMarkerLayouts( + editorDom: HTMLElement, + scrollContainer: HTMLDivElement, +): MarkerLayout[] { + const targets = Array.from( + editorDom.querySelectorAll(".ai-diff-indicator-block"), + ); + const scrollRect = scrollContainer.getBoundingClientRect(); + const markersById = new Map(); + + for (const target of targets) { + const blockId = target.dataset.aiDiffBlockId; + if (!blockId) continue; + + const rect = target.getBoundingClientRect(); + if (rect.height <= 0) continue; + + const blockTop = rect.top - scrollRect.top + scrollContainer.scrollTop; + const top = blockTop + MARKER_VERTICAL_PADDING; + const height = Math.max( + MARKER_MIN_HEIGHT, + rect.height - MARKER_VERTICAL_PADDING * 2, + ); + const blockLeft = rect.left - scrollRect.left; + const blockWidth = rect.width; + + markersById.set(blockId, { + blockId, + top, + height, + indicatorType: resolveIndicatorType(target), + blockTop, + blockLeft, + blockWidth, + }); + } + + return Array.from(markersById.values()).sort((a, b) => a.top - b.top); +} + +export function collectDeletionDividerLayouts( + editorDom: HTMLElement, + scrollContainer: HTMLDivElement, +): DeletionDividerLayout[] { + const targets = Array.from( + editorDom.querySelectorAll(".ai-diff-deletion-anchor-block"), + ); + if (targets.length === 0) return []; + + const scrollRect = scrollContainer.getBoundingClientRect(); + const { left, width } = computeDeletionDividerFrame( + editorDom, + scrollContainer, + ); + const markerTopsByKey = new Map(); + + for (const target of targets) { + const rect = target.getBoundingClientRect(); + if (rect.height <= 0) continue; + + const marginTop = + Number.parseFloat(getComputedStyle(target).marginTop) || 0; + const top = + rect.top - scrollRect.top + scrollContainer.scrollTop - marginTop / 2; + const key = `${Math.round(top)}`; + if (!markerTopsByKey.has(key)) { + markerTopsByKey.set(key, top); + } + } + + return Array.from(markerTopsByKey.values()) + .sort((a, b) => a - b) + .map((top) => ({ top, left, width })); +} diff --git a/src/components/editor/diff-indicator/types.ts b/src/components/editor/diff-indicator/types.ts new file mode 100644 index 00000000..9e9d1588 --- /dev/null +++ b/src/components/editor/diff-indicator/types.ts @@ -0,0 +1,17 @@ +import type { AiDiffIndicatorType } from "../../../lib/diff"; + +export type DeletionDividerLayout = { + top: number; + left: number; + width: number; +}; + +export type MarkerLayout = { + blockId: string; + top: number; + height: number; + indicatorType: AiDiffIndicatorType; + blockTop: number; + blockLeft: number; + blockWidth: number; +}; diff --git a/src/components/editor/diff-indicator/useDiffIndicatorState.ts b/src/components/editor/diff-indicator/useDiffIndicatorState.ts new file mode 100644 index 00000000..ad1d397c --- /dev/null +++ b/src/components/editor/diff-indicator/useDiffIndicatorState.ts @@ -0,0 +1,178 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type RefObject, +} from "react"; +import type { Editor as TiptapEditor } from "@tiptap/react"; +import type { AiDiffSession } from "../../../lib/diff"; +import { + createAiDiffBlockDecorationSet, + createAiWordDiffDecorationSet, +} from "./decorations"; +import { + collectDeletionDividerLayouts, + collectMarkerLayouts, + computeIndicatorLeftPx, +} from "./layout"; +import { + aiDiffIndicatorPluginKey, + aiDiffWordDiffPluginKey, + dispatchDecorationSet, +} from "./extension"; +import type { DeletionDividerLayout, MarkerLayout } from "./types"; + +interface UseDiffIndicatorStateParams { + editor: TiptapEditor | null; + aiDiffSession?: AiDiffSession | null; + scrollContainerRef?: RefObject; +} + +interface UseDiffIndicatorStateResult { + markers: MarkerLayout[]; + deletionDividers: DeletionDividerLayout[]; + leftPx: number; + overlayHeight: number; + activeBlockId: string | null; + activeMarker: MarkerLayout | null; + toggleMarker: (blockId: string) => void; + clearActiveBlock: () => void; +} + +export function useDiffIndicatorState({ + editor, + aiDiffSession, + scrollContainerRef, +}: UseDiffIndicatorStateParams): UseDiffIndicatorStateResult { + const [markers, setMarkers] = useState([]); + const [deletionDividers, setDeletionDividers] = useState< + DeletionDividerLayout[] + >([]); + const [leftPx, setLeftPx] = useState(0); + const [overlayHeight, setOverlayHeight] = useState(0); + const [activeBlockId, setActiveBlockId] = useState(null); + + useEffect(() => { + if (!aiDiffSession || aiDiffSession.blocks.length === 0) { + setActiveBlockId(null); + return; + } + + if (!activeBlockId) return; + const activeStillExists = aiDiffSession.blocks.some( + (block) => block.id === activeBlockId && !!block.indicatorType, + ); + if (!activeStillExists) { + setActiveBlockId(null); + } + }, [aiDiffSession, activeBlockId]); + + useEffect(() => { + if (!activeBlockId) return; + const activeMarkerStillVisible = markers.some( + (marker) => marker.blockId === activeBlockId, + ); + if (!activeMarkerStillVisible) { + setActiveBlockId(null); + } + }, [activeBlockId, markers]); + + const updateOverlay = useCallback(() => { + if (!editor || !scrollContainerRef?.current) { + setMarkers([]); + setDeletionDividers([]); + setOverlayHeight(0); + return; + } + + const editorDom = editor.view.dom as HTMLElement; + const scrollContainer = scrollContainerRef.current; + const nextLeftPx = computeIndicatorLeftPx(editorDom, scrollContainer); + const nextMarkers = collectMarkerLayouts(editorDom, scrollContainer); + const nextDeletionDividers = collectDeletionDividerLayouts( + editorDom, + scrollContainer, + ); + + setLeftPx(nextLeftPx); + setMarkers(nextMarkers); + setDeletionDividers(nextDeletionDividers); + setOverlayHeight(scrollContainer.scrollHeight); + }, [editor, scrollContainerRef]); + + useEffect(() => { + if (!editor) return; + + const decorationSet = createAiDiffBlockDecorationSet( + editor.state.doc, + aiDiffSession, + ); + dispatchDecorationSet(editor, aiDiffIndicatorPluginKey, decorationSet); + requestAnimationFrame(updateOverlay); + }, [editor, aiDiffSession, updateOverlay]); + + useEffect(() => { + if (!editor) return; + + const decorationSet = createAiWordDiffDecorationSet( + editor.state.doc, + aiDiffSession, + activeBlockId, + ); + dispatchDecorationSet(editor, aiDiffWordDiffPluginKey, decorationSet); + requestAnimationFrame(updateOverlay); + }, [editor, aiDiffSession, activeBlockId, updateOverlay]); + + useEffect(() => { + if (!editor || !scrollContainerRef?.current) return; + + const editorDom = editor.view.dom as HTMLElement; + const scrollContainer = scrollContainerRef.current; + const scheduleOverlayUpdate = () => { + requestAnimationFrame(updateOverlay); + }; + + const resizeObserver = new ResizeObserver(scheduleOverlayUpdate); + resizeObserver.observe(editorDom); + resizeObserver.observe(scrollContainer); + + editor.on("transaction", scheduleOverlayUpdate); + scrollContainer.addEventListener("scroll", scheduleOverlayUpdate, { + passive: true, + }); + window.addEventListener("resize", scheduleOverlayUpdate); + scheduleOverlayUpdate(); + + return () => { + resizeObserver.disconnect(); + editor.off("transaction", scheduleOverlayUpdate); + scrollContainer.removeEventListener("scroll", scheduleOverlayUpdate); + window.removeEventListener("resize", scheduleOverlayUpdate); + }; + }, [editor, scrollContainerRef, updateOverlay]); + + const activeMarker = useMemo(() => { + if (!activeBlockId) return null; + return markers.find((marker) => marker.blockId === activeBlockId) ?? null; + }, [activeBlockId, markers]); + + const toggleMarker = useCallback((blockId: string) => { + setActiveBlockId((current) => (current === blockId ? null : blockId)); + }, []); + + const clearActiveBlock = useCallback(() => { + setActiveBlockId(null); + }, []); + + return { + markers, + deletionDividers, + leftPx, + overlayHeight, + activeBlockId, + activeMarker, + toggleMarker, + clearActiveBlock, + }; +} diff --git a/src/hooks/useWaitForUpdatedEditorSnapshot.ts b/src/hooks/useWaitForUpdatedEditorSnapshot.ts new file mode 100644 index 00000000..e1ce395a --- /dev/null +++ b/src/hooks/useWaitForUpdatedEditorSnapshot.ts @@ -0,0 +1,39 @@ +import { useCallback, type RefObject } from "react"; +import type { Editor as TiptapEditor } from "@tiptap/react"; + +export function useWaitForUpdatedEditorSnapshot( + editorRef: RefObject, +) { + return useCallback( + async (beforeSerialized: string) => { + // Let React commit currentNote updates and Editor's setContent run. + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + + if (!editorRef.current) { + throw new Error("Editor not ready"); + } + + let afterJson = editorRef.current.getJSON(); + let afterSerialized = JSON.stringify(afterJson); + + // If content hasn't changed yet, poll briefly for async editor updates. + if (afterSerialized === beforeSerialized) { + const deadline = performance.now() + 500; + while (performance.now() < deadline) { + await new Promise((resolve) => { + setTimeout(resolve, 16); + }); + if (!editorRef.current) break; + afterJson = editorRef.current.getJSON(); + afterSerialized = JSON.stringify(afterJson); + if (afterSerialized !== beforeSerialized) break; + } + } + + return afterJson; + }, + [editorRef], + ); +} diff --git a/src/lib/diff.ts b/src/lib/diff.ts new file mode 100644 index 00000000..b0f4dbf2 --- /dev/null +++ b/src/lib/diff.ts @@ -0,0 +1,818 @@ +import type { JSONContent } from "@tiptap/core"; +import type { Schema, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { StepMap } from "@tiptap/pm/transform"; +import { + ChangeSet, + simplifyChanges, + type ChangeJSON, +} from "prosemirror-changeset"; + +export interface AiEditDiffInput { + schema: Schema; + before: JSONContent; + after: JSONContent; + metadata?: Data; +} + +export type AiEditChangeKind = "add" | "modify" | "delete-block"; +export type AiDiffIndicatorType = "add" | "modify"; + +export type AiEditRawChange = ChangeJSON & { + kind: AiEditChangeKind; + deletedText: string; +}; + +type BlockRange = { + from: number; + to: number; +}; + +type IndexedTopLevelBlockRange = BlockRange & { + index: number; + node: ProseMirrorNode; +}; + +type BlockAlignment = { + afterToBeforeIndex: Map; + newAfterIndexes: Set; + deletionAnchorAfterIndexes: Set; +}; + +type BlockPositionPair = { + beforePosition: number; + afterPosition: number; +}; + +const BLOCK_SIMILARITY_THRESHOLD = 0.35; +const SIMILARITY_EPSILON = 1e-6; +const BLOCK_TEXT_TOKEN_PATTERN = /[\p{L}\p{N}]+/gu; + +export interface AiDiffBlock { + id: string; + from: number; + to: number; + blockType: string; + indicatorType?: AiDiffIndicatorType; + hasDeletionAnchor: boolean; + relatedChangeIndexes: number[]; + originalBlock: JSONContent | null; +} + +export interface AiDiffSession { + before: JSONContent; + after: JSONContent; + changes: AiEditRawChange[]; + blocks: AiDiffBlock[]; +} + +function getTopLevelBlockRanges( + doc: ProseMirrorNode, +): IndexedTopLevelBlockRange[] { + const ranges: IndexedTopLevelBlockRange[] = []; + let blockIndex = 0; + + doc.forEach((node, offset) => { + if (!node.isBlock) return; + ranges.push({ + from: offset, + to: offset + node.nodeSize, + index: blockIndex, + node, + }); + blockIndex += 1; + }); + + return ranges; +} + +function getMergedIndicatorType( + existingType: AiDiffIndicatorType | undefined, + nextType: AiDiffIndicatorType, +): AiDiffIndicatorType { + if (!existingType) return nextType; + if (existingType === "modify" || nextType === "modify") return "modify"; + return "add"; +} + +function overlapsRange(range: BlockRange, from: number, to: number): boolean { + return from < range.to && to > range.from; +} + +function findContainingRanges( + ranges: IndexedTopLevelBlockRange[], + from: number, + to: number, +): IndexedTopLevelBlockRange[] { + return ranges.filter((range) => overlapsRange(range, from, to)); +} + +function findNearestBlockForCollapsedPosition( + ranges: IndexedTopLevelBlockRange[], + position: number, +): IndexedTopLevelBlockRange | null { + if (ranges.length === 0) return null; + + const containing = ranges.find((range) => { + return position >= range.from && position < range.to; + }); + if (containing) return containing; + + if (position >= ranges[ranges.length - 1].to) { + return ranges[ranges.length - 1]; + } + + let nearest: IndexedTopLevelBlockRange | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const range of ranges) { + const distance = + position < range.from + ? range.from - position + : position > range.to + ? position - range.to + : 0; + + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = range; + } + } + + return nearest; +} + +function tokenizeBlockText(text: string): Set { + const normalized = text.toLowerCase().trim(); + if (!normalized) return new Set(); + const matches = normalized.match(BLOCK_TEXT_TOKEN_PATTERN) ?? []; + return new Set(matches); +} + +function computeJaccardSimilarity(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 1; + if (a.size === 0 || b.size === 0) return 0; + + let intersection = 0; + const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a]; + for (const token of smaller) { + if (larger.has(token)) intersection += 1; + } + + const union = a.size + b.size - intersection; + if (union === 0) return 0; + return intersection / union; +} + +function computeBlockSimilarity( + beforeRange: IndexedTopLevelBlockRange, + afterRange: IndexedTopLevelBlockRange, +): number { + if (beforeRange.node.type.name !== afterRange.node.type.name) return 0; + + const beforeTokens = tokenizeBlockText(beforeRange.node.textContent); + const afterTokens = tokenizeBlockText(afterRange.node.textContent); + return computeJaccardSimilarity(beforeTokens, afterTokens); +} + +function findExactEqLcsPairs( + beforeRanges: IndexedTopLevelBlockRange[], + afterRanges: IndexedTopLevelBlockRange[], +): BlockPositionPair[] { + const beforeCount = beforeRanges.length; + const afterCount = afterRanges.length; + const dp = Array.from({ length: beforeCount + 1 }, () => + new Array(afterCount + 1).fill(0), + ); + + for (let i = beforeCount - 1; i >= 0; i -= 1) { + for (let j = afterCount - 1; j >= 0; j -= 1) { + if (beforeRanges[i].node.eq(afterRanges[j].node)) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + const pairs: BlockPositionPair[] = []; + let i = 0; + let j = 0; + while (i < beforeCount && j < afterCount) { + if (beforeRanges[i].node.eq(afterRanges[j].node)) { + pairs.push({ + beforePosition: i, + afterPosition: j, + }); + i += 1; + j += 1; + continue; + } + + if (dp[i + 1][j] >= dp[i][j + 1]) { + i += 1; + } else { + j += 1; + } + } + + return pairs; +} + +function findOrderedSimilarityPairs( + beforeRanges: IndexedTopLevelBlockRange[], + afterRanges: IndexedTopLevelBlockRange[], +): Array<{ beforeIndex: number; afterIndex: number }> { + if (beforeRanges.length === 0 || afterRanges.length === 0) return []; + + const beforeCount = beforeRanges.length; + const afterCount = afterRanges.length; + const similarityCache = new Map(); + const getSimilarity = (i: number, j: number): number => { + const key = `${i}:${j}`; + const cached = similarityCache.get(key); + if (cached !== undefined) return cached; + + const similarity = computeBlockSimilarity(beforeRanges[i], afterRanges[j]); + similarityCache.set(key, similarity); + return similarity; + }; + + const dp = Array.from({ length: beforeCount + 1 }, () => + new Array(afterCount + 1).fill(0), + ); + + for (let i = beforeCount - 1; i >= 0; i -= 1) { + for (let j = afterCount - 1; j >= 0; j -= 1) { + const skipBefore = dp[i + 1][j]; + const skipAfter = dp[i][j + 1]; + let best = Math.max(skipBefore, skipAfter); + + const similarity = getSimilarity(i, j); + if (similarity >= BLOCK_SIMILARITY_THRESHOLD) { + best = Math.max(best, similarity + dp[i + 1][j + 1]); + } + + dp[i][j] = best; + } + } + + const pairs: Array<{ beforeIndex: number; afterIndex: number }> = []; + let i = 0; + let j = 0; + while (i < beforeCount && j < afterCount) { + const similarity = getSimilarity(i, j); + const canMatch = similarity >= BLOCK_SIMILARITY_THRESHOLD; + const matchScore = canMatch ? similarity + dp[i + 1][j + 1] : -Infinity; + const skipBefore = dp[i + 1][j]; + const skipAfter = dp[i][j + 1]; + + if ( + canMatch && + matchScore >= skipBefore - SIMILARITY_EPSILON && + matchScore >= skipAfter - SIMILARITY_EPSILON + ) { + pairs.push({ + beforeIndex: beforeRanges[i].index, + afterIndex: afterRanges[j].index, + }); + i += 1; + j += 1; + continue; + } + + if (skipBefore >= skipAfter) { + i += 1; + } else { + j += 1; + } + } + + return pairs; +} + +function collectContiguousRuns(indexes: number[]): Array<{ start: number; end: number }> { + if (indexes.length === 0) return []; + + const sorted = [...indexes].sort((a, b) => a - b); + const runs: Array<{ start: number; end: number }> = []; + let runStart = sorted[0]; + let runEnd = sorted[0]; + + for (let i = 1; i < sorted.length; i += 1) { + const current = sorted[i]; + if (current === runEnd + 1) { + runEnd = current; + continue; + } + + runs.push({ start: runStart, end: runEnd }); + runStart = current; + runEnd = current; + } + + runs.push({ start: runStart, end: runEnd }); + return runs; +} + +function resolveDeletionAnchorAfterIndex( + deletedBeforeIndex: number, + sortedMatchedBeforeIndexes: number[], + beforeToAfterIndex: Map, + afterTopLevelRanges: IndexedTopLevelBlockRange[], +): number | null { + for (const matchedBeforeIndex of sortedMatchedBeforeIndexes) { + if (matchedBeforeIndex > deletedBeforeIndex) { + return beforeToAfterIndex.get(matchedBeforeIndex) ?? null; + } + } + + for (let i = sortedMatchedBeforeIndexes.length - 1; i >= 0; i -= 1) { + const matchedBeforeIndex = sortedMatchedBeforeIndexes[i]; + if (matchedBeforeIndex < deletedBeforeIndex) { + return beforeToAfterIndex.get(matchedBeforeIndex) ?? null; + } + } + + if (afterTopLevelRanges.length === 0) return null; + + const fallbackPosition = Math.max( + 0, + Math.min(deletedBeforeIndex, afterTopLevelRanges.length - 1), + ); + return afterTopLevelRanges[fallbackPosition]?.index ?? null; +} + +function alignTopLevelBlocks( + beforeTopLevelRanges: IndexedTopLevelBlockRange[], + afterTopLevelRanges: IndexedTopLevelBlockRange[], +): BlockAlignment { + const afterToBeforeIndex = new Map(); + const exactPairs = findExactEqLcsPairs(beforeTopLevelRanges, afterTopLevelRanges); + + for (const pair of exactPairs) { + const beforeIndex = beforeTopLevelRanges[pair.beforePosition]?.index; + const afterIndex = afterTopLevelRanges[pair.afterPosition]?.index; + if (beforeIndex === undefined || afterIndex === undefined) continue; + afterToBeforeIndex.set(afterIndex, beforeIndex); + } + + const anchors: BlockPositionPair[] = [ + { beforePosition: -1, afterPosition: -1 }, + ...exactPairs, + { + beforePosition: beforeTopLevelRanges.length, + afterPosition: afterTopLevelRanges.length, + }, + ]; + + for (let i = 0; i < anchors.length - 1; i += 1) { + const current = anchors[i]; + const next = anchors[i + 1]; + const beforeSegment = beforeTopLevelRanges.slice( + current.beforePosition + 1, + next.beforePosition, + ); + const afterSegment = afterTopLevelRanges.slice( + current.afterPosition + 1, + next.afterPosition, + ); + + const similarPairs = findOrderedSimilarityPairs(beforeSegment, afterSegment); + for (const pair of similarPairs) { + if (!afterToBeforeIndex.has(pair.afterIndex)) { + afterToBeforeIndex.set(pair.afterIndex, pair.beforeIndex); + } + } + } + + const matchedBeforeIndexes = new Set(afterToBeforeIndex.values()); + const newAfterIndexes = new Set(); + for (const range of afterTopLevelRanges) { + if (!afterToBeforeIndex.has(range.index)) { + newAfterIndexes.add(range.index); + } + } + + const unmatchedBeforeIndexes = beforeTopLevelRanges + .map((range) => range.index) + .filter((index) => !matchedBeforeIndexes.has(index)); + const unmatchedBeforeRuns = collectContiguousRuns(unmatchedBeforeIndexes); + + const beforeToAfterIndex = new Map(); + for (const [afterIndex, beforeIndex] of afterToBeforeIndex.entries()) { + beforeToAfterIndex.set(beforeIndex, afterIndex); + } + const sortedMatchedBeforeIndexes = Array.from(beforeToAfterIndex.keys()).sort( + (a, b) => a - b, + ); + + const deletionAnchorAfterIndexes = new Set(); + for (const run of unmatchedBeforeRuns) { + const anchorAfterIndex = resolveDeletionAnchorAfterIndex( + run.start, + sortedMatchedBeforeIndexes, + beforeToAfterIndex, + afterTopLevelRanges, + ); + if (anchorAfterIndex !== null) { + deletionAnchorAfterIndexes.add(anchorAfterIndex); + } + } + + return { + afterToBeforeIndex, + newAfterIndexes, + deletionAnchorAfterIndexes, + }; +} + +function findDeletedBeforeBlocksForChange( + change: ChangeJSON, + beforeTopLevelRanges: IndexedTopLevelBlockRange[], +): IndexedTopLevelBlockRange[] { + const deletedFrom = Math.min(change.fromA, change.toA); + const deletedTo = Math.max(change.fromA, change.toA); + if (deletedTo <= deletedFrom) return []; + + return beforeTopLevelRanges.filter((range) => { + const startsBeforeOrAtBlock = deletedFrom <= range.from + 1; + const endsAfterOrAtBlock = deletedTo >= range.to - 1; + return startsBeforeOrAtBlock && endsAfterOrAtBlock; + }); +} + +function inferChangeKind( + change: ChangeJSON, + beforeTopLevelRanges: IndexedTopLevelBlockRange[], +): AiEditChangeKind { + const insertedLength = Math.max(0, change.toB - change.fromB); + const deletedLength = Math.max(0, change.toA - change.fromA); + + if (insertedLength > 0 && deletedLength === 0) { + return "add"; + } + + if (insertedLength === 0 && deletedLength > 0) { + const containsDeletedTopLevelBlock = beforeTopLevelRanges.some( + // In practice, deleted block spans are often shifted by +1 at the start + // compared with top-level offsets, so allow that tolerance. + (range) => change.fromA <= range.from + 1 && change.toA >= range.to, + ); + + if (containsDeletedTopLevelBlock) { + return "delete-block"; + } + } + + return "modify"; +} + +function parseSnapshot( + schema: Schema, + snapshot: JSONContent, + label: "before" | "after", +): ProseMirrorNode { + try { + return schema.nodeFromJSON(snapshot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Invalid ${label} snapshot for the provided schema: ${message}`, + ); + } +} + +function buildOriginalBlock( + beforeTopLevelRanges: IndexedTopLevelBlockRange[], + changes: AiEditRawChange[], + relatedChangeIndexes: number[], +): JSONContent | null { + if (relatedChangeIndexes.length === 0) return null; + + const hasNonAddChange = relatedChangeIndexes.some( + (index) => changes[index]?.kind !== "add", + ); + if (!hasNonAddChange) return null; + + const candidateScores = new Map(); + const addCandidate = (candidate: IndexedTopLevelBlockRange | null) => { + if (!candidate) return; + candidateScores.set( + candidate.index, + (candidateScores.get(candidate.index) ?? 0) + 1, + ); + }; + + for (const changeIndex of relatedChangeIndexes) { + const change = changes[changeIndex]; + if (!change) continue; + + if (change.toA > change.fromA) { + const overlapping = beforeTopLevelRanges.filter((range) => + overlapsRange(range, change.fromA, change.toA), + ); + if (overlapping.length > 0) { + for (const candidate of overlapping) { + addCandidate(candidate); + } + continue; + } + } + + if (change.kind !== "add") { + addCandidate( + findNearestBlockForCollapsedPosition(beforeTopLevelRanges, change.fromA), + ); + } + } + + if (candidateScores.size === 0) return null; + + const [strongestIndex] = Array.from(candidateScores.entries()).sort( + (a, b) => b[1] - a[1], + )[0]; + const strongestRange = beforeTopLevelRanges.find( + (range) => range.index === strongestIndex, + ); + + return strongestRange?.node.toJSON() ?? null; +} + +export function listAiEditRawChanges({ + schema, + before, + after, + metadata, +}: AiEditDiffInput): AiEditRawChange[] { + const beforeDoc = parseSnapshot(schema, before, "before"); + const afterDoc = parseSnapshot(schema, after, "after"); + + if (beforeDoc.eq(afterDoc)) { + return []; + } + + const fullDocMap = new StepMap([ + 0, + beforeDoc.content.size, + afterDoc.content.size, + ]); + const metadataValue = metadata ?? ("ai-edit" as Data); + const changeSet = ChangeSet.create(beforeDoc).addSteps( + afterDoc, + [fullDocMap], + metadataValue, + ); + const simplified = simplifyChanges(changeSet.changes, afterDoc); + const beforeTopLevelRanges = getTopLevelBlockRanges(beforeDoc); + + return simplified.map((change) => { + const jsonChange = change.toJSON(); + return { + ...jsonChange, + kind: inferChangeKind(jsonChange, beforeTopLevelRanges), + deletedText: + jsonChange.toA > jsonChange.fromA + ? beforeDoc.textBetween( + jsonChange.fromA, + jsonChange.toA, + "\n", + "\n", + ) + : "", + }; + }); +} + +export function createAiDiffSession({ + schema, + before, + after, + metadata, +}: AiEditDiffInput): AiDiffSession | null { + const beforeDoc = parseSnapshot(schema, before, "before"); + const afterDoc = parseSnapshot(schema, after, "after"); + const changes = listAiEditRawChanges({ + schema, + before, + after, + metadata, + }); + + if (changes.length === 0) return null; + + const afterTopLevelRanges = getTopLevelBlockRanges(afterDoc); + const beforeTopLevelRanges = getTopLevelBlockRanges(beforeDoc); + const alignment = alignTopLevelBlocks(beforeTopLevelRanges, afterTopLevelRanges); + const beforeToAfterIndex = new Map(); + for (const [afterIndex, beforeIndex] of alignment.afterToBeforeIndex.entries()) { + beforeToAfterIndex.set(beforeIndex, afterIndex); + } + const sortedMatchedBeforeIndexes = Array.from(beforeToAfterIndex.keys()).sort( + (a, b) => a - b, + ); + const afterRangesByIndex = new Map( + afterTopLevelRanges.map((range) => [range.index, range] as const), + ); + const beforeRangesByIndex = new Map( + beforeTopLevelRanges.map((range) => [range.index, range] as const), + ); + const isAfterRangeUnchanged = (range: IndexedTopLevelBlockRange): boolean => { + const beforeIndex = alignment.afterToBeforeIndex.get(range.index); + if (typeof beforeIndex !== "number") return false; + const beforeRange = beforeRangesByIndex.get(beforeIndex); + if (!beforeRange) return false; + return beforeRange.node.eq(range.node); + }; + + if (afterTopLevelRanges.length === 0) { + return { + before, + after, + changes, + blocks: [], + }; + } + + type MutableBlock = { + id: string; + from: number; + to: number; + indicatorType?: AiDiffIndicatorType; + hasDeletionAnchor: boolean; + relatedChangeIndexes: Set; + sourceIndex: number; + beforeSourceIndex?: number; + }; + + const touchedBlocks = new Map(); + + const upsertTouchedBlock = (range: IndexedTopLevelBlockRange): MutableBlock => { + const key = `${range.from}:${range.to}`; + const existing = touchedBlocks.get(key); + if (existing) return existing; + + const next: MutableBlock = { + id: "", + from: range.from, + to: range.to, + hasDeletionAnchor: false, + relatedChangeIndexes: new Set(), + sourceIndex: range.index, + beforeSourceIndex: alignment.afterToBeforeIndex.get(range.index), + }; + touchedBlocks.set(key, next); + return next; + }; + + const markTouchedBlock = ( + range: IndexedTopLevelBlockRange, + indicatorType: AiDiffIndicatorType, + changeIndex: number, + ) => { + const block = upsertTouchedBlock(range); + block.indicatorType = getMergedIndicatorType( + block.indicatorType, + indicatorType, + ); + block.relatedChangeIndexes.add(changeIndex); + }; + + const markDeletionAnchor = ( + range: IndexedTopLevelBlockRange, + changeIndex?: number, + ) => { + const block = upsertTouchedBlock(range); + block.hasDeletionAnchor = true; + if (typeof changeIndex === "number" && changeIndex >= 0) { + block.relatedChangeIndexes.add(changeIndex); + } + }; + + const markDeletionAnchorByAfterIndex = ( + afterIndex: number, + changeIndex?: number, + ) => { + const range = afterRangesByIndex.get(afterIndex); + if (!range) return; + markDeletionAnchor(range, changeIndex); + }; + + for (const afterIndex of alignment.deletionAnchorAfterIndexes) { + markDeletionAnchorByAfterIndex(afterIndex); + } + + for (const [changeIndex, change] of changes.entries()) { + const changeFrom = Math.max(0, Math.min(change.fromB, afterDoc.content.size)); + const changeTo = Math.max(0, Math.min(change.toB, afterDoc.content.size)); + const deletedBeforeBlocks = findDeletedBeforeBlocksForChange( + change, + beforeTopLevelRanges, + ); + + if (deletedBeforeBlocks.length > 0) { + for (const deletedBeforeBlock of deletedBeforeBlocks) { + if (beforeToAfterIndex.has(deletedBeforeBlock.index)) { + continue; + } + + const anchorAfterIndex = resolveDeletionAnchorAfterIndex( + deletedBeforeBlock.index, + sortedMatchedBeforeIndexes, + beforeToAfterIndex, + afterTopLevelRanges, + ); + if (anchorAfterIndex !== null) { + markDeletionAnchorByAfterIndex(anchorAfterIndex, changeIndex); + } + } + } + + if (change.kind === "delete-block" && changeTo === changeFrom) { + if (deletedBeforeBlocks.length === 0) { + const nearestTopLevel = findNearestBlockForCollapsedPosition( + afterTopLevelRanges, + changeFrom, + ); + if (nearestTopLevel) { + markDeletionAnchor(nearestTopLevel, changeIndex); + } + } + continue; + } + + if (changeTo > changeFrom) { + for (const range of findContainingRanges( + afterTopLevelRanges, + changeFrom, + changeTo, + )) { + if (isAfterRangeUnchanged(range)) { + continue; + } + + const indicatorType: AiDiffIndicatorType = alignment.newAfterIndexes.has( + range.index, + ) + ? "add" + : "modify"; + markTouchedBlock(range, indicatorType, changeIndex); + } + continue; + } + + if (deletedBeforeBlocks.length > 0 || change.kind === "delete-block") continue; + + const nearestTopLevel = findNearestBlockForCollapsedPosition( + afterTopLevelRanges, + changeFrom, + ); + if (nearestTopLevel) { + const indicatorType: AiDiffIndicatorType = alignment.newAfterIndexes.has( + nearestTopLevel.index, + ) + ? "add" + : "modify"; + markTouchedBlock(nearestTopLevel, indicatorType, changeIndex); + } + } + + const blocks = Array.from(touchedBlocks.values()) + .sort((a, b) => a.from - b.from) + .map((block) => { + const relatedChangeIndexes = Array.from(block.relatedChangeIndexes).sort( + (a, b) => a - b, + ); + const idSuffix = + relatedChangeIndexes.length > 0 + ? relatedChangeIndexes.join("-") + : `anchor-${block.sourceIndex}`; + + const nextBlock: AiDiffBlock = { + id: `ai-diff-block-${block.sourceIndex}-${idSuffix}`, + from: block.from, + to: block.to, + blockType: + afterTopLevelRanges.find((range) => range.index === block.sourceIndex) + ?.node.type.name ?? "unknown", + indicatorType: block.indicatorType, + hasDeletionAnchor: block.hasDeletionAnchor, + relatedChangeIndexes, + originalBlock: + (typeof block.beforeSourceIndex === "number" + ? beforeRangesByIndex.get(block.beforeSourceIndex)?.node.toJSON() ?? + null + : null) ?? + buildOriginalBlock(beforeTopLevelRanges, changes, relatedChangeIndexes), + }; + + block.id = nextBlock.id; + return nextBlock; + }); + + return { + before, + after, + changes, + blocks, + }; +}