feat(core): markdown-diff & patch apply (#12844)

## New Features
- **Markdown diff**: 
- Introduced block-level diffing for markdown content, enabling
detection of insertions, deletions, and replacements between document
versions.
  - Generate patch operations from markdown diff.
- **Patch Renderer**: Transfer patch operations to a render diff which
can be rendered into page body.
- **Patch apply**:Added functionality to apply patch operations to
documents, supporting block insertion, deletion, and content
replacement.

## Refactors
* Move `affine/shared/__tests__/utils` to
`blocksuite/affine-shared/test-utils`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced utilities for declarative creation and testing of document
structures using template literals.
* Added new functions and types for block-level markdown diffing and
patch application.
* Provided a utility to generate structured render diffs for markdown
blocks.
* Added a unified test-utils entry point for easier access to testing
helpers.

* **Bug Fixes**
* Updated import paths in test files to use the new test-utils location.

* **Documentation**
* Improved example usage in documentation to reflect the new import
paths for test utilities.

* **Tests**
* Added comprehensive test suites for markdown diffing, patch
application, and render diff utilities.

* **Chores**
* Updated package dependencies and export maps to expose new test
utilities.
* Refactored internal test utilities organization for clarity and
maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

> CLOSE AI-271 AI-272 AI-273
This commit is contained in:
德布劳外 · 贾贵
2025-07-04 18:48:49 +08:00
committed by GitHub
parent 5da56b5b04
commit c882a8c5da
23 changed files with 1346 additions and 314 deletions

View File

@@ -0,0 +1,60 @@
import type { Store } from '@blocksuite/store';
import { insertFromMarkdown, replaceFromMarkdown } from '../../../utils';
import type { PatchOp } from './markdown-diff';
/**
* Apply a list of PatchOp to the page doc (children of the first note block)
* @param doc The page document Store
* @param patch Array of PatchOp
*/
export async function applyPatchToDoc(
doc: Store,
patch: PatchOp[]
): Promise<void> {
// Get all note blocks
const notes = doc.getBlocksByFlavour('affine:note');
if (notes.length === 0) return;
// Only handle the first note block
const note = notes[0].model;
// Build a map from block_id to BlockModel for quick lookup
const blockIdMap = new Map<string, any>();
note.children.forEach(child => {
blockIdMap.set(child.id, child);
});
for (const op of patch) {
if (op.op === 'delete') {
// Delete block
doc.deleteBlock(op.id);
} else if (op.op === 'replace') {
const oldBlock = blockIdMap.get(op.id);
if (!oldBlock) continue;
const parentId = note.id;
const index = note.children.findIndex(child => child.id === op.id);
if (index === -1) continue;
await replaceFromMarkdown(
undefined,
op.content,
doc,
parentId,
index,
op.id
);
} else if (op.op === 'insert') {
// Insert new block
const parentId = note.id;
const index = op.index;
await insertFromMarkdown(
undefined,
op.block.content,
doc,
parentId,
index,
op.block.id
);
}
}
}

View File

@@ -0,0 +1,133 @@
import { type Block, diffMarkdown } from './markdown-diff';
export interface RenderDiffs {
deletes: string[];
inserts: Record<string, Block[]>;
updates: Record<string, string>;
}
/**
* Example:
*
* Old markdown:
* ```md
* <!-- block_id=001 flavour=paragraph -->
* This is the first paragraph
*
* <!-- block_id=002 flavour=paragraph -->
* This is the second paragraph
*
* <!-- block_id=003 flavour=paragraph -->
* This is the third paragraph
*
* <!-- block_id=004 flavour=paragraph -->
* This is the fourth paragraph
* ```
*
* New markdown:
* ```md
* <!-- block_id=001 flavour=paragraph -->
* This is the first paragraph
*
* <!-- block_id=003 flavour=paragraph -->
* This is the 3rd paragraph
*
* <!-- block_id=005 flavour=paragraph -->
* New inserted paragraph 1
*
* <!-- block_id=006 flavour=paragraph -->
* New inserted paragraph 2
* ```
*
* The generated patches:
* ```js
* [
* { op: 'insert', index: 2, block: { id: '005', ... } },
* { op: 'insert', index: 3, bthirdlock: { id: '006', ... } },
* { op: 'update', id: '003', content: 'This is the 3rd paragraph' },
* { op: 'delete', id: '002' },
* { op: 'delete', id: '004' }
* ]
* ```
*
* UI expected:
* ```
* This is the first paragraph
* [DELETE DIFF] This is the second paragraph
* This is the third paragraph
* [DELETE DIFF] This is the fourth paragraph
* [INSERT DIFF] New inserted paragraph 1
* [INSERT DIFF] New inserted paragraph 2
* ```
*
* The resulting diffMap:
* ```js
* {
* deletes: ['002', '004'],
* inserts: { 3: [block_005, block_006] },
* updates: {}
* }
* ```
*/
export function generateRenderDiff(
originalMarkdown: string,
changedMarkdown: string
) {
const { patches, oldBlocks } = diffMarkdown(
originalMarkdown,
changedMarkdown
);
const diffMap: RenderDiffs = {
deletes: [],
inserts: {},
updates: {},
};
const indexToBlockId: Record<number, string> = {};
oldBlocks.forEach((block, idx) => {
indexToBlockId[idx] = block.id;
});
function getPrevBlock(index: number) {
let start = index - 1;
while (!indexToBlockId[start] && start >= 0) {
start--;
}
return indexToBlockId[start] || 'HEAD';
}
const insertGroups: Record<string, Block[]> = {};
let lastInsertKey: string | null = null;
let lastInsertIndex: number | null = null;
for (const patch of patches) {
switch (patch.op) {
case 'delete':
diffMap.deletes.push(patch.id);
break;
case 'insert': {
const prevBlockId = getPrevBlock(patch.index);
if (
lastInsertKey !== null &&
lastInsertIndex !== null &&
patch.index === lastInsertIndex + 1
) {
insertGroups[lastInsertKey].push(patch.block);
} else {
insertGroups[prevBlockId] = [patch.block];
lastInsertKey = prevBlockId;
}
lastInsertIndex = patch.index;
break;
}
case 'replace':
diffMap.updates[patch.id] = patch.content;
break;
}
}
diffMap.inserts = insertGroups;
return diffMap;
}

View File

@@ -0,0 +1,109 @@
export type Block = {
id: string;
type: string;
content: string;
};
export type PatchOp =
| { op: 'replace'; id: string; content: string }
| { op: 'delete'; id: string }
| { op: 'insert'; index: number; block: Block };
const BLOCK_MATCH_REGEXP = /^\s*<!--\s*block_id=(.*?)\s+flavour=(.*?)\s*-->/;
export function parseMarkdownToBlocks(markdown: string): Block[] {
const lines = markdown.split(/\r?\n/);
const blocks: Block[] = [];
let currentBlockId: string | null = null;
let currentType: string | null = null;
let currentContent: string[] = [];
for (const line of lines) {
const match = line.match(BLOCK_MATCH_REGEXP);
if (match) {
// If there is a block being collected, push it into blocks first
if (currentBlockId && currentType) {
blocks.push({
id: currentBlockId,
type: currentType,
content: currentContent.join('\n').trim(),
});
}
// Start a new block
currentBlockId = match[1];
currentType = match[2];
currentContent = [];
} else {
// Collect content
if (currentBlockId && currentType) {
currentContent.push(line);
}
}
}
// Collect the last block
if (currentBlockId && currentType) {
blocks.push({
id: currentBlockId,
type: currentType,
content: currentContent.join('\n').trim(),
});
}
return blocks;
}
function diffBlockLists(oldBlocks: Block[], newBlocks: Block[]): PatchOp[] {
const patch: PatchOp[] = [];
const oldMap = new Map<string, { block: Block; index: number }>();
oldBlocks.forEach((b, i) => oldMap.set(b.id, { block: b, index: i }));
const newMap = new Map<string, { block: Block; index: number }>();
newBlocks.forEach((b, i) => newMap.set(b.id, { block: b, index: i }));
// Mark old blocks that have been handled
const handledOld = new Set<string>();
// First process newBlocks in order
newBlocks.forEach((newBlock, newIdx) => {
const old = oldMap.get(newBlock.id);
if (old) {
handledOld.add(newBlock.id);
if (old.block.content !== newBlock.content) {
patch.push({
op: 'replace',
id: newBlock.id,
content: newBlock.content,
});
}
} else {
patch.push({
op: 'insert',
index: newIdx,
block: {
id: newBlock.id,
type: newBlock.type,
content: newBlock.content,
},
});
}
});
// Then process deleted oldBlocks
oldBlocks.forEach(oldBlock => {
if (!newMap.has(oldBlock.id)) {
patch.push({
op: 'delete',
id: oldBlock.id,
});
}
});
return patch;
}
export function diffMarkdown(oldMarkdown: string, newMarkdown: string) {
const oldBlocks = parseMarkdownToBlocks(oldMarkdown);
const newBlocks = parseMarkdownToBlocks(newMarkdown);
const patches: PatchOp[] = diffBlockLists(oldBlocks, newBlocks);
return { patches, newBlocks, oldBlocks };
}

View File

@@ -131,7 +131,8 @@ export async function insertFromMarkdown(
markdown: string,
doc: Store,
parent?: string,
index?: number
index?: number,
id?: string
) {
const { snapshot, transformer } = await markdownToSnapshot(
markdown,
@@ -144,6 +145,9 @@ export async function insertFromMarkdown(
const models: BlockModel[] = [];
for (let i = 0; i < snapshots.length; i++) {
const blockSnapshot = snapshots[i];
if (snapshots.length === 1 && id) {
blockSnapshot.id = id;
}
const model = await transformer.snapshotToBlock(
blockSnapshot,
doc,
@@ -158,6 +162,27 @@ export async function insertFromMarkdown(
return models;
}
export async function replaceFromMarkdown(
host: EditorHost | undefined,
markdown: string,
doc: Store,
parent: string,
index: number,
id: string
) {
doc.deleteBlock(id);
const { snapshot, transformer } = await markdownToSnapshot(
markdown,
doc,
host
);
const snapshots = snapshot?.content.flatMap(x => x.children) ?? [];
const blockSnapshot = snapshots[0];
blockSnapshot.id = id;
await transformer.snapshotToBlock(blockSnapshot, doc, parent, index);
}
export async function markDownToDoc(
provider: ServiceProvider,
schema: Schema,