From 27ea6f9fee8ab8516033a3c2304408ac2bf3072d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20S=C3=A1nchez?= Date: Sat, 21 Feb 2026 23:06:57 -0600 Subject: [PATCH 01/11] Add AI edit diff tracking with prosemirror-changeset Capture and compare editor snapshots before and after AI edits to list raw document changes for improved AI editing feedback. --- package-lock.json | 7 +-- package.json | 1 + src/App.tsx | 23 ++++++++- src/hooks/useWaitForUpdatedEditorSnapshot.ts | 39 +++++++++++++++ src/lib/diff.ts | 52 ++++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useWaitForUpdatedEditorSnapshot.ts create mode 100644 src/lib/diff.ts diff --git a/package-lock.json b/package-lock.json index f5bb227e..25590418 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 449e05d4..b40f3226 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.tsx b/src/App.tsx index 0ceb5302..37b10397 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,8 @@ import { } from "@tauri-apps/plugin-updater"; import * as aiService from "./services/ai"; import type { AiProvider } from "./services/ai"; +import { listAiEditRawChanges } from "./lib/diff"; +import { useWaitForUpdatedEditorSnapshot } from "./hooks/useWaitForUpdatedEditorSnapshot"; // Detect preview mode from URL search params function getWindowMode(): { @@ -58,6 +60,8 @@ function AppContent() { const [focusMode, setFocusMode] = useState(false); const [aiProvider, setAiProvider] = useState("claude"); const editorRef = useRef(null); + const waitForUpdatedEditorSnapshot = + useWaitForUpdatedEditorSnapshot(editorRef); const toggleSidebar = useCallback(() => { setSidebarVisible((prev) => !prev); @@ -96,9 +100,17 @@ function AppContent() { toast.error("No note selected"); return; } + if (!editorRef.current) { + toast.error("Editor not ready"); + return; + } setAiEditing(true); + // Capture snapshot of the original document + const beforeJson = editorRef.current.getJSON(); + const beforeSerialized = JSON.stringify(beforeJson); + try { const result = aiProvider === "codex" @@ -108,6 +120,15 @@ function AppContent() { // Reload the current note from disk await reloadCurrentNote(); + const afterJson = await waitForUpdatedEditorSnapshot(beforeSerialized); + + const changes = listAiEditRawChanges({ + schema: editorRef.current.state.schema, + before: beforeJson, + after: afterJson, + }); + console.log(changes); + // Show results if (result.success) { // Close modal after success @@ -140,7 +161,7 @@ function AppContent() { setAiEditing(false); } }, - [aiProvider, currentNote, reloadCurrentNote], + [aiProvider, currentNote, reloadCurrentNote, waitForUpdatedEditorSnapshot], ); // Memoize display items to prevent unnecessary recalculations 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..0ef5c97c --- /dev/null +++ b/src/lib/diff.ts @@ -0,0 +1,52 @@ +import type { JSONContent } from "@tiptap/core"; +import type { Schema, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { StepMap } from "@tiptap/pm/transform"; +import { ChangeSet, type ChangeJSON } from "prosemirror-changeset"; + +export interface AiEditDiffInput { + schema: Schema; + before: JSONContent; + after: JSONContent; + metadata?: Data; +} + +export type AiEditRawChange = ChangeJSON; + +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}`, + ); + } +} + +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, + ); + + return changeset.changes.map((change) => change.toJSON()); +} From fb36e06f0e94d03efb021e1e07481c2df425ceeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20S=C3=A1nchez?= Date: Sun, 22 Feb 2026 00:28:59 -0600 Subject: [PATCH 02/11] Add AI edit diff indicator extension and overlay Show visual blocks marking AI-generated changes in the editor with a sidebar indicator. Includes extension plugin, styling, and editor integration to display real-time diff markers. --- src/App.css | 21 ++ src/App.tsx | 11 +- src/components/editor/DiffIndicator.tsx | 317 ++++++++++++++++++++++++ src/components/editor/Editor.tsx | 10 + 4 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/DiffIndicator.tsx diff --git a/src/App.css b/src/App.css index e8640d0f..17541245 100644 --- a/src/App.css +++ b/src/App.css @@ -238,6 +238,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 */ @@ -398,6 +399,22 @@ 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; + width: 3px; + border-radius: 9999px; + background: #34d399; +} + .prose ul, .prose ol { margin-top: 0; @@ -928,4 +945,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 37b10397..48f423e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ import { } from "@tauri-apps/plugin-updater"; import * as aiService from "./services/ai"; import type { AiProvider } from "./services/ai"; -import { listAiEditRawChanges } from "./lib/diff"; +import { listAiEditRawChanges, type AiEditRawChange } from "./lib/diff"; import { useWaitForUpdatedEditorSnapshot } from "./hooks/useWaitForUpdatedEditorSnapshot"; // Detect preview mode from URL search params @@ -57,6 +57,7 @@ function AppContent() { const [sidebarVisible, setSidebarVisible] = useState(true); const [aiModalOpen, setAiModalOpen] = useState(false); const [aiEditing, setAiEditing] = useState(false); + const [aiEditChanges, setAiEditChanges] = useState([]); const [focusMode, setFocusMode] = useState(false); const [aiProvider, setAiProvider] = useState("claude"); const editorRef = useRef(null); @@ -105,6 +106,7 @@ function AppContent() { return; } + setAiEditChanges([]); setAiEditing(true); // Capture snapshot of the original document @@ -127,10 +129,10 @@ function AppContent() { before: beforeJson, after: afterJson, }); - console.log(changes); // Show results if (result.success) { + setAiEditChanges(changes); // Close modal after success setAiModalOpen(false); @@ -164,6 +166,10 @@ function AppContent() { [aiProvider, currentNote, reloadCurrentNote, waitForUpdatedEditorSnapshot], ); + useEffect(() => { + setAiEditChanges([]); + }, [selectedNoteId]); + // Memoize display items to prevent unnecessary recalculations const displayItems = useMemo(() => { return searchQuery.trim() ? searchResults : notes; @@ -370,6 +376,7 @@ function AppContent() { onToggleSidebar={toggleSidebar} sidebarVisible={sidebarVisible && !focusMode} focusMode={focusMode} + aiEditChanges={aiEditChanges} 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..faab3338 --- /dev/null +++ b/src/components/editor/DiffIndicator.tsx @@ -0,0 +1,317 @@ +import { useCallback, useEffect, useState, type RefObject } from "react"; +import { Extension } from "@tiptap/core"; +import type { Editor as TiptapEditor } from "@tiptap/react"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { AiEditRawChange } from "../../lib/diff"; + +export const aiDiffIndicatorPluginKey = new PluginKey( + "aiDiffIndicator", +); + +type BlockRange = { + from: number; + to: number; +}; + +function getTopLevelBlockRanges(doc: ProseMirrorNode): BlockRange[] { + const ranges: BlockRange[] = []; + + doc.forEach((node, offset) => { + if (!node.isBlock) return; + ranges.push({ + from: offset, + to: offset + node.nodeSize, + }); + }); + + return ranges; +} + +function dedupeRanges(ranges: BlockRange[]): BlockRange[] { + const seen = new Set(); + const deduped: BlockRange[] = []; + + for (const range of ranges) { + const key = `${range.from}:${range.to}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(range); + } + + return deduped; +} + +function findContainingRanges( + ranges: BlockRange[], + from: number, + to: number, +): BlockRange[] { + return ranges.filter((range) => from < range.to && to > range.from); +} + +function findNearestBlockForCollapsedPosition( + ranges: BlockRange[], + position: number, +): BlockRange | 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: BlockRange | 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; +} + +export function buildAiDiffBlockDecorations( + doc: ProseMirrorNode, + changes: AiEditRawChange[], +): Decoration[] { + if (changes.length === 0) return []; + + const topLevelRanges = getTopLevelBlockRanges(doc); + if (topLevelRanges.length === 0) return []; + + const touchedBlocks: BlockRange[] = []; + + for (const change of changes) { + const changeFrom = Math.max(0, Math.min(change.fromB, doc.content.size)); + const changeTo = Math.max(0, Math.min(change.toB, doc.content.size)); + + if (changeTo > changeFrom) { + touchedBlocks.push( + ...findContainingRanges(topLevelRanges, changeFrom, changeTo), + ); + continue; + } + + const nearestTopLevel = findNearestBlockForCollapsedPosition( + topLevelRanges, + changeFrom, + ); + if (nearestTopLevel) { + touchedBlocks.push(nearestTopLevel); + } + } + + return dedupeRanges(touchedBlocks).map((range) => + Decoration.node(range.from, range.to, { + class: "ai-diff-indicator-block", + }), + ); +} + +export function createAiDiffDecorationSet( + doc: ProseMirrorNode, + changes: AiEditRawChange[], +): DecorationSet { + return DecorationSet.create(doc, buildAiDiffBlockDecorations(doc, changes)); +} + +export const AiDiffIndicatorExtension = Extension.create({ + name: "aiDiffIndicator", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: aiDiffIndicatorPluginKey, + state: { + init: () => DecorationSet.empty, + apply: (tr, oldSet) => { + const mapped = oldSet.map(tr.mapping, tr.doc); + const meta = tr.getMeta(aiDiffIndicatorPluginKey); + if (meta !== undefined) { + return meta.decorationSet; + } + return mapped; + }, + }, + props: { + decorations: (state) => aiDiffIndicatorPluginKey.getState(state), + }, + }), + ]; + }, +}); + +interface DiffIndicatorProps { + editor: TiptapEditor | null; + changes?: AiEditRawChange[]; + scrollContainerRef?: RefObject; +} + +type MarkerLayout = { + top: number; + height: number; +}; + +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 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; +} + +function collectMarkerLayouts( + editorDom: HTMLElement, + scrollContainer: HTMLDivElement, +): MarkerLayout[] { + const targets = Array.from( + editorDom.querySelectorAll(".ai-diff-indicator-block"), + ); + + const scrollRect = scrollContainer.getBoundingClientRect(); + const seen = new Set(); + const markers: MarkerLayout[] = []; + + for (const target of targets) { + const rect = target.getBoundingClientRect(); + if (rect.height <= 0) continue; + + const top = rect.top - scrollRect.top + scrollContainer.scrollTop + 2; + const height = Math.max(10, rect.height - 4); + const dedupeKey = `${Math.round(top)}:${Math.round(height)}`; + + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + markers.push({ top, height }); + } + + markers.sort((a, b) => a.top - b.top); + return markers; +} + +export function DiffIndicator({ + editor, + changes = [], + scrollContainerRef, +}: DiffIndicatorProps) { + const [markers, setMarkers] = useState([]); + const [leftPx, setLeftPx] = useState(0); + const [overlayHeight, setOverlayHeight] = useState(0); + + const updateOverlay = useCallback(() => { + if (!editor || !scrollContainerRef?.current) { + setMarkers([]); + setOverlayHeight(0); + return; + } + + const editorDom = editor.view.dom as HTMLElement; + const scrollContainer = scrollContainerRef.current; + const nextLeftPx = computeIndicatorLeftPx(editorDom, scrollContainer); + const nextMarkers = collectMarkerLayouts(editorDom, scrollContainer); + + setLeftPx(nextLeftPx); + setMarkers(nextMarkers); + setOverlayHeight(scrollContainer.scrollHeight); + }, [editor, scrollContainerRef]); + + useEffect(() => { + if (!editor) return; + + const decorationSet = createAiDiffDecorationSet(editor.state.doc, changes); + const tr = editor.state.tr.setMeta(aiDiffIndicatorPluginKey, { + decorationSet, + }); + editor.view.dispatch(tr); + requestAnimationFrame(updateOverlay); + }, [editor, changes, 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]); + + if (markers.length === 0) return null; + + return ( + ) : ( <> + {searchOpen && (
From 8b1a4cfdee1c8ea688f679ecae266d6b388c6cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20S=C3=A1nchez?= Date: Sun, 22 Feb 2026 00:51:37 -0600 Subject: [PATCH 03/11] Add modify and add AI diff block indicators with styling and logic updates --- src/App.css | 13 +++- src/components/editor/DiffIndicator.tsx | 89 ++++++++++++++++++------- 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/App.css b/src/App.css index 17541245..ae1d64c4 100644 --- a/src/App.css +++ b/src/App.css @@ -17,6 +17,8 @@ --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; /* Editor font settings - simplified (overridden by ThemeContext) */ --editor-font-family: @@ -49,6 +51,8 @@ --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; } /* Register theme colors with Tailwind */ @@ -412,7 +416,14 @@ html.dark { position: absolute; width: 3px; border-radius: 9999px; - background: #34d399; +} + +.ai-diff-indicator-marker--add { + background: var(--color-ai-diff-add); +} + +.ai-diff-indicator-marker--modify { + background: var(--color-ai-diff-modify); } .prose ul, diff --git a/src/components/editor/DiffIndicator.tsx b/src/components/editor/DiffIndicator.tsx index faab3338..f6a67462 100644 --- a/src/components/editor/DiffIndicator.tsx +++ b/src/components/editor/DiffIndicator.tsx @@ -15,6 +15,12 @@ type BlockRange = { to: number; }; +type DiffIndicatorType = "add" | "modify"; + +type TypedBlockRange = BlockRange & { + indicatorType: DiffIndicatorType; +}; + function getTopLevelBlockRanges(doc: ProseMirrorNode): BlockRange[] { const ranges: BlockRange[] = []; @@ -29,18 +35,19 @@ function getTopLevelBlockRanges(doc: ProseMirrorNode): BlockRange[] { return ranges; } -function dedupeRanges(ranges: BlockRange[]): BlockRange[] { - const seen = new Set(); - const deduped: BlockRange[] = []; - - for (const range of ranges) { - const key = `${range.from}:${range.to}`; - if (seen.has(key)) continue; - seen.add(key); - deduped.push(range); - } +function getChangeLengths(change: AiEditRawChange) { + const insertedLength = Math.max(0, change.toB - change.fromB); + const deletedLength = Math.max(0, change.toA - change.fromA); + return { insertedLength, deletedLength }; +} - return deduped; +function getMergedIndicatorType( + existingType: DiffIndicatorType | undefined, + nextType: DiffIndicatorType, +): DiffIndicatorType { + if (!existingType) return nextType; + if (existingType === "modify" || nextType === "modify") return "modify"; + return "add"; } function findContainingRanges( @@ -95,16 +102,37 @@ export function buildAiDiffBlockDecorations( const topLevelRanges = getTopLevelBlockRanges(doc); if (topLevelRanges.length === 0) return []; - const touchedBlocks: BlockRange[] = []; + const touchedBlocks = new Map(); + + const markTouchedBlock = ( + range: BlockRange, + indicatorType: DiffIndicatorType, + ) => { + const key = `${range.from}:${range.to}`; + const existing = touchedBlocks.get(key); + touchedBlocks.set(key, { + ...range, + indicatorType: getMergedIndicatorType(existing?.indicatorType, indicatorType), + }); + }; for (const change of changes) { + const { insertedLength, deletedLength } = getChangeLengths(change); const changeFrom = Math.max(0, Math.min(change.fromB, doc.content.size)); const changeTo = Math.max(0, Math.min(change.toB, doc.content.size)); if (changeTo > changeFrom) { - touchedBlocks.push( - ...findContainingRanges(topLevelRanges, changeFrom, changeTo), - ); + for (const range of findContainingRanges( + topLevelRanges, + changeFrom, + changeTo, + )) { + // Treat pure insertions as add, even when they occur inside existing blocks. + const indicatorType: DiffIndicatorType = + insertedLength > 0 && deletedLength === 0 ? "add" : "modify"; + + markTouchedBlock(range, indicatorType); + } continue; } @@ -113,13 +141,13 @@ export function buildAiDiffBlockDecorations( changeFrom, ); if (nearestTopLevel) { - touchedBlocks.push(nearestTopLevel); + markTouchedBlock(nearestTopLevel, "modify"); } } - return dedupeRanges(touchedBlocks).map((range) => + return Array.from(touchedBlocks.values()).map((range) => Decoration.node(range.from, range.to, { - class: "ai-diff-indicator-block", + class: `ai-diff-indicator-block ai-diff-indicator-block--${range.indicatorType}`, }), ); } @@ -166,6 +194,7 @@ interface DiffIndicatorProps { type MarkerLayout = { top: number; height: number; + indicatorType: DiffIndicatorType; }; function parseCssLengthToPx(value: string, baseFontSizePx: number): number { @@ -208,8 +237,7 @@ function collectMarkerLayouts( ); const scrollRect = scrollContainer.getBoundingClientRect(); - const seen = new Set(); - const markers: MarkerLayout[] = []; + const markersByKey = new Map(); for (const target of targets) { const rect = target.getBoundingClientRect(); @@ -217,13 +245,26 @@ function collectMarkerLayouts( const top = rect.top - scrollRect.top + scrollContainer.scrollTop + 2; const height = Math.max(10, rect.height - 4); + const indicatorType: DiffIndicatorType = target.classList.contains( + "ai-diff-indicator-block--add", + ) + ? "add" + : "modify"; const dedupeKey = `${Math.round(top)}:${Math.round(height)}`; + const existing = markersByKey.get(dedupeKey); - if (seen.has(dedupeKey)) continue; - seen.add(dedupeKey); - markers.push({ top, height }); + if (!existing) { + markersByKey.set(dedupeKey, { top, height, indicatorType }); + continue; + } + + markersByKey.set(dedupeKey, { + ...existing, + indicatorType: getMergedIndicatorType(existing.indicatorType, indicatorType), + }); } + const markers = Array.from(markersByKey.values()); markers.sort((a, b) => a.top - b.top); return markers; } @@ -304,7 +345,7 @@ export function DiffIndicator({ {markers.map((marker, index) => (
Date: Sun, 22 Feb 2026 01:40:46 -0600 Subject: [PATCH 04/11] Add deletion markers to AI diff indicator for deleted blocks Show visual "Deleted" dividers in the editor for block deletions detected in AI edit diffs, enhancing clarity of removed content alongside additions and modifications. Adjust styling and logic to support deletion anchors. --- src/App.css | 32 +++++ src/components/editor/DiffIndicator.tsx | 161 ++++++++++++++++++++---- src/lib/diff.ts | 60 ++++++++- 3 files changed, 227 insertions(+), 26 deletions(-) diff --git a/src/App.css b/src/App.css index ae1d64c4..aab6c9f6 100644 --- a/src/App.css +++ b/src/App.css @@ -19,6 +19,7 @@ --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: @@ -53,6 +54,7 @@ --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 */ @@ -426,6 +428,36 @@ html.dark { 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; +} + .prose ul, .prose ol { margin-top: 0; diff --git a/src/components/editor/DiffIndicator.tsx b/src/components/editor/DiffIndicator.tsx index f6a67462..31faa53f 100644 --- a/src/components/editor/DiffIndicator.tsx +++ b/src/components/editor/DiffIndicator.tsx @@ -18,7 +18,8 @@ type BlockRange = { type DiffIndicatorType = "add" | "modify"; type TypedBlockRange = BlockRange & { - indicatorType: DiffIndicatorType; + indicatorType?: DiffIndicatorType; + hasDeletionAnchor?: boolean; }; function getTopLevelBlockRanges(doc: ProseMirrorNode): BlockRange[] { @@ -35,12 +36,6 @@ function getTopLevelBlockRanges(doc: ProseMirrorNode): BlockRange[] { return ranges; } -function getChangeLengths(change: AiEditRawChange) { - const insertedLength = Math.max(0, change.toB - change.fromB); - const deletedLength = Math.max(0, change.toA - change.fromA); - return { insertedLength, deletedLength }; -} - function getMergedIndicatorType( existingType: DiffIndicatorType | undefined, nextType: DiffIndicatorType, @@ -104,38 +99,63 @@ export function buildAiDiffBlockDecorations( const touchedBlocks = new Map(); + const upsertTouchedBlock = (range: BlockRange): TypedBlockRange => { + const key = `${range.from}:${range.to}`; + const existing = touchedBlocks.get(key); + if (existing) return existing; + + const next: TypedBlockRange = { ...range }; + touchedBlocks.set(key, next); + return next; + }; + const markTouchedBlock = ( range: BlockRange, indicatorType: DiffIndicatorType, ) => { - const key = `${range.from}:${range.to}`; - const existing = touchedBlocks.get(key); - touchedBlocks.set(key, { - ...range, - indicatorType: getMergedIndicatorType(existing?.indicatorType, indicatorType), - }); + const block = upsertTouchedBlock(range); + block.indicatorType = getMergedIndicatorType( + block.indicatorType, + indicatorType, + ); + }; + + const markDeletionAnchor = (range: BlockRange) => { + const block = upsertTouchedBlock(range); + block.hasDeletionAnchor = true; }; for (const change of changes) { - const { insertedLength, deletedLength } = getChangeLengths(change); const changeFrom = Math.max(0, Math.min(change.fromB, doc.content.size)); const changeTo = Math.max(0, Math.min(change.toB, doc.content.size)); + if (change.kind === "delete-block" && changeTo === changeFrom) { + const nearestTopLevel = findNearestBlockForCollapsedPosition( + topLevelRanges, + changeFrom, + ); + if (nearestTopLevel) { + markDeletionAnchor(nearestTopLevel); + continue; + } + } + if (changeTo > changeFrom) { for (const range of findContainingRanges( topLevelRanges, changeFrom, changeTo, )) { - // Treat pure insertions as add, even when they occur inside existing blocks. const indicatorType: DiffIndicatorType = - insertedLength > 0 && deletedLength === 0 ? "add" : "modify"; + change.kind === "add" ? "add" : "modify"; markTouchedBlock(range, indicatorType); } continue; } + if (change.kind === "delete-block") continue; + const nearestTopLevel = findNearestBlockForCollapsedPosition( topLevelRanges, changeFrom, @@ -145,11 +165,26 @@ export function buildAiDiffBlockDecorations( } } - return Array.from(touchedBlocks.values()).map((range) => - Decoration.node(range.from, range.to, { - class: `ai-diff-indicator-block ai-diff-indicator-block--${range.indicatorType}`, - }), - ); + return Array.from(touchedBlocks.values()) + .map((range) => { + const classes: string[] = []; + if (range.indicatorType) { + classes.push( + "ai-diff-indicator-block", + `ai-diff-indicator-block--${range.indicatorType}`, + ); + } + if (range.hasDeletionAnchor) { + classes.push("ai-diff-deletion-anchor-block"); + } + + if (classes.length === 0) return null; + + return Decoration.node(range.from, range.to, { + class: classes.join(" "), + }); + }) + .filter((decoration): decoration is Decoration => decoration !== null); } export function createAiDiffDecorationSet( @@ -197,6 +232,12 @@ type MarkerLayout = { indicatorType: DiffIndicatorType; }; +type DeletionDividerLayout = { + top: number; + left: number; + width: number; +}; + function parseCssLengthToPx(value: string, baseFontSizePx: number): number { const raw = value.trim(); if (!raw) return 0; @@ -260,7 +301,10 @@ function collectMarkerLayouts( markersByKey.set(dedupeKey, { ...existing, - indicatorType: getMergedIndicatorType(existing.indicatorType, indicatorType), + indicatorType: getMergedIndicatorType( + existing.indicatorType, + indicatorType, + ), }); } @@ -269,18 +313,69 @@ function collectMarkerLayouts( return markers; } +function computeDeletionDividerFrame( + editorDom: HTMLElement, + scrollContainer: HTMLDivElement, +): { left: number; width: number } { + const editorRect = editorDom.getBoundingClientRect(); + const scrollRect = scrollContainer.getBoundingClientRect(); + const horizontalInset = 25; + const width = Math.max(140, editorRect.width - horizontalInset * 2); + const left = editorRect.left - scrollRect.left + horizontalInset; + + return { left, width }; +} + +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 })); +} + export function DiffIndicator({ editor, changes = [], scrollContainerRef, }: DiffIndicatorProps) { const [markers, setMarkers] = useState([]); + const [deletionDividers, setDeletionDividers] = useState< + DeletionDividerLayout[] + >([]); const [leftPx, setLeftPx] = useState(0); const [overlayHeight, setOverlayHeight] = useState(0); const updateOverlay = useCallback(() => { if (!editor || !scrollContainerRef?.current) { setMarkers([]); + setDeletionDividers([]); setOverlayHeight(0); return; } @@ -289,11 +384,16 @@ export function DiffIndicator({ 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]); + }, [changes, editor, scrollContainerRef]); useEffect(() => { if (!editor) return; @@ -334,7 +434,7 @@ export function DiffIndicator({ }; }, [editor, scrollContainerRef, updateOverlay]); - if (markers.length === 0) return null; + if (markers.length === 0 && deletionDividers.length === 0) return null; return (