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:
@@ -63,7 +63,8 @@
|
|||||||
"./theme": "./src/theme/index.ts",
|
"./theme": "./src/theme/index.ts",
|
||||||
"./styles": "./src/styles/index.ts",
|
"./styles": "./src/styles/index.ts",
|
||||||
"./services": "./src/services/index.ts",
|
"./services": "./src/services/index.ts",
|
||||||
"./adapters": "./src/adapters/index.ts"
|
"./adapters": "./src/adapters/index.ts",
|
||||||
|
"./test-utils": "./src/test-utils/index.ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
|
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
|
||||||
import { affine } from '../../helpers/affine-template';
|
import { affine } from '../../../test-utils';
|
||||||
|
|
||||||
describe('commands/block-crud', () => {
|
describe('commands/block-crud', () => {
|
||||||
describe('getFirstBlockCommand', () => {
|
describe('getFirstBlockCommand', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
|
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
|
||||||
import { affine } from '../../helpers/affine-template';
|
import { affine } from '../../../test-utils';
|
||||||
|
|
||||||
describe('commands/block-crud', () => {
|
describe('commands/block-crud', () => {
|
||||||
describe('getLastBlockCommand', () => {
|
describe('getLastBlockCommand', () => {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment happy-dom
|
* @vitest-environment happy-dom
|
||||||
*/
|
*/
|
||||||
import '../../helpers/affine-test-utils';
|
|
||||||
|
|
||||||
import type { TextSelection } from '@blocksuite/std';
|
import type { TextSelection } from '@blocksuite/std';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
|
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
|
||||||
import { affine, block } from '../../helpers/affine-template';
|
import { affine, block } from '../../../test-utils';
|
||||||
|
|
||||||
describe('commands/model-crud', () => {
|
describe('commands/model-crud', () => {
|
||||||
describe('replaceSelectedTextWithBlocksCommand', () => {
|
describe('replaceSelectedTextWithBlocksCommand', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
|
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
|
||||||
import { ImageSelection } from '../../../selection';
|
import { ImageSelection } from '../../../selection';
|
||||||
import { affine } from '../../helpers/affine-template';
|
import { affine } from '../../../test-utils';
|
||||||
|
|
||||||
describe('commands/selection', () => {
|
describe('commands/selection', () => {
|
||||||
describe('isNothingSelectedCommand', () => {
|
describe('isNothingSelectedCommand', () => {
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
import {
|
|
||||||
CodeBlockSchemaExtension,
|
|
||||||
DatabaseBlockSchemaExtension,
|
|
||||||
ImageBlockSchemaExtension,
|
|
||||||
ListBlockSchemaExtension,
|
|
||||||
NoteBlockSchemaExtension,
|
|
||||||
ParagraphBlockSchemaExtension,
|
|
||||||
RootBlockSchemaExtension,
|
|
||||||
} from '@blocksuite/affine-model';
|
|
||||||
import { TextSelection } from '@blocksuite/std';
|
|
||||||
import { type Block, type Store } from '@blocksuite/store';
|
|
||||||
import { Text } from '@blocksuite/store';
|
|
||||||
import { TestWorkspace } from '@blocksuite/store/test';
|
|
||||||
|
|
||||||
import { createTestHost } from './create-test-host';
|
|
||||||
|
|
||||||
// Extensions array
|
|
||||||
const extensions = [
|
|
||||||
RootBlockSchemaExtension,
|
|
||||||
NoteBlockSchemaExtension,
|
|
||||||
ParagraphBlockSchemaExtension,
|
|
||||||
ListBlockSchemaExtension,
|
|
||||||
ImageBlockSchemaExtension,
|
|
||||||
DatabaseBlockSchemaExtension,
|
|
||||||
CodeBlockSchemaExtension,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mapping from tag names to flavours
|
|
||||||
const tagToFlavour: Record<string, string> = {
|
|
||||||
'affine-page': 'affine:page',
|
|
||||||
'affine-note': 'affine:note',
|
|
||||||
'affine-paragraph': 'affine:paragraph',
|
|
||||||
'affine-list': 'affine:list',
|
|
||||||
'affine-image': 'affine:image',
|
|
||||||
'affine-database': 'affine:database',
|
|
||||||
'affine-code': 'affine:code',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SelectionInfo {
|
|
||||||
anchorBlockId?: string;
|
|
||||||
anchorOffset?: number;
|
|
||||||
focusBlockId?: string;
|
|
||||||
focusOffset?: number;
|
|
||||||
cursorBlockId?: string;
|
|
||||||
cursorOffset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse template strings and build BlockSuite document structure,
|
|
||||||
* then create a host object with the document
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* ```
|
|
||||||
* const host = affine`
|
|
||||||
* <affine-page id="page">
|
|
||||||
* <affine-note id="note">
|
|
||||||
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
|
|
||||||
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
|
|
||||||
* </affine-note>
|
|
||||||
* </affine-page>
|
|
||||||
* `;
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function affine(strings: TemplateStringsArray, ...values: any[]) {
|
|
||||||
// Merge template strings and values
|
|
||||||
let htmlString = '';
|
|
||||||
strings.forEach((str, i) => {
|
|
||||||
htmlString += str;
|
|
||||||
if (i < values.length) {
|
|
||||||
htmlString += values[i];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new doc
|
|
||||||
const workspace = new TestWorkspace({});
|
|
||||||
workspace.meta.initialize();
|
|
||||||
const doc = workspace.createDoc('test-doc');
|
|
||||||
const store = doc.getStore({ extensions });
|
|
||||||
|
|
||||||
let selectionInfo: SelectionInfo = {};
|
|
||||||
|
|
||||||
// Use DOMParser to parse HTML string
|
|
||||||
doc.load(() => {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
|
||||||
const root = dom.body.firstElementChild;
|
|
||||||
|
|
||||||
if (!root) {
|
|
||||||
throw new Error('Template must contain a root element');
|
|
||||||
}
|
|
||||||
|
|
||||||
buildDocFromElement(store, root, null, selectionInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create host object
|
|
||||||
const host = createTestHost(store);
|
|
||||||
|
|
||||||
// Set selection if needed
|
|
||||||
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
|
|
||||||
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
|
|
||||||
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
|
|
||||||
const focusOffset = selectionInfo.focusOffset ?? 0;
|
|
||||||
const anchorOffset = selectionInfo.anchorOffset ?? 0;
|
|
||||||
|
|
||||||
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
|
|
||||||
const selection = host.selection.create(TextSelection, {
|
|
||||||
from: {
|
|
||||||
blockId: selectionInfo.anchorBlockId,
|
|
||||||
index: anchorOffset,
|
|
||||||
length: focusOffset,
|
|
||||||
},
|
|
||||||
to: null,
|
|
||||||
});
|
|
||||||
host.selection.setGroup('note', [selection]);
|
|
||||||
} else {
|
|
||||||
const selection = host.selection.create(TextSelection, {
|
|
||||||
from: {
|
|
||||||
blockId: selectionInfo.anchorBlockId,
|
|
||||||
index: anchorOffset,
|
|
||||||
length: anchorTextLength - anchorOffset,
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
blockId: selectionInfo.focusBlockId,
|
|
||||||
index: 0,
|
|
||||||
length: focusOffset,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
host.selection.setGroup('note', [selection]);
|
|
||||||
}
|
|
||||||
} else if (selectionInfo.cursorBlockId) {
|
|
||||||
const selection = host.selection.create(TextSelection, {
|
|
||||||
from: {
|
|
||||||
blockId: selectionInfo.cursorBlockId,
|
|
||||||
index: selectionInfo.cursorOffset ?? 0,
|
|
||||||
length: 0,
|
|
||||||
},
|
|
||||||
to: null,
|
|
||||||
});
|
|
||||||
host.selection.setGroup('note', [selection]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a single block from template string
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* ```
|
|
||||||
* const block = block`<affine-note />`
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function block(
|
|
||||||
strings: TemplateStringsArray,
|
|
||||||
...values: any[]
|
|
||||||
): Block | null {
|
|
||||||
// Merge template strings and values
|
|
||||||
let htmlString = '';
|
|
||||||
strings.forEach((str, i) => {
|
|
||||||
htmlString += str;
|
|
||||||
if (i < values.length) {
|
|
||||||
htmlString += values[i];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a temporary doc to hold the block
|
|
||||||
const workspace = new TestWorkspace({});
|
|
||||||
workspace.meta.initialize();
|
|
||||||
const doc = workspace.createDoc('temp-doc');
|
|
||||||
const store = doc.getStore({ extensions });
|
|
||||||
|
|
||||||
let blockId: string | null = null;
|
|
||||||
const selectionInfo: SelectionInfo = {};
|
|
||||||
|
|
||||||
// Use DOMParser to parse HTML string
|
|
||||||
doc.load(() => {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
|
||||||
const root = dom.body.firstElementChild;
|
|
||||||
|
|
||||||
if (!root) {
|
|
||||||
throw new Error('Template must contain a root element');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a root block if needed
|
|
||||||
const flavour = tagToFlavour[root.tagName.toLowerCase()];
|
|
||||||
if (
|
|
||||||
flavour === 'affine:paragraph' ||
|
|
||||||
flavour === 'affine:list' ||
|
|
||||||
flavour === 'affine:code'
|
|
||||||
) {
|
|
||||||
const pageId = store.addBlock('affine:page', {});
|
|
||||||
const noteId = store.addBlock('affine:note', {}, pageId);
|
|
||||||
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
|
|
||||||
} else {
|
|
||||||
blockId = buildDocFromElement(store, root, null, selectionInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return the created block
|
|
||||||
return blockId ? (store.getBlock(blockId) ?? null) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively build document structure
|
|
||||||
* @param doc
|
|
||||||
* @param element
|
|
||||||
* @param parentId
|
|
||||||
* @param selectionInfo
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
function buildDocFromElement(
|
|
||||||
doc: Store,
|
|
||||||
element: Element,
|
|
||||||
parentId: string | null,
|
|
||||||
selectionInfo: SelectionInfo
|
|
||||||
): string {
|
|
||||||
const tagName = element.tagName.toLowerCase();
|
|
||||||
|
|
||||||
// Handle selection tags
|
|
||||||
if (tagName === 'anchor') {
|
|
||||||
if (!parentId) return '';
|
|
||||||
const parentBlock = doc.getBlock(parentId);
|
|
||||||
if (parentBlock) {
|
|
||||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
|
||||||
selectionInfo.anchorBlockId = parentId;
|
|
||||||
selectionInfo.anchorOffset = textBeforeCursor.length;
|
|
||||||
}
|
|
||||||
return parentId;
|
|
||||||
} else if (tagName === 'focus') {
|
|
||||||
if (!parentId) return '';
|
|
||||||
const parentBlock = doc.getBlock(parentId);
|
|
||||||
if (parentBlock) {
|
|
||||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
|
||||||
selectionInfo.focusBlockId = parentId;
|
|
||||||
selectionInfo.focusOffset = textBeforeCursor.length;
|
|
||||||
}
|
|
||||||
return parentId;
|
|
||||||
} else if (tagName === 'cursor') {
|
|
||||||
if (!parentId) return '';
|
|
||||||
const parentBlock = doc.getBlock(parentId);
|
|
||||||
if (parentBlock) {
|
|
||||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
|
||||||
selectionInfo.cursorBlockId = parentId;
|
|
||||||
selectionInfo.cursorOffset = textBeforeCursor.length;
|
|
||||||
}
|
|
||||||
return parentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const flavour = tagToFlavour[tagName];
|
|
||||||
|
|
||||||
if (!flavour) {
|
|
||||||
throw new Error(`Unknown tag name: ${tagName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const props: Record<string, any> = {};
|
|
||||||
|
|
||||||
const customId = element.getAttribute('id');
|
|
||||||
|
|
||||||
// If ID is specified, add it to props
|
|
||||||
if (customId) {
|
|
||||||
props.id = customId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process element attributes
|
|
||||||
Array.from(element.attributes).forEach(attr => {
|
|
||||||
if (attr.name !== 'id') {
|
|
||||||
// Skip id attribute, we already handled it
|
|
||||||
props[attr.name] = attr.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Special handling for different block types based on their flavours
|
|
||||||
switch (flavour) {
|
|
||||||
case 'affine:paragraph':
|
|
||||||
case 'affine:list':
|
|
||||||
if (element.textContent) {
|
|
||||||
props.text = new Text(element.textContent);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create block
|
|
||||||
const blockId = doc.addBlock(flavour, props, parentId);
|
|
||||||
|
|
||||||
// Process all child nodes, including text nodes
|
|
||||||
Array.from(element.children).forEach(child => {
|
|
||||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
// Handle element nodes
|
|
||||||
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
|
|
||||||
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
||||||
// Handle text nodes
|
|
||||||
console.log('buildDocFromElement text node:', child.textContent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return blockId;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TextSelection } from '@blocksuite/std';
|
import { TextSelection } from '@blocksuite/std';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { affine } from './affine-template';
|
import { affine } from '../../test-utils';
|
||||||
|
|
||||||
describe('helpers/affine-template', () => {
|
describe('helpers/affine-template', () => {
|
||||||
it('should create a basic document structure from template', () => {
|
it('should create a basic document structure from template', () => {
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { affine } from '../__tests__/utils/affine-template';
|
import { affine } from '@blocksuite/affine-shared/test-utils';
|
||||||
|
|
||||||
// Create a simple document
|
// Create a simple document
|
||||||
const doc = affine`
|
const doc = affine`
|
||||||
316
blocksuite/affine/shared/src/test-utils/affine-template.ts
Normal file
316
blocksuite/affine/shared/src/test-utils/affine-template.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import {
|
||||||
|
CodeBlockSchemaExtension,
|
||||||
|
DatabaseBlockSchemaExtension,
|
||||||
|
ImageBlockSchemaExtension,
|
||||||
|
ListBlockSchemaExtension,
|
||||||
|
NoteBlockSchemaExtension,
|
||||||
|
ParagraphBlockSchemaExtension,
|
||||||
|
RootBlockSchemaExtension,
|
||||||
|
} from '@blocksuite/affine-model';
|
||||||
|
import { Container } from '@blocksuite/global/di';
|
||||||
|
import { TextSelection } from '@blocksuite/std';
|
||||||
|
import {
|
||||||
|
type Block,
|
||||||
|
type ExtensionType,
|
||||||
|
type Store,
|
||||||
|
Text,
|
||||||
|
} from '@blocksuite/store';
|
||||||
|
import { TestWorkspace } from '@blocksuite/store/test';
|
||||||
|
|
||||||
|
import { createTestHost } from './create-test-host';
|
||||||
|
|
||||||
|
const DEFAULT_EXTENSIONS = [
|
||||||
|
RootBlockSchemaExtension,
|
||||||
|
NoteBlockSchemaExtension,
|
||||||
|
ParagraphBlockSchemaExtension,
|
||||||
|
ListBlockSchemaExtension,
|
||||||
|
ImageBlockSchemaExtension,
|
||||||
|
DatabaseBlockSchemaExtension,
|
||||||
|
CodeBlockSchemaExtension,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mapping from tag names to flavours
|
||||||
|
const tagToFlavour: Record<string, string> = {
|
||||||
|
'affine-page': 'affine:page',
|
||||||
|
'affine-note': 'affine:note',
|
||||||
|
'affine-paragraph': 'affine:paragraph',
|
||||||
|
'affine-list': 'affine:list',
|
||||||
|
'affine-image': 'affine:image',
|
||||||
|
'affine-database': 'affine:database',
|
||||||
|
'affine-code': 'affine:code',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SelectionInfo {
|
||||||
|
anchorBlockId?: string;
|
||||||
|
anchorOffset?: number;
|
||||||
|
focusBlockId?: string;
|
||||||
|
focusOffset?: number;
|
||||||
|
cursorBlockId?: string;
|
||||||
|
cursorOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAffineTemplate(
|
||||||
|
extensions: ExtensionType[] = DEFAULT_EXTENSIONS
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Parse template strings and build BlockSuite document structure,
|
||||||
|
* then create a host object with the document
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```
|
||||||
|
* const host = affine`
|
||||||
|
* <affine-page id="page">
|
||||||
|
* <affine-note id="note">
|
||||||
|
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
|
||||||
|
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
|
||||||
|
* </affine-note>
|
||||||
|
* </affine-page>
|
||||||
|
* `;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function affine(strings: TemplateStringsArray, ...values: any[]) {
|
||||||
|
// Merge template strings and values
|
||||||
|
let htmlString = '';
|
||||||
|
strings.forEach((str, i) => {
|
||||||
|
htmlString += str;
|
||||||
|
if (i < values.length) {
|
||||||
|
htmlString += values[i];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new doc
|
||||||
|
const workspace = new TestWorkspace({});
|
||||||
|
workspace.meta.initialize();
|
||||||
|
const doc = workspace.createDoc('test-doc');
|
||||||
|
const container = new Container();
|
||||||
|
extensions.forEach(extension => {
|
||||||
|
extension.setup(container);
|
||||||
|
});
|
||||||
|
const store = doc.getStore({ extensions, provider: container.provider() });
|
||||||
|
let selectionInfo: SelectionInfo = {};
|
||||||
|
|
||||||
|
// Use DOMParser to parse HTML string
|
||||||
|
doc.load(() => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
||||||
|
const root = dom.body.firstElementChild;
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
throw new Error('Template must contain a root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDocFromElement(store, root, null, selectionInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create host object
|
||||||
|
const host = createTestHost(store);
|
||||||
|
|
||||||
|
// Set selection if needed
|
||||||
|
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
|
||||||
|
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
|
||||||
|
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
|
||||||
|
const focusOffset = selectionInfo.focusOffset ?? 0;
|
||||||
|
const anchorOffset = selectionInfo.anchorOffset ?? 0;
|
||||||
|
|
||||||
|
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
|
||||||
|
const selection = host.selection.create(TextSelection, {
|
||||||
|
from: {
|
||||||
|
blockId: selectionInfo.anchorBlockId,
|
||||||
|
index: anchorOffset,
|
||||||
|
length: focusOffset,
|
||||||
|
},
|
||||||
|
to: null,
|
||||||
|
});
|
||||||
|
host.selection.setGroup('note', [selection]);
|
||||||
|
} else {
|
||||||
|
const selection = host.selection.create(TextSelection, {
|
||||||
|
from: {
|
||||||
|
blockId: selectionInfo.anchorBlockId,
|
||||||
|
index: anchorOffset,
|
||||||
|
length: anchorTextLength - anchorOffset,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
blockId: selectionInfo.focusBlockId,
|
||||||
|
index: 0,
|
||||||
|
length: focusOffset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
host.selection.setGroup('note', [selection]);
|
||||||
|
}
|
||||||
|
} else if (selectionInfo.cursorBlockId) {
|
||||||
|
const selection = host.selection.create(TextSelection, {
|
||||||
|
from: {
|
||||||
|
blockId: selectionInfo.cursorBlockId,
|
||||||
|
index: selectionInfo.cursorOffset ?? 0,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
to: null,
|
||||||
|
});
|
||||||
|
host.selection.setGroup('note', [selection]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single block from template string
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```
|
||||||
|
* const block = block`<affine-note />`
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function block(
|
||||||
|
strings: TemplateStringsArray,
|
||||||
|
...values: any[]
|
||||||
|
): Block | null {
|
||||||
|
// Merge template strings and values
|
||||||
|
let htmlString = '';
|
||||||
|
strings.forEach((str, i) => {
|
||||||
|
htmlString += str;
|
||||||
|
if (i < values.length) {
|
||||||
|
htmlString += values[i];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a temporary doc to hold the block
|
||||||
|
const workspace = new TestWorkspace({});
|
||||||
|
workspace.meta.initialize();
|
||||||
|
const doc = workspace.createDoc('temp-doc');
|
||||||
|
const store = doc.getStore({ extensions });
|
||||||
|
|
||||||
|
let blockId: string | null = null;
|
||||||
|
const selectionInfo: SelectionInfo = {};
|
||||||
|
|
||||||
|
// Use DOMParser to parse HTML string
|
||||||
|
doc.load(() => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
||||||
|
const root = dom.body.firstElementChild;
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
throw new Error('Template must contain a root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a root block if needed
|
||||||
|
const flavour = tagToFlavour[root.tagName.toLowerCase()];
|
||||||
|
if (
|
||||||
|
flavour === 'affine:paragraph' ||
|
||||||
|
flavour === 'affine:list' ||
|
||||||
|
flavour === 'affine:code'
|
||||||
|
) {
|
||||||
|
const pageId = store.addBlock('affine:page', {});
|
||||||
|
const noteId = store.addBlock('affine:note', {}, pageId);
|
||||||
|
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
|
||||||
|
} else {
|
||||||
|
blockId = buildDocFromElement(store, root, null, selectionInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the created block
|
||||||
|
return blockId ? (store.getBlock(blockId) ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
affine,
|
||||||
|
block,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { affine, block } = createAffineTemplate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively build document structure
|
||||||
|
* @param doc
|
||||||
|
* @param element
|
||||||
|
* @param parentId
|
||||||
|
* @param selectionInfo
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function buildDocFromElement(
|
||||||
|
doc: Store,
|
||||||
|
element: Element,
|
||||||
|
parentId: string | null,
|
||||||
|
selectionInfo: SelectionInfo
|
||||||
|
): string {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Handle selection tags
|
||||||
|
if (tagName === 'anchor') {
|
||||||
|
if (!parentId) return '';
|
||||||
|
const parentBlock = doc.getBlock(parentId);
|
||||||
|
if (parentBlock) {
|
||||||
|
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||||
|
selectionInfo.anchorBlockId = parentId;
|
||||||
|
selectionInfo.anchorOffset = textBeforeCursor.length;
|
||||||
|
}
|
||||||
|
return parentId;
|
||||||
|
} else if (tagName === 'focus') {
|
||||||
|
if (!parentId) return '';
|
||||||
|
const parentBlock = doc.getBlock(parentId);
|
||||||
|
if (parentBlock) {
|
||||||
|
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||||
|
selectionInfo.focusBlockId = parentId;
|
||||||
|
selectionInfo.focusOffset = textBeforeCursor.length;
|
||||||
|
}
|
||||||
|
return parentId;
|
||||||
|
} else if (tagName === 'cursor') {
|
||||||
|
if (!parentId) return '';
|
||||||
|
const parentBlock = doc.getBlock(parentId);
|
||||||
|
if (parentBlock) {
|
||||||
|
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||||
|
selectionInfo.cursorBlockId = parentId;
|
||||||
|
selectionInfo.cursorOffset = textBeforeCursor.length;
|
||||||
|
}
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flavour = tagToFlavour[tagName];
|
||||||
|
|
||||||
|
if (!flavour) {
|
||||||
|
throw new Error(`Unknown tag name: ${tagName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const props: Record<string, any> = {};
|
||||||
|
|
||||||
|
const customId = element.getAttribute('id');
|
||||||
|
|
||||||
|
// If ID is specified, add it to props
|
||||||
|
if (customId) {
|
||||||
|
props.id = customId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process element attributes
|
||||||
|
Array.from(element.attributes).forEach(attr => {
|
||||||
|
if (attr.name !== 'id') {
|
||||||
|
// Skip id attribute, we already handled it
|
||||||
|
props[attr.name] = attr.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Special handling for different block types based on their flavours
|
||||||
|
switch (flavour) {
|
||||||
|
case 'affine:paragraph':
|
||||||
|
case 'affine:list':
|
||||||
|
if (element.textContent) {
|
||||||
|
props.text = new Text(element.textContent);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create block
|
||||||
|
const blockId = doc.addBlock(flavour, props, parentId);
|
||||||
|
|
||||||
|
// Process all child nodes, including text nodes
|
||||||
|
Array.from(element.children).forEach(child => {
|
||||||
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
// Handle element nodes
|
||||||
|
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
|
||||||
|
} else if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
// Handle text nodes
|
||||||
|
console.log('buildDocFromElement text node:', child.textContent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return blockId;
|
||||||
|
}
|
||||||
@@ -63,10 +63,8 @@ function compareBlocks(
|
|||||||
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
|
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
for (const [i, child] of actual.children.entries()) {
|
||||||
for (let i = 0; i < actual.children.length; i++) {
|
if (!compareBlocks(child, expected.children[i], compareId)) return false;
|
||||||
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
|
|||||||
std.selection = new MockSelectionStore();
|
std.selection = new MockSelectionStore();
|
||||||
|
|
||||||
std.command = new CommandManager(std as any);
|
std.command = new CommandManager(std as any);
|
||||||
// @ts-expect-error
|
// @ts-expect-error dev-only
|
||||||
host.command = std.command;
|
host.command = std.command;
|
||||||
host.selection = std.selection;
|
host.selection = std.selection;
|
||||||
|
|
||||||
3
blocksuite/affine/shared/src/test-utils/index.ts
Normal file
3
blocksuite/affine/shared/src/test-utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './affine-template';
|
||||||
|
export * from './affine-test-utils';
|
||||||
|
export * from './create-test-host';
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@affine/track": "workspace:*",
|
"@affine/track": "workspace:*",
|
||||||
"@blocksuite/affine": "workspace:*",
|
"@blocksuite/affine": "workspace:*",
|
||||||
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.13",
|
"@blocksuite/icons": "^2.2.13",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@dotlottie/player-component": "^2.7.12",
|
"@dotlottie/player-component": "^2.7.12",
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@types/animejs": "^3.1.12",
|
"@types/animejs": "^3.1.12",
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
|
||||||
|
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
|
||||||
|
import { createAffineTemplate } from '@blocksuite/affine-shared/test-utils';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { applyPatchToDoc } from '../../../../blocksuite/ai/utils/apply-model/apply-patch-to-doc';
|
||||||
|
import type { PatchOp } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff';
|
||||||
|
|
||||||
|
const manager = new StoreExtensionManager(getInternalStoreExtensions());
|
||||||
|
const { affine } = createAffineTemplate(manager.get('store'));
|
||||||
|
|
||||||
|
describe('applyPatchToDoc', () => {
|
||||||
|
it('should delete a block', async () => {
|
||||||
|
const host = affine`
|
||||||
|
<affine-page id="page">
|
||||||
|
<affine-note id="note">
|
||||||
|
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2">World</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const patch: PatchOp[] = [{ op: 'delete', id: 'paragraph-1' }];
|
||||||
|
await applyPatchToDoc(host.store, patch);
|
||||||
|
|
||||||
|
const expected = affine`
|
||||||
|
<affine-page id="page">
|
||||||
|
<affine-note id="note">
|
||||||
|
<affine-paragraph id="paragraph-2">World</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(host.store).toEqualDoc(expected.store, {
|
||||||
|
compareId: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace a block', async () => {
|
||||||
|
const host = affine`
|
||||||
|
<affine-page id="page">
|
||||||
|
<affine-note id="note">
|
||||||
|
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2">World</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const patch: PatchOp[] = [
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
id: 'paragraph-1',
|
||||||
|
content: 'New content',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await applyPatchToDoc(host.store, patch);
|
||||||
|
|
||||||
|
const expected = affine`
|
||||||
|
<affine-page id="page">
|
||||||
|
<affine-note id="note">
|
||||||
|
<affine-paragraph id="paragraph-1">New content</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2">World</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(host.store).toEqualDoc(expected.store, {
|
||||||
|
compareId: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert a block at index', async () => {
|
||||||
|
const host = affine`
|
||||||
|
<affine-page id="page">
|
||||||
|
<affine-note id="note">
|
||||||
|
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2">World</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const patch: PatchOp[] = [
|
||||||
|
{
|
||||||
|
op: 'insert',
|
||||||
|
index: 2,
|
||||||
|
block: {
|
||||||
|
id: 'paragraph-3',
|
||||||
|
type: 'affine:paragraph',
|
||||||
|
content: 'Inserted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await applyPatchToDoc(host.store, patch);
|
||||||
|
|
||||||
|
const expected = affine`
|
||||||
|
<affine-page id="page">
|
||||||
|
<affine-note id="note">
|
||||||
|
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2">World</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-3">Inserted</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(host.store).toEqualDoc(expected.store, {
|
||||||
|
compareId: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { generateRenderDiff } from '../../../../blocksuite/ai/utils/apply-model/generate-render-diff';
|
||||||
|
|
||||||
|
describe('generateRenderDiff', () => {
|
||||||
|
test('should handle block insertion', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
This is a new paragraph.
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: [],
|
||||||
|
inserts: {
|
||||||
|
'block-001': [
|
||||||
|
{
|
||||||
|
id: 'block-002',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'This is a new paragraph.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle block deletion', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
This paragraph will be deleted.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: ['block-002'],
|
||||||
|
inserts: {},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle block replacement', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Old Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# New Title
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: [],
|
||||||
|
inserts: {},
|
||||||
|
updates: {
|
||||||
|
'block-001': '# New Title',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mixed changes', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
Old paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
To be deleted.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
Updated paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-004 flavour=paragraph -->
|
||||||
|
New paragraph.
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: ['block-003'],
|
||||||
|
inserts: {
|
||||||
|
'block-002': [
|
||||||
|
{
|
||||||
|
id: 'block-004',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'New paragraph.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
'block-002': 'Updated paragraph.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle consecutive block insertions', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
First inserted paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
Second inserted paragraph.
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: [],
|
||||||
|
inserts: {
|
||||||
|
'block-001': [
|
||||||
|
{
|
||||||
|
id: 'block-002',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'First inserted paragraph.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-003',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Second inserted paragraph.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle consecutive block deletions', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
First paragraph to be deleted.
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
Second paragraph to be deleted.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: ['block-002', 'block-003'],
|
||||||
|
inserts: {},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle block insertion at the head', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-000 flavour=paragraph -->
|
||||||
|
Head paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: [],
|
||||||
|
inserts: {
|
||||||
|
HEAD: [
|
||||||
|
{
|
||||||
|
id: 'block-000',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Head paragraph.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle block insertion at the tail', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
Tail paragraph.
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: [],
|
||||||
|
inserts: {
|
||||||
|
'block-001': [
|
||||||
|
{
|
||||||
|
id: 'block-002',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Tail paragraph.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle delete then insert after', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
To be deleted.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
Inserted after delete.
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: ['block-002'],
|
||||||
|
inserts: {
|
||||||
|
'block-001': [
|
||||||
|
{
|
||||||
|
id: 'block-003',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Inserted after delete.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle consecutive insertions', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
First insert.
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
Second insert.
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: [],
|
||||||
|
inserts: {
|
||||||
|
'block-001': [
|
||||||
|
{
|
||||||
|
id: 'block-002',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'First insert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-003',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Second insert.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle interval insertions', () => {
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
Paragraph.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
Inserted between.
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
Paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-004 flavour=paragraph -->
|
||||||
|
Inserted at tail.
|
||||||
|
`;
|
||||||
|
const diff = generateRenderDiff(oldMd, newMd);
|
||||||
|
expect(diff).toEqual({
|
||||||
|
deletes: [],
|
||||||
|
inserts: {
|
||||||
|
'block-001': [
|
||||||
|
{
|
||||||
|
id: 'block-003',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Inserted between.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'block-002': [
|
||||||
|
{
|
||||||
|
id: 'block-004',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Inserted at tail.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updates: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { diffMarkdown } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff';
|
||||||
|
|
||||||
|
describe('diffMarkdown', () => {
|
||||||
|
test('should diff block insertion', () => {
|
||||||
|
// Only a new block is inserted
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
This is a new paragraph.
|
||||||
|
`;
|
||||||
|
const { patches } = diffMarkdown(oldMd, newMd);
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{
|
||||||
|
op: 'insert',
|
||||||
|
index: 1,
|
||||||
|
block: {
|
||||||
|
id: 'block-002',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'This is a new paragraph.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should diff block deletion', () => {
|
||||||
|
// A block is deleted
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
This paragraph will be deleted.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const { patches } = diffMarkdown(oldMd, newMd);
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{
|
||||||
|
op: 'delete',
|
||||||
|
id: 'block-002',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should diff block replacement', () => {
|
||||||
|
// Only content of a block is changed
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Old Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# New Title
|
||||||
|
`;
|
||||||
|
const { patches } = diffMarkdown(oldMd, newMd);
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
id: 'block-001',
|
||||||
|
content: '# New Title',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should diff mixed changes', () => {
|
||||||
|
// Mixed: delete, insert, replace
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
Old paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
To be deleted.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
Updated paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-004 flavour=paragraph -->
|
||||||
|
New paragraph.
|
||||||
|
`;
|
||||||
|
const { patches } = diffMarkdown(oldMd, newMd);
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
id: 'block-002',
|
||||||
|
content: 'Updated paragraph.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'insert',
|
||||||
|
index: 2,
|
||||||
|
block: {
|
||||||
|
id: 'block-004',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'New paragraph.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'delete',
|
||||||
|
id: 'block-003',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should diff consecutive block insertions', () => {
|
||||||
|
// Two new blocks are inserted consecutively
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
First inserted paragraph.
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
Second inserted paragraph.
|
||||||
|
`;
|
||||||
|
const { patches } = diffMarkdown(oldMd, newMd);
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{
|
||||||
|
op: 'insert',
|
||||||
|
index: 1,
|
||||||
|
block: {
|
||||||
|
id: 'block-002',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'First inserted paragraph.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'insert',
|
||||||
|
index: 2,
|
||||||
|
block: {
|
||||||
|
id: 'block-003',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Second inserted paragraph.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should diff consecutive block deletions', () => {
|
||||||
|
// Two blocks are deleted consecutively
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
First paragraph to be deleted.
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
Second paragraph to be deleted.
|
||||||
|
`;
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
`;
|
||||||
|
const { patches } = diffMarkdown(oldMd, newMd);
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{
|
||||||
|
op: 'delete',
|
||||||
|
id: 'block-002',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'delete',
|
||||||
|
id: 'block-003',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should diff deletion followed by insertion at the same position', () => {
|
||||||
|
// A block is deleted and a new block is inserted at the end
|
||||||
|
const oldMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-002 flavour=paragraph -->
|
||||||
|
This paragraph will be deleted
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
HelloWorld
|
||||||
|
`;
|
||||||
|
|
||||||
|
const newMd = `
|
||||||
|
<!-- block_id=block-001 flavour=title -->
|
||||||
|
# Title
|
||||||
|
|
||||||
|
<!-- block_id=block-003 flavour=paragraph -->
|
||||||
|
HelloWorld
|
||||||
|
|
||||||
|
<!-- block_id=block-004 flavour=paragraph -->
|
||||||
|
This is a new paragraph inserted after deletion.
|
||||||
|
`;
|
||||||
|
const { patches } = diffMarkdown(oldMd, newMd);
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{
|
||||||
|
op: 'insert',
|
||||||
|
index: 2,
|
||||||
|
block: {
|
||||||
|
id: 'block-004',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'This is a new paragraph inserted after deletion.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'delete',
|
||||||
|
id: 'block-002',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -131,7 +131,8 @@ export async function insertFromMarkdown(
|
|||||||
markdown: string,
|
markdown: string,
|
||||||
doc: Store,
|
doc: Store,
|
||||||
parent?: string,
|
parent?: string,
|
||||||
index?: number
|
index?: number,
|
||||||
|
id?: string
|
||||||
) {
|
) {
|
||||||
const { snapshot, transformer } = await markdownToSnapshot(
|
const { snapshot, transformer } = await markdownToSnapshot(
|
||||||
markdown,
|
markdown,
|
||||||
@@ -144,6 +145,9 @@ export async function insertFromMarkdown(
|
|||||||
const models: BlockModel[] = [];
|
const models: BlockModel[] = [];
|
||||||
for (let i = 0; i < snapshots.length; i++) {
|
for (let i = 0; i < snapshots.length; i++) {
|
||||||
const blockSnapshot = snapshots[i];
|
const blockSnapshot = snapshots[i];
|
||||||
|
if (snapshots.length === 1 && id) {
|
||||||
|
blockSnapshot.id = id;
|
||||||
|
}
|
||||||
const model = await transformer.snapshotToBlock(
|
const model = await transformer.snapshotToBlock(
|
||||||
blockSnapshot,
|
blockSnapshot,
|
||||||
doc,
|
doc,
|
||||||
@@ -158,6 +162,27 @@ export async function insertFromMarkdown(
|
|||||||
return models;
|
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(
|
export async function markDownToDoc(
|
||||||
provider: ServiceProvider,
|
provider: ServiceProvider,
|
||||||
schema: Schema,
|
schema: Schema,
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
{ "path": "../../common/nbstore" },
|
{ "path": "../../common/nbstore" },
|
||||||
{ "path": "../track" },
|
{ "path": "../track" },
|
||||||
{ "path": "../../../blocksuite/affine/all" },
|
{ "path": "../../../blocksuite/affine/all" },
|
||||||
|
{ "path": "../../../blocksuite/affine/shared" },
|
||||||
{ "path": "../../../blocksuite/framework/std" },
|
{ "path": "../../../blocksuite/framework/std" },
|
||||||
{ "path": "../../common/infra" }
|
{ "path": "../../common/infra" },
|
||||||
|
{ "path": "../../../blocksuite/affine/ext-loader" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1348,8 +1348,10 @@ export const PackageList = [
|
|||||||
'packages/frontend/templates',
|
'packages/frontend/templates',
|
||||||
'packages/frontend/track',
|
'packages/frontend/track',
|
||||||
'blocksuite/affine/all',
|
'blocksuite/affine/all',
|
||||||
|
'blocksuite/affine/shared',
|
||||||
'blocksuite/framework/std',
|
'blocksuite/framework/std',
|
||||||
'packages/common/infra',
|
'packages/common/infra',
|
||||||
|
'blocksuite/affine/ext-loader',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -402,6 +402,8 @@ __metadata:
|
|||||||
"@affine/templates": "workspace:*"
|
"@affine/templates": "workspace:*"
|
||||||
"@affine/track": "workspace:*"
|
"@affine/track": "workspace:*"
|
||||||
"@blocksuite/affine": "workspace:*"
|
"@blocksuite/affine": "workspace:*"
|
||||||
|
"@blocksuite/affine-ext-loader": "workspace:*"
|
||||||
|
"@blocksuite/affine-shared": "workspace:*"
|
||||||
"@blocksuite/icons": "npm:^2.2.13"
|
"@blocksuite/icons": "npm:^2.2.13"
|
||||||
"@blocksuite/std": "workspace:*"
|
"@blocksuite/std": "workspace:*"
|
||||||
"@dotlottie/player-component": "npm:^2.7.12"
|
"@dotlottie/player-component": "npm:^2.7.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user