mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 16:44:56 +00:00
Compare commits
2 Commits
v2026.2.4-
...
06-18-feat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc69a3bef | ||
|
|
e8d774a2ad |
@@ -28,6 +28,7 @@ import { query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { ParagraphBlockConfigExtension } from './paragraph-block-config.js';
|
||||
import { paragraphBlockStyles } from './styles.js';
|
||||
@@ -227,6 +228,12 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
|
||||
}
|
||||
|
||||
override renderBlock(): TemplateResult<1> {
|
||||
const widgets = html`${repeat(
|
||||
Object.entries(this.widgets),
|
||||
([id]) => id,
|
||||
([_, widget]) => widget
|
||||
)}`;
|
||||
|
||||
const { type$ } = this.model.props;
|
||||
const collapsed = this.store.readonly
|
||||
? this._readonlyCollapsed
|
||||
@@ -341,6 +348,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
|
||||
</div>
|
||||
|
||||
${children}
|
||||
${widgets}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
@@ -63,7 +65,8 @@
|
||||
"./theme": "./src/theme/index.ts",
|
||||
"./styles": "./src/styles/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": [
|
||||
"src",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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('getFirstBlockCommand', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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('getLastBlockCommand', () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import '../../helpers/affine-test-utils';
|
||||
import '../../../test-utils/affine-test-utils';
|
||||
|
||||
import type { TextSelection } from '@blocksuite/std';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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('replaceSelectedTextWithBlocksCommand', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
|
||||
import { ImageSelection } from '../../../selection';
|
||||
import { affine } from '../../helpers/affine-template';
|
||||
import { affine } from '../../../test-utils';
|
||||
|
||||
describe('commands/selection', () => {
|
||||
describe('isNothingSelectedCommand', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { affine } from './affine-template';
|
||||
import { affine } from '../../test-utils';
|
||||
|
||||
describe('helpers/affine-template', () => {
|
||||
it('should create a basic document structure from template', () => {
|
||||
@@ -1,29 +1,32 @@
|
||||
import {
|
||||
CodeBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
ParagraphBlockSchemaExtension,
|
||||
RootBlockSchemaExtension,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
|
||||
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import { type Block, type Store } from '@blocksuite/store';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { type Block, type Store, 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,
|
||||
];
|
||||
const manager = new StoreExtensionManager(getInternalStoreExtensions());
|
||||
const extensions = manager.get('store');
|
||||
|
||||
// // Extensions array
|
||||
// const extensions = [
|
||||
// RootBlockSchemaExtension,
|
||||
// NoteBlockSchemaExtension,
|
||||
// ParagraphBlockSchemaExtension,
|
||||
// ListBlockSchemaExtension,
|
||||
// ImageBlockSchemaExtension,
|
||||
// DatabaseBlockSchemaExtension,
|
||||
// CodeBlockSchemaExtension,
|
||||
// RootStoreExtension,
|
||||
// NoteStoreExtension,
|
||||
// ParagraphStoreExtension,
|
||||
// ListStoreExtension,
|
||||
// ImageStoreExtension,
|
||||
// DatabaseStoreExtension,
|
||||
// CodeStoreExtension
|
||||
// ];
|
||||
|
||||
// Mapping from tag names to flavours
|
||||
const tagToFlavour: Record<string, string> = {
|
||||
@@ -75,8 +78,11 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
|
||||
const workspace = new TestWorkspace({});
|
||||
workspace.meta.initialize();
|
||||
const doc = workspace.createDoc('test-doc');
|
||||
const store = doc.getStore({ extensions });
|
||||
|
||||
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
|
||||
@@ -63,10 +63,8 @@ function compareBlocks(
|
||||
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
|
||||
return false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < actual.children.length; i++) {
|
||||
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
|
||||
return false;
|
||||
for (const [i, child] of actual.children.entries()) {
|
||||
if (!compareBlocks(child, expected.children[i], compareId)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
|
||||
std.selection = new MockSelectionStore();
|
||||
|
||||
std.command = new CommandManager(std as any);
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error dev-only
|
||||
host.command = std.command;
|
||||
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/track": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.13",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@dotlottie/player-component": "^2.7.12",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { affine } 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';
|
||||
|
||||
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', block_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);
|
||||
});
|
||||
|
||||
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',
|
||||
block_id: 'paragraph-1',
|
||||
new_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);
|
||||
});
|
||||
|
||||
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_at',
|
||||
index: 2,
|
||||
new_block: { 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
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 type=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
This is a new paragraph.
|
||||
`;
|
||||
const patch = diffMarkdown(oldMd, newMd);
|
||||
expect(patch).toEqual([
|
||||
{
|
||||
op: 'insert_at',
|
||||
index: 1,
|
||||
new_block: {
|
||||
type: 'paragraph',
|
||||
content: 'This is a new paragraph.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff block deletion', () => {
|
||||
// A block is deleted
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
This paragraph will be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
`;
|
||||
const patch = diffMarkdown(oldMd, newMd);
|
||||
expect(patch).toEqual([
|
||||
{
|
||||
op: 'delete',
|
||||
block_id: 'block-002',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff block replacement', () => {
|
||||
// Only content of a block is changed
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Old Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# New Title
|
||||
`;
|
||||
const patch = diffMarkdown(oldMd, newMd);
|
||||
expect(patch).toEqual([
|
||||
{
|
||||
op: 'replace',
|
||||
block_id: 'block-001',
|
||||
new_content: '# New Title',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff mixed changes', () => {
|
||||
// Mixed: delete, insert, replace
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
Old paragraph.
|
||||
|
||||
<!-- block_id=block-003 type=paragraph -->
|
||||
To be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
Updated paragraph.
|
||||
|
||||
<!-- block_id=block-004 type=paragraph -->
|
||||
New paragraph.
|
||||
`;
|
||||
const patch = diffMarkdown(oldMd, newMd);
|
||||
expect(patch).toEqual([
|
||||
{
|
||||
op: 'replace',
|
||||
block_id: 'block-002',
|
||||
new_content: 'Updated paragraph.',
|
||||
},
|
||||
{
|
||||
op: 'insert_at',
|
||||
index: 2,
|
||||
new_block: {
|
||||
type: 'paragraph',
|
||||
content: 'New paragraph.',
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'delete',
|
||||
block_id: 'block-003',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff consecutive block insertions', () => {
|
||||
// Two new blocks are inserted consecutively
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
First inserted paragraph.
|
||||
|
||||
<!-- block_id=block-003 type=paragraph -->
|
||||
Second inserted paragraph.
|
||||
`;
|
||||
const patch = diffMarkdown(oldMd, newMd);
|
||||
expect(patch).toEqual([
|
||||
{
|
||||
op: 'insert_at',
|
||||
index: 1,
|
||||
new_block: {
|
||||
type: 'paragraph',
|
||||
content: 'First inserted paragraph.',
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'insert_at',
|
||||
index: 2,
|
||||
new_block: {
|
||||
type: 'paragraph',
|
||||
content: 'Second inserted paragraph.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff consecutive block deletions', () => {
|
||||
// Two blocks are deleted consecutively
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
First paragraph to be deleted.
|
||||
|
||||
<!-- block_id=block-003 type=paragraph -->
|
||||
Second paragraph to be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
`;
|
||||
const patch = diffMarkdown(oldMd, newMd);
|
||||
expect(patch).toEqual([
|
||||
{
|
||||
op: 'delete',
|
||||
block_id: 'block-002',
|
||||
},
|
||||
{
|
||||
op: 'delete',
|
||||
block_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 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
This paragraph will be deleted
|
||||
|
||||
<!-- block_id=block-003 type=paragraph -->
|
||||
HelloWorld
|
||||
`;
|
||||
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 type=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-003 type=paragraph -->
|
||||
HelloWorld
|
||||
|
||||
<!-- block_id=block-004 type=paragraph -->
|
||||
This is a new paragraph inserted after deletion.
|
||||
`;
|
||||
const patch = diffMarkdown(oldMd, newMd);
|
||||
expect(patch).toEqual([
|
||||
{
|
||||
op: 'insert_at',
|
||||
index: 2,
|
||||
new_block: {
|
||||
type: 'paragraph',
|
||||
content: 'This is a new paragraph inserted after deletion.',
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'delete',
|
||||
block_id: 'block-002',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
} from './widgets/ai-panel/components';
|
||||
import { AIFinishTip } from './widgets/ai-panel/components/finish-tip';
|
||||
import { GeneratingPlaceholder } from './widgets/ai-panel/components/generating-placeholder';
|
||||
import { AFFINE_BLOCK_DIFF_WIDGET, AffineBlockDiffWidget } from './widgets/block-diff/widget';
|
||||
import {
|
||||
AFFINE_EDGELESS_COPILOT_WIDGET,
|
||||
EdgelessCopilotWidget,
|
||||
@@ -158,6 +159,7 @@ export function registerAIEffects() {
|
||||
|
||||
customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget);
|
||||
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
|
||||
customElements.define(AFFINE_BLOCK_DIFF_WIDGET, AffineBlockDiffWidget);
|
||||
|
||||
customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel);
|
||||
customElements.define(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { PatchOp } from '../utils/apply-model/markdown-diff';
|
||||
|
||||
interface DiffMap {
|
||||
// removed blocks
|
||||
deletes: string[];
|
||||
// inserted blocks
|
||||
// key is the start block id, value is the blocks(markdowns) inserted
|
||||
inserts: Record<string, string[]>;
|
||||
// updated blocks
|
||||
// key is the block id, value is the block(markdown)
|
||||
updates: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BlockDiffService {
|
||||
/**
|
||||
* Set the patches to the block diffs
|
||||
* @param patches - The patches to set.
|
||||
* @returns The diff map.
|
||||
*/
|
||||
setPatches(patches: PatchOp[]): DiffMap;
|
||||
}
|
||||
|
||||
export const BlockDiffService = createIdentifier<BlockDiffService>(
|
||||
'AffineBlockDiffService'
|
||||
);
|
||||
|
||||
export class BlockDiffServiceImpl implements BlockDiffService {
|
||||
setPatches(patches: PatchOp[]): DiffMap {
|
||||
const diffMap: DiffMap = {
|
||||
deletes: [],
|
||||
inserts: {},
|
||||
updates: {},
|
||||
};
|
||||
|
||||
for (const patch of patches) {
|
||||
switch (patch.op) {
|
||||
case 'delete':
|
||||
diffMap.deletes.push(patch.block_id);
|
||||
break;
|
||||
case 'insert_at':
|
||||
diffMap.inserts[patch.new_block.id] = [patch.new_block.content];
|
||||
break;
|
||||
case 'replace':
|
||||
diffMap.updates[patch.block_id] = patch.new_content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return diffMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Store } from '@blocksuite/store';
|
||||
|
||||
import { insertFromMarkdown } 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.block_id);
|
||||
} else if (op.op === 'replace') {
|
||||
// Replace block: delete then insert
|
||||
const oldBlock = blockIdMap.get(op.block_id);
|
||||
if (!oldBlock) continue;
|
||||
const parentId = note.id;
|
||||
const index = note.children.findIndex(child => child.id === op.block_id);
|
||||
if (index === -1) continue;
|
||||
doc.deleteBlock(op.block_id);
|
||||
// Insert new content
|
||||
await insertFromMarkdown(undefined, op.new_content, doc, parentId, index);
|
||||
} else if (op.op === 'insert_at') {
|
||||
// Insert new block
|
||||
const parentId = note.id;
|
||||
const index = op.index;
|
||||
await insertFromMarkdown(
|
||||
undefined,
|
||||
op.new_block.content,
|
||||
doc,
|
||||
parentId,
|
||||
index
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export type Block = {
|
||||
block_id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type NewBlock = Omit<Block, 'block_id'>;
|
||||
|
||||
export type PatchOp =
|
||||
| { op: 'replace'; block_id: string; new_content: string }
|
||||
| { op: 'delete'; block_id: string }
|
||||
| { op: 'insert_at'; index: number; new_block: NewBlock };
|
||||
|
||||
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(/^<!--\s*block_id=(.*?)\s+type=(.*?)\s*-->/);
|
||||
if (match) {
|
||||
// If there is a block being collected, push it into blocks first
|
||||
if (currentBlockId && currentType) {
|
||||
blocks.push({
|
||||
block_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({
|
||||
block_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.block_id, { block: b, index: i }));
|
||||
const newMap = new Map<string, { block: Block; index: number }>();
|
||||
newBlocks.forEach((b, i) => newMap.set(b.block_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.block_id);
|
||||
if (old) {
|
||||
handledOld.add(newBlock.block_id);
|
||||
if (old.block.content !== newBlock.content) {
|
||||
patch.push({
|
||||
op: 'replace',
|
||||
block_id: newBlock.block_id,
|
||||
new_content: newBlock.content,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
patch.push({
|
||||
op: 'insert_at',
|
||||
index: newIdx,
|
||||
new_block: {
|
||||
type: newBlock.type,
|
||||
content: newBlock.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Then process deleted oldBlocks
|
||||
oldBlocks.forEach(oldBlock => {
|
||||
if (!newMap.has(oldBlock.block_id)) {
|
||||
patch.push({
|
||||
op: 'delete',
|
||||
block_id: oldBlock.block_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function diffMarkdown(
|
||||
oldMarkdown: string,
|
||||
newMarkdown: string
|
||||
): PatchOp[] {
|
||||
const oldBlocks = parseMarkdownToBlocks(oldMarkdown);
|
||||
const newBlocks = parseMarkdownToBlocks(newMarkdown);
|
||||
|
||||
const patch: PatchOp[] = diffBlockLists(oldBlocks, newBlocks);
|
||||
|
||||
return patch;
|
||||
}
|
||||
@@ -505,6 +505,7 @@ export class AffineAIPanelWidget extends WidgetComponent {
|
||||
}
|
||||
|
||||
override render() {
|
||||
console.log
|
||||
if (this.state === 'hidden') {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/affine/std';
|
||||
import { html } from 'lit';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
export const AFFINE_BLOCK_DIFF_WIDGET = 'affine-block-diff-widget';
|
||||
|
||||
export class AffineBlockDiffWidget extends WidgetComponent {
|
||||
override render() {
|
||||
const attached = this.block?.blockId;
|
||||
return html`<div
|
||||
class="ai-panel-container"
|
||||
data-testid="ai-panel-container"
|
||||
>
|
||||
<h1>Block Diff</h1>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export const blockDiffWidget = WidgetViewExtension(
|
||||
'affine:paragraph',
|
||||
AFFINE_BLOCK_DIFF_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_BLOCK_DIFF_WIDGET)}`
|
||||
);
|
||||
@@ -20,6 +20,7 @@ import { FrameworkProvider } from '@toeverything/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EdgelessClipboardAIChatConfig } from './edgeless-clipboard';
|
||||
import { blockDiffWidget } from '../../ai/widgets/block-diff/widget';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
enable: z.boolean().optional(),
|
||||
@@ -50,6 +51,8 @@ export class AIViewExtension extends ViewExtensionProvider<AIViewOptions> {
|
||||
config: imageToolbarAIEntryConfig(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
if (context.scope === 'edgeless' || context.scope === 'page') {
|
||||
context.register([
|
||||
aiPanelWidget,
|
||||
@@ -73,6 +76,7 @@ export class AIViewExtension extends ViewExtensionProvider<AIViewOptions> {
|
||||
]);
|
||||
}
|
||||
if (context.scope === 'page') {
|
||||
context.register(blockDiffWidget);
|
||||
context.register(getAIPageRootWatcher(framework));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +399,7 @@ __metadata:
|
||||
"@affine/templates": "workspace:*"
|
||||
"@affine/track": "workspace:*"
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
"@blocksuite/affine-shared": "workspace:*"
|
||||
"@blocksuite/icons": "npm:^2.2.13"
|
||||
"@blocksuite/std": "workspace:*"
|
||||
"@dotlottie/player-component": "npm:^2.7.12"
|
||||
@@ -3731,6 +3732,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@blocksuite/affine-shared@workspace:blocksuite/affine/shared"
|
||||
dependencies:
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
"@blocksuite/affine-ext-loader": "workspace:*"
|
||||
"@blocksuite/affine-model": "workspace:*"
|
||||
"@blocksuite/global": "workspace:*"
|
||||
"@blocksuite/icons": "npm:^2.2.12"
|
||||
|
||||
Reference in New Issue
Block a user