snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import rehypeParse from 'rehype-parse';
import rehypeStringify from 'rehype-stringify';
import { unified } from 'unified';
import { describe, expect, it } from 'vitest';
import { rehypeInlineToBlock } from '../../../../adapters/html/rehype-plugins/inline-to-block';
describe('rehypeInlineToBlock', () => {
const process = (html: string) => {
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeInlineToBlock)
.use(rehypeStringify)
.processSync(html)
.toString();
};
it('should not transform inline elements without block children', () => {
const input = '<b>Hello World</b>';
const output = process(input);
expect(output).toBe('<b>Hello World</b>');
});
it('should transform inline elements containing block children', () => {
const input = '<b><p>Hello World</p></b>';
const output = process(input);
expect(output).toBe('<div data-original-tag="b"><p>Hello World</p></div>');
});
it('should preserve existing attributes when transforming', () => {
const input = '<b class="test" id="demo"><p>Hello World</p></b>';
const output = process(input);
expect(output).toBe(
'<div class="test" id="demo" data-original-tag="b"><p>Hello World</p></div>'
);
});
it('should handle multiple block elements within inline element', () => {
const input = '<b><p>First</p><div>Second</div><h1>Third</h1></b>';
const output = process(input);
expect(output).toBe(
'<div data-original-tag="b"><p>First</p><div>Second</div><h1>Third</h1></div>'
);
});
it('should handle mixed content (text and block elements)', () => {
const input = '<b>Text before<p>Block element</p>Text after</b>';
const output = process(input);
expect(output).toBe(
'<div data-original-tag="b">Text before<p>Block element</p>Text after</div>'
);
});
it('should handle complex nested structures', () => {
const input = '<b><div><p>Nested <b>inline</b> content</p></div></b>';
const output = process(input);
expect(output).toBe(
'<div data-original-tag="b"><div><p>Nested <b>inline</b> content</p></div></div>'
);
});
});

View File

@@ -0,0 +1,55 @@
import rehypeParse from 'rehype-parse';
import rehypeStringify from 'rehype-stringify';
import { unified } from 'unified';
import { describe, expect, it } from 'vitest';
import { rehypeWrapInlineElements } from '../../../../adapters/html/rehype-plugins/wrap-inline-element';
describe('rehypeWrapInlineElements', () => {
const process = (html: string) => {
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeWrapInlineElements)
.use(rehypeStringify)
.processSync(html)
.toString();
};
it('should not wrap inline elements without block children in a div tag', () => {
const input = '<div><span>Hello World</span></div>';
const output = process(input);
expect(output).toBe('<div><span>Hello World</span></div>');
});
it('should not wrap elements without inline children in a div tag', () => {
const input = '<div><h1>Hello World</h1></div>';
const output = process(input);
expect(output).toBe('<div><h1>Hello World</h1></div>');
});
it('should wrap inline elements containing block children in a p tag', () => {
const input = '<div><p>Hello World</p><span>Hello World</span></div>';
const output = process(input);
expect(output).toBe(
'<div><p>Hello World</p><p><span>Hello World</span></p></div>'
);
});
it('should wrap inline elements sequentially', () => {
const input =
'<div><p>Hello World</p><span>Hello</span><span>World</span></div>';
const output = process(input);
expect(output).toBe(
'<div><p>Hello World</p><p><span>Hello</span><span>World</span></p></div>'
);
});
it('should wrap inline elements sequentially mixed with block elements', () => {
const input =
'<div><p>Hello World</p><span>Hello</span><span>World</span><h1>Title</h1><span>Hello</span><span>World</span></div>';
const output = process(input);
expect(output).toBe(
'<div><p>Hello World</p><p><span>Hello</span><span>World</span></p><h1>Title</h1><p><span>Hello</span><span>World</span></p></div>'
);
});
});

View File

@@ -0,0 +1,149 @@
import type { Blockquote, Paragraph } from 'mdast';
import remarkMath from 'remark-math';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { describe, expect, it } from 'vitest';
import { remarkGfm } from '../../../../adapters/markdown/gfm';
import { remarkCallout } from '../../../../adapters/markdown/remark-plugins/remark-callout';
import type { MarkdownAST } from '../../../../adapters/markdown/type';
describe('remarkCallout plugin', () => {
function isBlockQuote(node: MarkdownAST): node is Blockquote {
return node.type === 'blockquote';
}
function isParagraph(node: MarkdownAST): node is Paragraph {
return node.type === 'paragraph';
}
const process = (content: string) => {
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkCallout);
const ast = processor.parse(content);
return processor.runSync(ast);
};
const assertCallout = (
root: any,
expectedEmoji: string,
expectedText?: string
) => {
const firstChild = root.children[0];
expect(isBlockQuote(firstChild)).toBe(true);
expect(firstChild.data).toEqual({
isCallout: true,
calloutEmoji: expectedEmoji,
});
if (expectedText !== undefined) {
if (expectedText === '') {
// if expectedText is empty, the callout should not have any children
expect(firstChild.children).toHaveLength(0);
} else {
const firstParagraph = firstChild.children[0];
expect(isParagraph(firstParagraph)).toBe(true);
expect(firstParagraph.children[0].value).toBe(expectedText);
}
}
};
const assertRegularBlockquote = (root: any, expectedText: string) => {
const firstChild = root.children[0];
expect(isBlockQuote(firstChild)).toBe(true);
expect(firstChild.data).toBeUndefined();
const firstParagraph = firstChild.children[0];
expect(isParagraph(firstParagraph)).toBe(true);
expect(firstParagraph.children[0].value).toBe(expectedText);
};
it('should transform callout with emoji and text in the same line', async () => {
const root = process('> [!💡] This is a callout with emoji');
assertCallout(root, '💡', 'This is a callout with emoji');
});
it('should transform callout without emoji and text in the same line', async () => {
const root = process('> [!] This is a callout without emoji');
assertCallout(root, '', 'This is a callout without emoji');
});
it('should handle callout with multiple lines and text in the different line', async () => {
const root = process('> [!💡]\n> with multiple lines');
assertCallout(root, '💡', 'with multiple lines');
});
it('should handle empty callout', async () => {
const root = process('> [!💡]');
assertCallout(root, '💡', '');
});
it('should handle callout with leading whitespace', async () => {
const root = process(
'> [!💡]\n> This is a callout with leading whitespace\n '
);
assertCallout(root, '💡', 'This is a callout with leading whitespace');
});
it('should handle callout with trailing whitespace', async () => {
const root = process(
'> [!💡]\n> This is a callout with trailing whitespace\n '
);
assertCallout(root, '💡', 'This is a callout with trailing whitespace');
});
it('should not transform regular blockquote', async () => {
const root = process('> This is a regular blockquote');
assertRegularBlockquote(root, 'This is a regular blockquote');
});
it('should not transform regular blockquote when the emoji is not in the start of the line', async () => {
const root = process('> This is a regular blockquote [!💡]');
assertRegularBlockquote(root, 'This is a regular blockquote [!💡]');
});
it('should not transform when callout marker is in the middle of text', async () => {
const root = process(
'> This is a regular blockquote with [!💡] in the middle'
);
assertRegularBlockquote(
root,
'This is a regular blockquote with [!💡] in the middle'
);
});
it('should handle multiple callouts in the same document', async () => {
const root = process(
`> [!💡] First callout\n\n> [!] Second callout without emoji`
);
expect(root.children).toHaveLength(2);
assertCallout({ children: [root.children[0]] }, '💡', 'First callout');
assertCallout(
{ children: [root.children[1]] },
'',
'Second callout without emoji'
);
});
it('should handle multiple callouts and regular blockquote in the same document', async () => {
const root = process(
`> [!💡] First callout\n\n> [!] Second callout without emoji\n\n> This is a regular blockquote`
);
expect(root.children).toHaveLength(3);
assertCallout({ children: [root.children[0]] }, '💡', 'First callout');
assertCallout(
{ children: [root.children[1]] },
'',
'Second callout without emoji'
);
assertRegularBlockquote(
{ children: [root.children[2]] },
'This is a regular blockquote'
);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { getImageFullPath } from '../../../adapters/utils/file-path';
describe('getImageFullPath', () => {
it('should resolve relative image paths correctly', () => {
const filePath = 'path/to/markdown/file.md';
// Test relative path in same directory
expect(getImageFullPath(filePath, 'image.png')).toBe(
'path/to/markdown/image.png'
);
// Test relative path in subdirectory
expect(getImageFullPath(filePath, 'images/photo.jpg')).toBe(
'path/to/markdown/images/photo.jpg'
);
// Test relative path in subdirectory
expect(getImageFullPath(filePath, './images/photo.jpg')).toBe(
'path/to/markdown/images/photo.jpg'
);
// Test relative path with parent directory
expect(getImageFullPath(filePath, '../images/photo.jpg')).toBe(
'path/to/images/photo.jpg'
);
// Test relative path with multiple parent directories
expect(getImageFullPath(filePath, '../../images/photo.jpg')).toBe(
'path/images/photo.jpg'
);
// Test relative path with multiple parent directories (which is not supported)
expect(getImageFullPath(filePath, '../../../../images/photo.jpg')).toBe(
'images/photo.jpg'
);
});
it('should handle absolute image paths correctly', () => {
const filePath = 'path/to/markdown/file.md';
// Test absolute path
expect(getImageFullPath(filePath, '/images/photo.jpg')).toBe(
'images/photo.jpg'
);
});
it('should handle URL-encoded image paths correctly', () => {
const filePath = 'path/to/markdown/file.md';
// Test URL-encoded spaces
expect(getImageFullPath(filePath, 'my%20photo.jpg')).toBe(
'path/to/markdown/my photo.jpg'
);
});
});

View File

@@ -0,0 +1,230 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest';
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getFirstBlockCommand', () => {
it('should return null when root is not exists', () => {
const host = affine`<affine-page></affine-page>`;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
role: 'content',
root: undefined,
});
expect(firstBlock).toBeNull();
});
it('should return first block with content role when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-1-2">Second Paragraph</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2-2">Second Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
role: 'hub',
root: undefined,
});
expect(firstBlock?.id).toBe('note-1');
});
it('should return first block with any role in the array when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-1-2">Second Paragraph</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2-2">Second Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
role: ['hub', 'content'],
root: undefined,
});
expect(firstBlock?.id).toBe('note-1');
});
it('should return first block with specified flavour when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Paragraph</affine-paragraph>
<affine-list id="list-1">List Item</affine-list>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
flavour: 'affine:list',
root: note,
});
expect(firstBlock?.id).toBe('list-1');
});
it('should return first block with any flavour in the array when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Paragraph</affine-paragraph>
<affine-list id="list-1">List Item</affine-list>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
flavour: ['affine:list', 'affine:code'],
root: note,
});
expect(firstBlock?.id).toBe('list-1');
});
it('should return first block matching both role and flavour when both specified', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Content Paragraph</affine-paragraph>
<affine-list id="list-1">Content List</affine-list>
<affine-paragraph id="paragraph-2">hub Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
role: 'content',
flavour: 'affine:list',
root: note,
});
expect(firstBlock?.id).toBe('list-1');
});
it('should return first block with default roles when role not specified', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">hub Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Content Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-3">Hub Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
root: undefined,
});
expect(firstBlock?.id).toBe('note-1');
});
it('should return first block with specified role when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Content Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">hub Paragraph</affine-paragraph>
<affine-database id="database-1">Database</affine-database>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
role: 'hub',
root: note,
});
expect(firstBlock?.id).toBe('database-1');
});
it('should return null when no blocks with specified role are found in children', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Content Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Another Content Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
role: 'hub',
root: note,
});
expect(firstBlock).toBeNull();
});
it('should return null when no blocks with specified flavour are found in children', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Another Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
flavour: 'affine:list',
root: note,
});
expect(firstBlock).toBeNull();
});
it('should return first block with specified role within specified root subtree', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">1-1 Content</affine-paragraph>
<affine-paragraph id="paragraph-1-2">1-2 hub</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">2-1 hub</affine-paragraph>
<affine-paragraph id="paragraph-2-2">2-2 Content</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-2')?.model;
const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, {
role: 'content',
root: note,
});
expect(firstBlock?.id).toBe('paragraph-2-1');
});
});
});

View File

@@ -0,0 +1,230 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest';
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getLastBlockCommand', () => {
it('should return null when root is not exists', () => {
const host = affine`<affine-page></affine-page>`;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
role: 'content',
root: undefined,
});
expect(lastBlock).toBeNull();
});
it('should return last block with content role when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-1-2">Second Paragraph</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2-2">Second Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
role: 'hub',
root: undefined,
});
expect(lastBlock?.id).toBe('note-2');
});
it('should return last block with any role in the array when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-1-2">Second Paragraph</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">First Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2-2">Second Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
role: ['hub', 'content'],
root: undefined,
});
expect(lastBlock?.id).toBe('note-2');
});
it('should return last block with specified flavour when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Paragraph</affine-paragraph>
<affine-list id="list-1">List Item</affine-list>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
flavour: 'affine:list',
root: note,
});
expect(lastBlock?.id).toBe('list-1');
});
it('should return last block with any flavour in the array when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Paragraph</affine-paragraph>
<affine-list id="list-1">List Item</affine-list>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
flavour: ['affine:list', 'affine:code'],
root: note,
});
expect(lastBlock?.id).toBe('list-1');
});
it('should return last block matching both role and flavour when both specified', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Content Paragraph</affine-paragraph>
<affine-list id="list-1">Content List</affine-list>
<affine-paragraph id="paragraph-2">hub Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
role: 'content',
flavour: 'affine:list',
root: note,
});
expect(lastBlock?.id).toBe('list-1');
});
it('should return last block with default roles when role not specified', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">hub Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Content Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-3">Hub Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
root: undefined,
});
expect(lastBlock?.id).toBe('note-1');
});
it('should return last block with specified role when found', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Content Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">hub Paragraph</affine-paragraph>
<affine-database id="database-1">Database</affine-database>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
role: 'hub',
root: note,
});
expect(lastBlock?.id).toBe('database-1');
});
it('should return null when no blocks with specified role are found in children', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Content Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Another Content Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
role: 'hub',
root: note,
});
expect(lastBlock).toBeNull();
});
it('should return null when no blocks with specified flavour are found in children', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Paragraph</affine-paragraph>
<affine-paragraph id="paragraph-2">Another Paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-1')?.model;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
flavour: 'affine:list',
root: note,
});
expect(lastBlock).toBeNull();
});
it('should return last block with specified role within specified root subtree', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1-1">1-1 Content</affine-paragraph>
<affine-paragraph id="paragraph-1-2">1-2 hub</affine-paragraph>
</affine-note>
<affine-note id="note-2">
<affine-paragraph id="paragraph-2-1">2-1 hub</affine-paragraph>
<affine-paragraph id="paragraph-2-2">2-2 Content</affine-paragraph>
</affine-note>
</affine-page>
`;
const note = host.store.getBlock('note-2')?.model;
const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, {
role: 'content',
root: note,
});
expect(lastBlock?.id).toBe('paragraph-2-2');
});
});
});

View File

@@ -0,0 +1,519 @@
/**
* @vitest-environment happy-dom
*/
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 '../../../test-utils';
describe('commands/model-crud', () => {
describe('replaceSelectedTextWithBlocksCommand', () => {
it('should replace selected text with blocks when both first and last blocks are mergable blocks', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph>
<affine-paragraph id="paragraph-2">Wor<focus />ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-paragraph id="111">111</affine-paragraph>`,
block`<affine-code id="code"></affine-code>`,
block`<affine-paragraph id="222">222</affine-paragraph>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel111</affine-paragraph>
<affine-code id="code"></affine-code>
<affine-paragraph id="paragraph-2">222ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when both first and last blocks are mergable blocks in single paragraph', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-paragraph id="111">111</affine-paragraph>`,
block`<affine-code id="code"></affine-code>`,
block`<affine-paragraph id="222">222</affine-paragraph>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel111</affine-paragraph>
<affine-code id="code"></affine-code>
<affine-paragraph id="222">222ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when blocks contains only one mergable block', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph>
<affine-paragraph id="paragraph-2">Wor<focus />ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [block`<affine-paragraph id="111">111</affine-paragraph>`]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel111ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when blocks contains only one mergable block in single paragraph', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [block`<affine-paragraph id="111">111</affine-paragraph>`]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel111ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when only first block is mergable block', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
<affine-paragraph>Wor<focus />ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-paragraph>111</affine-paragraph>`,
block`<affine-code></affine-code>`,
block`<affine-code></affine-code>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note >
<affine-paragraph>Hel111</affine-paragraph>
<affine-code></affine-code>
<affine-code></affine-code>
<affine-paragraph>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when only first block is mergable block in single paragraph', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-paragraph>111</affine-paragraph>`,
block`<affine-code></affine-code>`,
block`<affine-code></affine-code>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel111</affine-paragraph>
<affine-code></affine-code>
<affine-code></affine-code>
<affine-paragraph>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when only last block is mergable block', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
<affine-paragraph>Wor<focus />ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-code></affine-code>`,
block`<affine-code></affine-code>`,
block`<affine-paragraph>111</affine-paragraph>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note >
<affine-paragraph>Hel</affine-paragraph>
<affine-code></affine-code>
<affine-code></affine-code>
<affine-paragraph>111ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when only last block is mergable block in single paragraph', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-code></affine-code>`,
block`<affine-code></affine-code>`,
block`<affine-paragraph>111</affine-paragraph>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel</affine-paragraph>
<affine-code></affine-code>
<affine-code></affine-code>
<affine-paragraph>111ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when neither first nor last block is mergable block', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
<affine-paragraph>Wor<focus />ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-code></affine-code>`,
block`<affine-code></affine-code>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note >
<affine-paragraph>Hel</affine-paragraph>
<affine-code></affine-code>
<affine-code></affine-code>
<affine-paragraph>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when neither first nor last block is mergable block in single paragraph', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-code></affine-code>`,
block`<affine-code></affine-code>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel</affine-paragraph>
<affine-code></affine-code>
<affine-code></affine-code>
<affine-paragraph>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when both first and last blocks are mergable blocks with different types', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
<affine-paragraph>Wor<focus />ld</affine-paragraph>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-list>1.</affine-list>`,
block`<affine-list>2.</affine-list>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note >
<affine-paragraph>Hel</affine-paragraph>
<affine-list>1.</affine-list>
<affine-list>2.</affine-list>
<affine-paragraph>ld</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when both first and last blocks are paragraphs, and cursor is at the end of the text-block with different types', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-list>Hel<anchor />lo</affine-list>
<affine-list>Wor<focus />ld</affine-list>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-paragraph>111</affine-paragraph>`,
block`<affine-paragraph>222</affine-paragraph>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note >
<affine-list>Hel111</affine-list>
<affine-list>222ld</affine-list>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when first block is paragraph, and cursor is at the end of the text-block with different type ', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-list>Hel<anchor />lo</affine-list>
<affine-list>Wor<focus />ld</affine-list>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-paragraph>111</affine-paragraph>`,
block`<affine-code></affine-code>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note >
<affine-list>Hel111</affine-list>
<affine-code></affine-code>
<affine-list>ld</affine-list>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace selected text with blocks when last block is paragraph, and cursor is at the end of the text-block with different type ', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-list>Hel<anchor />lo</affine-list>
<affine-list>Wor<focus />ld</affine-list>
</affine-note>
</affine-page>
`;
const blocks = [
block`<affine-code></affine-code>`,
block`<affine-paragraph>222</affine-paragraph>`,
]
.filter((b): b is NonNullable<typeof b> => b !== null)
.map(b => b.model);
const textSelection = host.selection.value[0] as TextSelection;
host.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
const expected = affine`
<affine-page>
<affine-note >
<affine-list>Hel</affine-list>
<affine-code></affine-code>
<affine-list>222ld</affine-list>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
});
});

View File

@@ -0,0 +1,147 @@
/**
* @vitest-environment happy-dom
*/
import { BlockSelection, TextSelection } from '@blocksuite/std';
import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection';
import { affine } from '../../../test-utils';
describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => {
it('should return true when nothing is selected', () => {
const host = affine`<affine-page></affine-page>`;
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{}
);
expect(isNothingSelected).toBe(true);
});
it('should return false when text selection exists', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
// Mock text selection
const textSelection = new TextSelection({
from: {
blockId: 'paragraph-1',
index: 0,
length: 0,
},
to: {
blockId: 'paragraph-1',
index: 4,
length: 0,
},
});
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{
currentTextSelection: textSelection,
}
);
expect(isNothingSelected).toBe(false);
});
it('should return false when block selection exists', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
// Mock block selection
const blockSelection = new BlockSelection({
blockId: 'paragraph-1',
});
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{
currentBlockSelections: [blockSelection],
}
);
expect(isNothingSelected).toBe(false);
});
it('should return false when image selection exists', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-image id="image-1">Test paragraph</affine-image>
</affine-note>
</affine-page>
`;
// Mock image selection
const imageSelection = new ImageSelection({
blockId: 'image-1',
});
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
{
currentImageSelections: [imageSelection],
}
);
expect(isNothingSelected).toBe(false);
});
it('should return false when no selection is provided but selection is found in context', () => {
const host = affine`
<affine-page>
<affine-note id="note-1">
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
// Mock selection behavior via vi.spyOn before executing the command
const mockTextSelection = new TextSelection({
from: {
blockId: 'paragraph-1',
index: 0,
length: 0,
},
to: null,
});
const mockContext = {
// No explicit `currentTextSelection provided
std: {
selection: {
find: vi.fn().mockImplementation(type => {
if (type === TextSelection) {
return mockTextSelection;
}
return null;
}),
filter: vi.fn().mockReturnValue([]),
},
},
};
const [_, { isNothingSelected }] = host.command.exec(
isNothingSelectedCommand,
mockContext as any
);
expect(isNothingSelected).toBe(false);
});
});
});

View File

@@ -0,0 +1,159 @@
import { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { affine } from '../../test-utils';
describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello, world</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toBeDefined();
const pageBlock = host.store.getBlock('page');
expect(pageBlock).toBeDefined();
expect(pageBlock?.flavour).toBe('affine:page');
const noteBlock = host.store.getBlock('note');
expect(noteBlock).toBeDefined();
expect(noteBlock?.flavour).toBe('affine:note');
const paragraphBlock = host.store.getBlock('paragraph-1');
expect(paragraphBlock).toBeDefined();
expect(paragraphBlock?.flavour).toBe('affine:paragraph');
});
it('should handle nested blocks correctly', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph>First paragraph</affine-paragraph>
<affine-list>List item</affine-list>
<affine-paragraph>Second paragraph</affine-paragraph>
</affine-note>
</affine-page>
`;
const noteBlocks = host.store.getBlocksByFlavour('affine:note');
const paragraphBlocks = host.store.getBlocksByFlavour('affine:paragraph');
const listBlocks = host.store.getBlocksByFlavour('affine:list');
expect(noteBlocks.length).toBe(1);
expect(paragraphBlocks.length).toBe(2);
expect(listBlocks.length).toBe(1);
const noteBlock = noteBlocks[0];
const noteChildren =
host.store.getBlock(noteBlock.id)?.model.children || [];
expect(noteChildren.length).toBe(3);
expect(noteChildren[0].flavour).toBe('affine:paragraph');
expect(noteChildren[1].flavour).toBe('affine:list');
expect(noteChildren[2].flavour).toBe('affine:paragraph');
});
it('should handle empty blocks correctly', () => {
const host = affine`
<affine-page>
<affine-note>
<affine-paragraph></affine-paragraph>
</affine-note>
</affine-page>
`;
const paragraphBlocks = host.store.getBlocksByFlavour('affine:paragraph');
expect(paragraphBlocks.length).toBe(1);
const paragraphBlock = host.store.getBlock(paragraphBlocks[0].id);
const paragraphText = paragraphBlock?.model.text?.toString() || '';
expect(paragraphText).toBe('');
});
it('should throw error on invalid template', () => {
expect(() => {
affine`
<unknown-tag></unknown-tag>
`;
}).toThrow();
});
it('should handle text selection with anchor and focus', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph>
<affine-paragraph id="paragraph-2">Wo<focus />rld</affine-paragraph>
</affine-note>
</affine-page>
`;
const selection = host.selection.value[0] as TextSelection;
expect(selection).toBeDefined();
expect(selection.is(TextSelection)).toBe(true);
expect(selection.from.blockId).toBe('paragraph-1');
expect(selection.from.index).toBe(3);
expect(selection.from.length).toBe(2);
expect(selection.to?.blockId).toBe('paragraph-2');
expect(selection.to?.index).toBe(0);
expect(selection.to?.length).toBe(2);
});
it('should handle cursor position', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello<cursor />World</affine-paragraph>
</affine-note>
</affine-page>
`;
const selection = host.selection.value[0] as TextSelection;
expect(selection).toBeDefined();
expect(selection.is(TextSelection)).toBe(true);
expect(selection.from.blockId).toBe('paragraph-1');
expect(selection.from.index).toBe(5);
expect(selection.from.length).toBe(0);
expect(selection.to).toBeNull();
});
it('should handle selection in empty blocks', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1"><cursor /></affine-paragraph>
</affine-note>
</affine-page>
`;
const selection = host.selection.value[0] as TextSelection;
expect(selection).toBeDefined();
expect(selection.is(TextSelection)).toBe(true);
expect(selection.from.blockId).toBe('paragraph-1');
expect(selection.from.index).toBe(0);
expect(selection.from.length).toBe(0);
expect(selection.to).toBeNull();
});
it('should handle single point selection', () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello<anchor></anchor>World<focus></focus>Affine</affine-paragraph>
</affine-note>
</affine-page>
`;
const selection = host.selection.value[0] as TextSelection;
expect(selection).toBeDefined();
expect(selection.is(TextSelection)).toBe(true);
expect(selection.from.blockId).toBe('paragraph-1');
expect(selection.from.index).toBe(5);
expect(selection.from.length).toBe(5);
expect(selection.to).toBeNull();
});
});

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getImageFilesFromLocal,
registerNativeImageFilesPicker,
} from '../../utils';
describe('getImageFilesFromLocal', () => {
let originalShowPicker: ((this: HTMLInputElement) => void) | undefined;
beforeEach(() => {
originalShowPicker = (
HTMLInputElement.prototype as HTMLInputElement & {
showPicker?: (this: HTMLInputElement) => void;
}
).showPicker;
(
HTMLInputElement.prototype as HTMLInputElement & {
showPicker?: (this: HTMLInputElement) => void;
}
).showPicker = vi.fn(function (this: HTMLInputElement) {
this.dispatchEvent(new Event('cancel'));
});
});
afterEach(() => {
if (originalShowPicker) {
(
HTMLInputElement.prototype as HTMLInputElement & {
showPicker?: (this: HTMLInputElement) => void;
}
).showPicker = originalShowPicker;
} else {
delete (
HTMLInputElement.prototype as unknown as {
showPicker?: unknown;
}
).showPicker;
}
registerNativeImageFilesPicker(null);
vi.restoreAllMocks();
});
it('prefers the registered native image picker when available', async () => {
const file = new File(['picked-image'], 'picked.png', {
type: 'image/png',
lastModified: 123,
});
const nativePicker = vi.fn(async () => [file]);
registerNativeImageFilesPicker(nativePicker);
const files = await getImageFilesFromLocal();
expect(nativePicker).toHaveBeenCalledTimes(1);
expect(files).toEqual([file]);
});
it('falls back to the standard picker when the native picker rejects', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const nativePicker = vi.fn(async () => {
throw new Error('Native picker failed');
});
registerNativeImageFilesPicker(nativePicker);
const files = await getImageFilesFromLocal();
expect(nativePicker).toHaveBeenCalledTimes(1);
expect(files).toEqual([]);
expect(
(
HTMLInputElement.prototype as HTMLInputElement & {
showPicker?: (this: HTMLInputElement) => void;
}
).showPicker
).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,108 @@
import {
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from 'vitest';
import * as PointToRangeUtils from '../../utils/dom/point-to-range';
import { handleNativeRangeAtPoint } from '../../utils/dom/point-to-range';
describe('test handleNativeRangeAtPoint', () => {
let caretRangeFromPointSpy: MockInstance<
(clientX: number, clientY: number) => Range | null
>;
let resetNativeSelectionSpy: MockInstance<(range: Range | null) => void>;
beforeEach(() => {
caretRangeFromPointSpy = vi.spyOn(
PointToRangeUtils.api,
'caretRangeFromPoint'
);
resetNativeSelectionSpy = vi.spyOn(
PointToRangeUtils.api,
'resetNativeSelection'
);
});
it('does nothing if caretRangeFromPoint returns null', () => {
caretRangeFromPointSpy.mockReturnValue(null);
handleNativeRangeAtPoint(10, 10);
expect(resetNativeSelectionSpy).not.toHaveBeenCalled();
});
it('keeps range untouched if startContainer is a Text node', () => {
const div = document.createElement('div');
div.textContent = 'hello';
const text = div.firstChild!;
const range = document.createRange();
range.setStart(text, 2);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer).toBe(text);
expect(range.startOffset).toBe(2);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
it('moves caret into direct text child when clicking element', () => {
const div = document.createElement('div');
div.append('hello');
const range = document.createRange();
range.setStart(div, 1);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
expect(range.startContainer.textContent).toBe('hello');
expect(range.startOffset).toBe(5);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
it('moves caret to last meaningful text inside nested element', () => {
const div = document.createElement('div');
div.innerHTML = `<span>a</span><span><em>b</em>c</span>`;
const range = document.createRange();
range.setStart(div, 2);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
expect(range.startContainer.textContent).toBe('c');
expect(range.startOffset).toBe(1);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
it('falls back to searching startContainer when offset element has no text', () => {
const div = document.createElement('div');
div.innerHTML = `<span></span><span>ok</span>`;
const range = document.createRange();
range.setStart(div, 1);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer.textContent).toBe('ok');
expect(range.startOffset).toBe(2);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { Point } from '@blocksuite/global/gfx';
import { describe, expect, it } from 'vitest';
describe('Point', () => {
it('should return a min point', () => {
const a = new Point(0, 0);
const b = new Point(-1, 1);
expect(Point.min(a, b)).toEqual(new Point(-1, 0));
});
it('should return a max point', () => {
const a = new Point(0, 0);
const b = new Point(-1, 1);
expect(Point.max(a, b)).toEqual(new Point(0, 1));
});
it('should return a clamp point', () => {
const min = new Point(0, 0);
const max = new Point(1, 1);
const a = new Point(-1, 2);
expect(Point.clamp(a, min, max)).toEqual(new Point(0, 1));
const b = new Point(2, 2);
expect(Point.clamp(b, min, max)).toEqual(new Point(1, 1));
const c = new Point(0.5, 0.5);
expect(Point.clamp(c, min, max)).toEqual(new Point(0.5, 0.5));
});
it('should return a copy of point', () => {
const a = new Point(0, 0);
expect(a.clone()).toEqual(new Point(0, 0));
});
it('#set method should set x and y', () => {
const p = new Point(0, 0);
p.set(1, 2);
expect(p).toEqual(new Point(1, 2));
});
it('#add', () => {
const a = new Point(1, 2);
const b = new Point(3, 4);
expect(a.add(b)).toEqual(new Point(4, 6));
});
it('#subtract', () => {
const a = new Point(1, 2);
const b = new Point(3, 4);
expect(a.subtract(b)).toEqual(new Point(-2, -2));
});
it('#scale', () => {
const a = new Point(1, 2);
expect(a.scale(2)).toEqual(new Point(2, 4));
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { isFuzzyMatch, substringMatchScore } from '../../utils/string.js';
describe('fuzzyMatch', () => {
it('basic case', () => {
expect(isFuzzyMatch('John Smith', 'j')).toEqual(true);
expect(isFuzzyMatch('John Smith', 'js')).toEqual(true);
expect(isFuzzyMatch('John Smith', 'jsa')).toEqual(false);
});
it('should works with CJK', () => {
expect(isFuzzyMatch('中', '中')).toEqual(true);
expect(isFuzzyMatch('中文', '中')).toEqual(true);
expect(isFuzzyMatch('中文字符', '中字')).toEqual(true);
expect(isFuzzyMatch('中文字符', '字中')).toEqual(false);
});
it('should works with IME', () => {
// IME will generate a space between 'da' and 't'
expect(isFuzzyMatch('database', 'da t')).toEqual(true);
});
});
describe('substringMatchScore', () => {
it('should return a fraction if there exists a common maximal length substring. ', () => {
const result = substringMatchScore('testing the function', 'tet');
expect(result).toBeLessThan(1);
expect(result).toBeGreaterThan(0);
});
it('should return bigger score for longer match', () => {
const result = substringMatchScore('testing the function', 'functin');
const result2 = substringMatchScore('testing the function', 'tet');
// because th length of common substring of 'functin' is bigger than 'tet'
expect(result).toBeGreaterThan(result2);
});
it('should return bigger score when using same query to search a shorter string', () => {
const result = substringMatchScore('test', 'test');
const result2 = substringMatchScore('testing the function', 'test');
expect(result).toBeGreaterThan(result2);
});
it('should return 0 when there is no match', () => {
const result = substringMatchScore('abc', 'defghijk');
expect(result).toBe(0);
});
it('should handle cases where the query is longer than the string', () => {
const result = substringMatchScore('short', 'longer substring');
expect(result).toBe(0);
});
it('should handle empty strings correctly', () => {
const result = substringMatchScore('any string', '');
expect(result).toBe(0);
});
it('should handle both strings being empty', () => {
const result = substringMatchScore('', '');
expect(result).toBe(0);
});
it('should handle cases where both strings are identical', () => {
const result = substringMatchScore('identical', 'identical');
expect(result).toBe(1);
});
});

View File

@@ -0,0 +1,191 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import { sanitizeSvg } from '../../utils/svg.js';
type HappyDOMWindow = Window & {
happyDOM: {
setURL: (url: string) => void;
};
};
function setLocation(url: string) {
(window as unknown as HappyDOMWindow).happyDOM.setURL(url);
}
function svgDataUrl(svg: string) {
const bytes = new TextEncoder().encode(svg);
let binary = '';
bytes.forEach(byte => {
binary += String.fromCharCode(byte);
});
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function decodeSvgDataUrl(dataUrl: string) {
const base64 = dataUrl.split(',')[1];
return new TextDecoder().decode(
Uint8Array.from(atob(base64), char => char.charCodeAt(0))
);
}
describe('sanitizeSvg', () => {
test('wraps DOMPurify svg fragments back into an svg root', () => {
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"></rect></svg>'
);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
});
test('accepts svg documents with xml and doctype prefixes', () => {
const sanitized = sanitizeSvg(`<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="100" height="100"></rect>
</svg>`);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
expect(sanitized).not.toContain('<!DOCTYPE');
});
test('rejects non-svg roots', () => {
expect(sanitizeSvg('<div><svg></svg></div>')).toBe('');
});
test('rejects malformed doctype prefixes without regexp backtracking', () => {
const maliciousPrefix = '<!doctype' + '?><!doctype'.repeat(10_000);
expect(sanitizeSvg(`${maliciousPrefix}<div></div>`)).toBe('');
});
test('keeps internal glyph references and safe image data urls', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<defs><path id="glyph-a" d="M0 0h10v10z"></path></defs>
<use href="#glyph-a"></use>
<use xlink:href="#glyph-a"></use>
<a xlink:href="https://typst.app/docs/tutorial"><path d="M0 0h10v10z"></path></a>
<image href="data:image/png;base64,AAAA" width="10" height="10"></image>
</svg>
`);
expect(sanitized).toContain('href="#glyph-a"');
expect(sanitized).toContain('xlink:href="#glyph-a"');
expect(sanitized).toContain('xlink:href="https://typst.app/docs/tutorial"');
expect(sanitized).toContain('data:image/png;base64,AAAA');
});
test('removes external glyph references and unsafe css', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<style>@import "https://example.com/style.css"; .a { fill: #000; }</style>
<use href="https://example.com/glyph.svg#x"></use>
<use xlink:href="https://example.com/glyph.svg#x"></use>
<a xlink:href="javascript:alert(1)"><path d="M0 0h10v10z"></path></a>
<image href="https://example.com/image.png" width="10" height="10"></image>
<path style="fill: url(https://example.com/pattern.svg#x)" d="M0 0h10v10z"></path>
</svg>
`);
expect(sanitized).not.toContain('https://example.com');
expect(sanitized).not.toContain('javascript:');
expect(sanitized).not.toContain('@import');
expect(sanitized).not.toContain('url(');
});
test('removes links sharing the current registrable domain', () => {
setLocation('https://sub.example.co.uk/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://sub.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://other.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a xlink:href="https://example.com/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://sub.example.co.uk/docs');
expect(sanitized).not.toContain('https://other.example.co.uk/docs');
expect(sanitized).toContain('https://example.com/docs');
});
test('keeps private suffix sibling domains separate', () => {
setLocation('https://foo.github.io/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://foo.github.io/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://bar.github.io/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://foo.github.io/docs');
expect(sanitized).toContain('https://bar.github.io/docs');
});
test('handles local hostnames by exact hostname', () => {
setLocation('http://localhost:3000/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="http://localhost:8080/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://share.localhost/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://127.0.0.1/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('http://localhost:8080/docs');
expect(sanitized).toContain('http://share.localhost/docs');
expect(sanitized).toContain('http://127.0.0.1/docs');
});
test('recursively sanitizes svg images', () => {
const nestedSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="glyph-a" d="M0 0h10v10z"></path></defs><use href="#glyph-a"></use><use href="https://example.com/glyph.svg#x"></use></svg>'
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${nestedSvg}" width="10" height="10"></image>
</svg>
`);
const sanitizedImageHref = sanitized.match(/href="([^"]+)"/)?.[1];
expect(sanitizedImageHref).toMatch(/^data:image\/svg\+xml;base64,/);
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('<svg');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('#glyph-a');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).not.toContain(
'https://example.com'
);
});
test('removes svg images nested deeper than two levels', () => {
const thirdLevelSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"></rect></svg>'
);
const secondLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${thirdLevelSvg}"></image></svg>`
);
const firstLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${secondLevelSvg}"></image></svg>`
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${firstLevelSvg}"></image>
</svg>
`);
const firstLevelHref = sanitized.match(/href="([^"]+)"/)?.[1];
const firstLevelSanitizedSvg = decodeSvgDataUrl(firstLevelHref ?? '');
const secondLevelHref = firstLevelSanitizedSvg.match(/href="([^"]+)"/)?.[1];
const secondLevelSanitizedSvg = decodeSvgDataUrl(secondLevelHref ?? '');
expect(firstLevelSanitizedSvg).toContain('<image');
expect(secondLevelSanitizedSvg).not.toContain('<image');
});
});

View File

@@ -0,0 +1,139 @@
import { describe, expect, test } from 'vitest';
import { isValidUrl, splitTextByUrl } from '../../utils/url.js';
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
test('basic case', () => {
expect(isValidUrl('')).toEqual(false);
expect(isValidUrl('1.co')).toEqual(true);
expect(isValidUrl('https://www.example.com')).toEqual(true);
expect(isValidUrl('www.example.com')).toEqual(true);
expect(isValidUrl('http://www.github.com/toeverything/blocksuite')).toEqual(
true
);
});
test('CAUTION: any link include allowed schema is a valid url!', () => {
expect(isValidUrl('http://www.example.cm')).toEqual(true);
expect(isValidUrl('https://x ')).toEqual(true);
expect(isValidUrl('mailto://w:80')).toEqual(true);
});
test('link include a unknown schema is not a valid url', () => {
expect(isValidUrl('xxx://www.example.com')).toEqual(false);
expect(isValidUrl('https://')).toEqual(false);
expect(isValidUrl('http://w.... !@#*(!!!!')).toEqual(false);
});
test('URL without protocol is a valid URL', () => {
expect(isValidUrl('www.example.com')).toEqual(true);
expect(isValidUrl('example.co')).toEqual(true);
expect(isValidUrl('example.cm')).toEqual(true);
expect(isValidUrl('1.1.1.1')).toEqual(false);
expect(isValidUrl('example.c')).toEqual(false);
});
test('special cases', () => {
expect(isValidUrl('example.com.')).toEqual(false);
// I don't know why
// private & local networks is excluded
expect(isValidUrl('127.0.0.1')).toEqual(false);
expect(isValidUrl('10.0.0.1')).toEqual(false);
expect(isValidUrl('localhost')).toEqual(false);
expect(isValidUrl('0.0.0.0')).toEqual(false);
expect(isValidUrl('128.0.0.1')).toEqual(false);
expect(isValidUrl('1.0.0.1')).toEqual(false);
});
test('email link is a valid URL', () => {
// See https://www.rapidtables.com/web/html/mailto.html
expect(isValidUrl('mailto:name@email.com')).toEqual(true);
expect(
isValidUrl(
'mailto:name@rapidtables.com?subject=The%20subject%20of%20the%20mail'
)
).toEqual(true);
expect(
isValidUrl(
'mailto:name1@rapidtables.com?cc=name2@rapidtables.com&bcc=name3@rapidtables.com&subject=The%20subject%20of%20the%20email&body=The%20body%20of%20the%20email'
)
).toEqual(true);
// multiple email recipients
expect(isValidUrl('mailto:name1@mail.com,name2@mail.com')).toEqual(true);
});
test('misc case', () => {
// Emoji domain
expect(isValidUrl('xn--i-7iq.ws')).toEqual(true);
expect(
isValidUrl('https://username:password@www.example.com:80/?q_a=1234567')
).toEqual(true);
expect(isValidUrl('新华网.cn')).toEqual(true);
expect(isValidUrl('example.com/中文/にほんご')).toEqual(true);
// It's a valid url, but we don't want to support it
// Longest TLD up to date is `.xn--vermgensberatung-pwb`, at 24 characters in Punycode and 17 when decoded [vermögensberatung].
// See also https://stackoverflow.com/questions/9238640/how-long-can-a-tld-possibly-be#:~:text=Longest%20TLD%20up%20to%20date,17%20when%20decoded%20%5Bverm%C3%B6gensberatung%5D.
expect(isValidUrl('example.xn--vermgensberatung-pwb')).toEqual(false);
});
test('should allow ip address url when origin is same', () => {
expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true);
});
});
describe('splitTextByUrl', () => {
test('should split text and keep url part as link segment', () => {
expect(splitTextByUrl('hi - https://google.com')).toEqual([
{ text: 'hi - ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should support prefixed url token without swallowing prefix text', () => {
expect(splitTextByUrl('-https://google.com')).toEqual([
{ text: '-' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should trim tail punctuations from url token', () => {
expect(splitTextByUrl('visit https://google.com, now')).toEqual([
{ text: 'visit ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
{ text: ', now' },
]);
});
test('should convert domain token in plain text', () => {
expect(splitTextByUrl('google.com and text')).toEqual([
{
text: 'google.com',
link: 'https://google.com',
},
{ text: ' and text' },
]);
});
test('should normalize www domain token link while preserving display text', () => {
expect(splitTextByUrl('www.google.com')).toEqual([
{
text: 'www.google.com',
link: 'https://www.google.com',
},
]);
});
});

View File

@@ -0,0 +1,165 @@
import {
type AttachmentBlockProps,
AttachmentBlockSchema,
} from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type AssetsManager,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import { AdapterFactoryIdentifier } from './types/adapter';
export type Attachment = File[];
type CreateAttachmentBlockSnapshotOptions = {
id?: string;
props: Partial<AttachmentBlockProps> & Pick<AttachmentBlockProps, 'name'>;
};
export function createAttachmentBlockSnapshot({
id = nanoid(),
props,
}: CreateAttachmentBlockSnapshotOptions): BlockSnapshot {
return {
type: 'block',
id,
flavour: AttachmentBlockSchema.model.flavour,
props,
children: [],
};
}
type AttachmentToSliceSnapshotPayload = {
file: Attachment;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class AttachmentAdapter extends BaseAdapter<Attachment> {
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<Attachment>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.fromBlockSnapshot is not implemented.'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<Attachment>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.fromDocSnapshot is not implemented.'
);
}
override fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<Attachment>> {
const attachments: Attachment = [];
for (const contentSlice of payload.snapshot.content) {
if (contentSlice.type === 'block') {
const { flavour, props } = contentSlice;
if (flavour === 'affine:attachment') {
const { sourceId } = props;
const file = payload.assets?.getAssets().get(sourceId as string) as
| File
| undefined;
if (file) {
attachments.push(file);
}
}
}
}
return Promise.resolve({ file: attachments, assetsIds: [] });
}
override toBlockSnapshot(
_payload: ToBlockSnapshotPayload<Attachment>
): Promise<BlockSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.toBlockSnapshot is not implemented.'
);
}
override toDocSnapshot(
_payload: ToDocSnapshotPayload<Attachment>
): Promise<DocSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'AttachmentAdapter.toDocSnapshot is not implemented.'
);
}
override async toSliceSnapshot({
assets,
file: files,
pageId,
workspaceId,
}: AttachmentToSliceSnapshotPayload): Promise<SliceSnapshot | null> {
if (files.length === 0) return null;
const content: SliceSnapshot['content'] = [];
for (const blob of files) {
const id = nanoid();
const { name, size, type } = blob;
assets?.uploadingAssetsMap.set(id, {
blob,
mapInto: sourceId => ({ sourceId }),
});
content.push(
createAttachmentBlockSnapshot({
id,
props: {
name,
size,
type,
embed: false,
style: 'horizontalThin',
index: 'a0',
xywh: '[0,0,0,0]',
rotate: 0,
},
})
);
}
return {
type: 'slice',
content,
pageId,
workspaceId,
};
}
}
export const AttachmentAdapterFactoryIdentifier =
AdapterFactoryIdentifier('Attachment');
export const AttachmentAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(AttachmentAdapterFactoryIdentifier, provider => ({
get: (job: Transformer) => new AttachmentAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,107 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type {
BlockSnapshot,
DocSnapshot,
FromBlockSnapshotPayload,
FromBlockSnapshotResult,
FromDocSnapshotPayload,
FromDocSnapshotResult,
FromSliceSnapshotPayload,
FromSliceSnapshotResult,
SliceSnapshot,
ToBlockSnapshotPayload,
ToDocSnapshotPayload,
ToSliceSnapshotPayload,
} from '@blocksuite/store';
import { BaseAdapter } from '@blocksuite/store';
import { NotificationProvider } from '../../services/notification-service.js';
import { decodeClipboardBlobs, encodeClipboardBlobs } from './utils.js';
export type FileSnapshot = {
name: string;
type: string;
content: string;
};
export class ClipboardAdapter extends BaseAdapter<string> {
static MIME = 'BLOCKSUITE/SNAPSHOT';
private readonly _onError = (message: string) => {
const notification = this.provider.getOptional(NotificationProvider);
if (!notification) return;
notification.toast(message);
};
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<string>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.fromBlockSnapshot is not implemented'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<string>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.fromDocSnapshot is not implemented'
);
}
override async fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<string>> {
const snapshot = payload.snapshot;
const assets = payload.assets;
if (!assets) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'ClipboardAdapter.fromSliceSnapshot: assets is not found'
);
}
const map = assets.getAssets();
const blobs: Record<string, FileSnapshot> = await encodeClipboardBlobs(
map,
this._onError
);
return {
file: JSON.stringify({
snapshot,
blobs,
}),
assetsIds: [],
};
}
override toBlockSnapshot(
_payload: ToBlockSnapshotPayload<string>
): Promise<BlockSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.toBlockSnapshot is not implemented'
);
}
override toDocSnapshot(
_payload: ToDocSnapshotPayload<string>
): Promise<DocSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.toDocSnapshot is not implemented'
);
}
override toSliceSnapshot(
payload: ToSliceSnapshotPayload<string>
): Promise<SliceSnapshot> {
const json = JSON.parse(payload.file);
const { blobs, snapshot } = json;
const map = payload.assets?.getAssets();
decodeClipboardBlobs(blobs, map);
return Promise.resolve(snapshot);
}
}

View File

@@ -0,0 +1,2 @@
export * from './clipboard';
export * from './utils';

View File

@@ -0,0 +1,117 @@
import type { FileSnapshot } from './clipboard.js';
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Use a lookup table to find the index.
const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
export const encode = (arraybuffer: ArrayBuffer): string => {
const bytes = new Uint8Array(arraybuffer);
const len = bytes.length;
let i,
base64 = '';
for (i = 0; i < len; i += 3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if (len % 3 === 2) {
base64 = base64.substring(0, base64.length - 1) + '=';
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + '==';
}
return base64;
};
export const decode = (base64: string): ArrayBuffer => {
const len = base64.length;
let bufferLength = base64.length * 0.75,
i,
p = 0,
encoded1,
encoded2,
encoded3,
encoded4;
if (base64[base64.length - 1] === '=') {
bufferLength--;
if (base64[base64.length - 2] === '=') {
bufferLength--;
}
}
const arraybuffer = new ArrayBuffer(bufferLength),
bytes = new Uint8Array(arraybuffer);
for (i = 0; i < len; i += 4) {
encoded1 = lookup[base64.charCodeAt(i)];
encoded2 = lookup[base64.charCodeAt(i + 1)];
encoded3 = lookup[base64.charCodeAt(i + 2)];
encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return arraybuffer;
};
export async function encodeClipboardBlobs(
map: Map<string, Blob>,
onError?: (message: string) => void
) {
const blobs: Record<string, FileSnapshot> = {};
let sumSize = 0;
await Promise.all(
Array.from(map.entries()).map(async ([id, blob]) => {
if (blob.size > 4 * 1024 * 1024) {
onError?.((blob as File).name ?? 'File' + ' is too large to be copied');
return;
}
sumSize += blob.size;
if (sumSize > 6 * 1024 * 1024) {
onError?.(
(blob as File).name ??
'File' + ' cannot be copied due to the clipboard size limit'
);
return;
}
const content = encode(await blob.arrayBuffer());
const file: FileSnapshot = {
name: (blob as File).name,
type: blob.type,
content,
};
blobs[id] = file;
})
);
return blobs;
}
export function decodeClipboardBlobs(
blobs: Record<string, FileSnapshot>,
map: Map<string, Blob> | undefined
) {
if (!map) {
console.error(
`Trying to decode clipboard blobs, but the map is not found.`
);
return;
}
Object.entries<FileSnapshot>(blobs).forEach(([sourceId, file]) => {
const blob = new Blob([decode(file.content)]);
const f = new File([blob], file.name, {
type: file.type,
});
map.set(sourceId, f);
});
}

View File

@@ -0,0 +1,31 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { BlockAdapterMatcher } from '../types/adapter.js';
import type { HtmlAST } from '../types/hast.js';
import type { HtmlDeltaConverter } from './delta-converter.js';
export type BlockHtmlAdapterMatcher = BlockAdapterMatcher<
HtmlAST,
HtmlDeltaConverter
>;
export const BlockHtmlAdapterMatcherIdentifier =
createIdentifier<BlockHtmlAdapterMatcher>('BlockHtmlAdapterMatcher');
export function BlockHtmlAdapterExtension(
matcher: BlockHtmlAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockHtmlAdapterMatcher>;
} {
const identifier = BlockHtmlAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}

View File

@@ -0,0 +1,135 @@
import {
createIdentifier,
type ServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import type { DeltaInsert, ExtensionType } from '@blocksuite/store';
import type { AffineTextAttributes } from '../../types/index.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type DeltaASTConverterOptions,
type InlineDeltaMatcher,
} from '../types/delta-converter.js';
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
import { AdapterTextUtils } from '../utils/text.js';
export type InlineDeltaToHtmlAdapterMatcher = InlineDeltaMatcher<InlineHtmlAST>;
export const InlineDeltaToHtmlAdapterMatcherIdentifier =
createIdentifier<InlineDeltaToHtmlAdapterMatcher>(
'InlineDeltaToHtmlAdapterMatcher'
);
export function InlineDeltaToHtmlAdapterExtension(
matcher: InlineDeltaToHtmlAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<InlineDeltaToHtmlAdapterMatcher>;
} {
const identifier = InlineDeltaToHtmlAdapterMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export type HtmlASTToDeltaMatcher = ASTToDeltaMatcher<HtmlAST>;
export const HtmlASTToDeltaMatcherIdentifier =
createIdentifier<HtmlASTToDeltaMatcher>('HtmlASTToDeltaMatcher');
export function HtmlASTToDeltaExtension(
matcher: HtmlASTToDeltaMatcher
): ExtensionType & {
identifier: ServiceIdentifier<HtmlASTToDeltaMatcher>;
} {
const identifier = HtmlASTToDeltaMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export class HtmlDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
HtmlAST
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToHtmlAdapterMatcher[],
readonly htmlASTToDeltaMatchers: HtmlASTToDeltaMatcher[],
readonly provider: ServiceProvider
) {
super();
}
private _applyTextFormatting(
delta: DeltaInsert<AffineTextAttributes>
): InlineHtmlAST {
let hast: InlineHtmlAST = {
type: 'text',
value: delta.insert,
};
const context: {
configs: Map<string, string>;
current: InlineHtmlAST;
} = {
configs: this.configs,
current: hast,
};
for (const matcher of this.inlineDeltaMatchers) {
if (matcher.match(delta)) {
hast = matcher.toAST(delta, context, this.provider);
context.current = hast;
}
}
return hast;
}
private _spreadAstToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
const context = {
configs: this.configs,
options,
toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) =>
this._spreadAstToDelta(ast, options),
};
for (const matcher of this.htmlASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
return 'children' in ast
? ast.children.flatMap(child => this._spreadAstToDelta(child, options))
: [];
}
astToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
return this._spreadAstToDelta(ast, options).reduce((acc, cur) => {
return AdapterTextUtils.mergeDeltas(acc, cur);
}, [] as DeltaInsert<AffineTextAttributes>[]);
}
deltaToAST(
deltas: DeltaInsert<AffineTextAttributes>[],
depth = 0
): InlineHtmlAST[] {
if (depth > 0) {
deltas.unshift({ insert: ' '.repeat(4).repeat(depth) });
}
return deltas.map(delta => this._applyTextFormatting(delta));
}
}

View File

@@ -0,0 +1,402 @@
import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model';
import type { ServiceProvider } from '@blocksuite/global/di';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import DOMPurify from 'dompurify';
import type { Root } from 'hast';
import rehypeParse from 'rehype-parse';
import rehypeStringify from 'rehype-stringify';
import { unified } from 'unified';
import {
type AdapterContext,
AdapterFactoryIdentifier,
type HtmlAST,
} from '../types';
import { HastUtils } from '../utils/hast';
import {
type BlockHtmlAdapterMatcher,
BlockHtmlAdapterMatcherIdentifier,
} from './block-adapter';
import {
HtmlASTToDeltaMatcherIdentifier,
HtmlDeltaConverter,
InlineDeltaToHtmlAdapterMatcherIdentifier,
} from './delta-converter';
import {
rehypeInlineToBlock,
rehypeWrapInlineElements,
} from './rehype-plugins';
export type Html = string;
type HtmlToSliceSnapshotPayload = {
file: Html;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class HtmlAdapter extends BaseAdapter<Html> {
private readonly _astToHtml = (ast: Root) => {
return unified().use(rehypeStringify).stringify(ast);
};
private readonly _traverseHtml = async (
html: HtmlAST,
snapshot: BlockSnapshot,
assets?: AssetsManager
) => {
const walker = new ASTWalker<HtmlAST, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is HtmlAST =>
'type' in (node as object) && (node as HtmlAST).type !== undefined
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return walker.walk(html, snapshot);
};
private readonly _traverseSnapshot = async (
snapshot: BlockSnapshot,
html: HtmlAST,
assets?: AssetsManager
) => {
const assetsIds: string[] = [];
const walker = new ASTWalker<BlockSnapshot, HtmlAST>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
HtmlAST,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer: { content: '' },
assets,
updateAssetIds: (assetsId: string) => {
assetsIds.push(assetsId);
},
};
await matcher.fromBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
HtmlAST,
HtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer: { content: '' },
assets,
};
await matcher.fromBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return {
ast: (await walker.walk(snapshot, html)) as Root,
assetsIds,
};
};
deltaConverter: HtmlDeltaConverter;
readonly blockMatchers: BlockHtmlAdapterMatcher[];
constructor(job: Transformer, provider: ServiceProvider) {
super(job, provider);
const blockMatchers = Array.from(
provider.getAll(BlockHtmlAdapterMatcherIdentifier).values()
);
const inlineDeltaToHtmlAdapterMatchers = Array.from(
provider.getAll(InlineDeltaToHtmlAdapterMatcherIdentifier).values()
);
const htmlInlineToDeltaMatchers = Array.from(
provider.getAll(HtmlASTToDeltaMatcherIdentifier).values()
);
this.blockMatchers = blockMatchers;
this.deltaConverter = new HtmlDeltaConverter(
job.adapterConfigs,
inlineDeltaToHtmlAdapterMatchers,
htmlInlineToDeltaMatchers,
provider
);
}
private _htmlToAst(html: Html) {
const processor = unified()
.use(rehypeParse)
.use(rehypeInlineToBlock)
.use(rehypeWrapInlineElements);
const ast = processor.parse(html);
return processor.runSync(ast);
}
override async fromBlockSnapshot(
payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<string>> {
const root: Root = {
type: 'root',
children: [
{
type: 'doctype',
},
],
};
const { ast, assetsIds } = await this._traverseSnapshot(
payload.snapshot,
root,
payload.assets
);
return {
file: this._astToHtml(ast),
assetsIds,
};
}
override async fromDocSnapshot(
payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<string>> {
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: payload.snapshot.blocks,
assets: payload.assets,
});
return {
file: file.replace(
'<!--BlockSuiteDocTitlePlaceholder-->',
`<h1>${payload.snapshot.meta.title}</h1>`
),
assetsIds,
};
}
override async fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<string>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of payload.snapshot.content) {
const root: Root = {
type: 'root',
children: [],
};
const { ast, assetsIds } = await this._traverseSnapshot(
contentSlice,
root,
payload.assets
);
sliceAssetsIds.push(...assetsIds);
buffer += this._astToHtml(ast);
}
const html = buffer;
return {
file: html,
assetsIds: sliceAssetsIds,
};
}
override toBlockSnapshot(
payload: ToBlockSnapshotPayload<string>
): Promise<BlockSnapshot> {
const htmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return this._traverseHtml(
htmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
);
}
override async toDocSnapshot(
payload: ToDocSnapshotPayload<string>
): Promise<DocSnapshot> {
const sanitized = DOMPurify.sanitize(payload.file);
const htmlAst = this._htmlToAst(sanitized);
const titleAst = HastUtils.querySelector(htmlAst, 'title');
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return {
type: 'page',
meta: {
id: nanoid(),
title: HastUtils.getTextContent(titleAst, 'Untitled'),
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: this.deltaConverter.astToDelta(
titleAst ?? {
type: 'text',
value: 'Untitled',
}
),
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
await this._traverseHtml(
htmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
),
],
},
};
}
override async toSliceSnapshot(
payload: HtmlToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const htmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
const contentSlice = (await this._traverseHtml(
htmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
)) as BlockSnapshot;
if (contentSlice.children.length === 0) {
return null;
}
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const HtmlAdapterFactoryIdentifier = AdapterFactoryIdentifier('Html');
export const HtmlAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(HtmlAdapterFactoryIdentifier, provider => ({
get: job => new HtmlAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './html.js';

View File

@@ -0,0 +1,2 @@
export * from './inline-to-block';
export * from './wrap-inline-element';

View File

@@ -0,0 +1,37 @@
import type { Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { HastUtils } from '../../utils/hast';
/**
* The content copied from google docs will be wrapped in <b> tag
* To handle this case, we need to convert the <b> tag to a <div> tag
*/
const inlineElements = new Set(['b']);
export const rehypeInlineToBlock: Plugin<[], Root> = () => {
return tree => {
visit(tree, 'element', node => {
// Check if the current node is an inline element
if (inlineElements.has(node.tagName)) {
// Check if the node has a block element child
const hasBlockChild = node.children.some(
child =>
child.type === 'element' && HastUtils.isTagBlock(child.tagName)
);
if (hasBlockChild) {
const originalTag = node.tagName;
// Convert the inline element to a div
node.tagName = 'div';
// Keep the original properties
node.properties = {
...node.properties,
'data-original-tag': originalTag,
};
}
}
});
};
};

View File

@@ -0,0 +1,79 @@
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { HastUtils } from '../../utils/hast';
/**
* In some cases, the inline elements are wrapped in a div tag mixed with block elements
* We need to wrap them in a p tag to avoid the inline elements being treated as a block element
*/
export const rehypeWrapInlineElements: Plugin<[], Root> = () => {
return tree => {
visit(tree, 'element', (node: Element) => {
if (node.tagName === 'div') {
// First check if we have a mix of inline and block elements
let hasInline = false;
let hasBlock = false;
for (const child of node.children) {
if (child.type === 'element') {
if (HastUtils.isElementInline(child)) {
hasInline = true;
} else if (HastUtils.isTagBlock(child.tagName)) {
hasBlock = true;
}
if (hasInline && hasBlock) break;
}
}
// Only process if we have both inline and block elements
if (hasInline && hasBlock) {
const newChildren: ElementContent[] = [];
let currentInlineGroup: ElementContent[] = [];
for (const child of node.children) {
if (child.type === 'element') {
const elementChild = child;
if (HastUtils.isElementInline(elementChild)) {
// Add to current inline group
currentInlineGroup.push(elementChild);
} else if (HastUtils.isTagBlock(elementChild.tagName)) {
// If we have accumulated inline elements, wrap them in a p tag
if (currentInlineGroup.length > 0) {
newChildren.push({
type: 'element',
tagName: 'p',
properties: {},
children: currentInlineGroup,
});
currentInlineGroup = [];
}
// Add the block element as is
newChildren.push(elementChild);
} else {
// For unknown elements, treat them as inline
currentInlineGroup.push(elementChild);
}
} else {
// For text nodes, treat them as inline content
currentInlineGroup.push(child);
}
}
// Handle any remaining inline elements at the end
if (currentInlineGroup.length > 0) {
newChildren.push({
type: 'element',
tagName: 'p',
properties: {},
children: currentInlineGroup,
});
}
// Replace the original children with the new structure
node.children = newChildren;
}
}
});
};
};

View File

@@ -0,0 +1,137 @@
import { ImageBlockSchema } from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type AssetsManager,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import { AdapterFactoryIdentifier } from './types/adapter';
export type Image = File[];
type ImageToSliceSnapshotPayload = {
file: Image;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class ImageAdapter extends BaseAdapter<Image> {
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<Image>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.fromBlockSnapshot is not implemented.'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<Image>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.fromDocSnapshot is not implemented.'
);
}
override fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<Image>> {
const images: Image = [];
for (const contentSlice of payload.snapshot.content) {
if (contentSlice.type === 'block') {
const { flavour, props } = contentSlice;
if (flavour === 'affine:image') {
const { sourceId } = props;
const file = payload.assets?.getAssets().get(sourceId as string) as
| File
| undefined;
if (file) {
images.push(file);
}
}
}
}
return Promise.resolve({ file: images, assetsIds: [] });
}
override toBlockSnapshot(
_payload: ToBlockSnapshotPayload<Image>
): Promise<BlockSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.toBlockSnapshot is not implemented.'
);
}
override toDocSnapshot(
_payload: ToDocSnapshotPayload<Image>
): Promise<DocSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ImageAdapter.toDocSnapshot is not implemented'
);
}
override async toSliceSnapshot({
assets,
file: files,
pageId,
workspaceId,
}: ImageToSliceSnapshotPayload): Promise<SliceSnapshot | null> {
if (files.length === 0) return null;
const content: SliceSnapshot['content'] = [];
const flavour = ImageBlockSchema.model.flavour;
for (const blob of files) {
const id = nanoid();
const { size } = blob;
assets?.uploadingAssetsMap.set(id, {
blob,
mapInto: sourceId => ({ sourceId }),
});
content.push({
type: 'block',
flavour,
id,
props: { size },
children: [],
});
}
return {
type: 'slice',
content,
pageId,
workspaceId,
};
}
}
export const ImageAdapterFactoryIdentifier = AdapterFactoryIdentifier('Image');
export const ImageAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(ImageAdapterFactoryIdentifier, provider => ({
get: (job: Transformer) => new ImageAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,89 @@
export * from './attachment';
export * from './clipboard';
export {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
BlockHtmlAdapterMatcherIdentifier,
type Html,
HtmlAdapter,
HtmlAdapterFactoryExtension,
HtmlAdapterFactoryIdentifier,
HtmlASTToDeltaExtension,
type HtmlASTToDeltaMatcher,
HtmlASTToDeltaMatcherIdentifier,
HtmlDeltaConverter,
InlineDeltaToHtmlAdapterExtension,
type InlineDeltaToHtmlAdapterMatcher,
InlineDeltaToHtmlAdapterMatcherIdentifier,
} from './html';
export * from './image';
export {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
BlockMarkdownAdapterMatcherIdentifier,
FOOTNOTE_DEFINITION_PREFIX,
getCalloutEmoji,
getFootnoteDefinitionText,
IN_PARAGRAPH_NODE_CONTEXT_KEY,
InlineDeltaToMarkdownAdapterExtension,
type InlineDeltaToMarkdownAdapterMatcher,
InlineDeltaToMarkdownAdapterMatcherIdentifier,
isCalloutNode,
isFootnoteDefinitionNode,
isMarkdownAST,
type Markdown,
MarkdownAdapter,
MarkdownAdapterFactoryExtension,
MarkdownAdapterFactoryIdentifier,
type MarkdownAdapterPreprocessor,
type MarkdownAST,
MarkdownASTToDeltaExtension,
type MarkdownASTToDeltaMatcher,
MarkdownASTToDeltaMatcherIdentifier,
MarkdownDeltaConverter,
MarkdownPreprocessorExtension,
MarkdownPreprocessorManager,
} from './markdown';
export * from './middlewares';
export * from './mix-text';
export {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
BlockNotionHtmlAdapterMatcherIdentifier,
type InlineDeltaToNotionHtmlAdapterMatcher,
type NotionHtml,
NotionHtmlAdapter,
NotionHtmlAdapterFactoryExtension,
NotionHtmlAdapterFactoryIdentifier,
NotionHtmlASTToDeltaExtension,
type NotionHtmlASTToDeltaMatcher,
NotionHtmlASTToDeltaMatcherIdentifier,
NotionHtmlDeltaConverter,
} from './notion-html';
export * from './notion-text';
export { PdfAdapter } from './pdf';
export {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
BlockPlainTextAdapterMatcherIdentifier,
InlineDeltaToPlainTextAdapterExtension,
type InlineDeltaToPlainTextAdapterMatcher,
InlineDeltaToPlainTextAdapterMatcherIdentifier,
type PlainText,
PlainTextAdapter,
PlainTextAdapterFactoryExtension,
PlainTextAdapterFactoryIdentifier,
PlainTextDeltaConverter,
} from './plain-text';
export {
type AdapterContext,
type AdapterFactory,
AdapterFactoryIdentifier,
type BlockAdapterMatcher,
DeltaASTConverter,
type HtmlAST,
type InlineHtmlAST,
isBlockSnapshotNode,
type TextBuffer,
} from './types';
export * from './utils';

View File

@@ -0,0 +1,31 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { BlockAdapterMatcher } from '../types/adapter.js';
import type { MarkdownDeltaConverter } from './delta-converter.js';
import type { MarkdownAST } from './type.js';
export type BlockMarkdownAdapterMatcher = BlockAdapterMatcher<
MarkdownAST,
MarkdownDeltaConverter
>;
export const BlockMarkdownAdapterMatcherIdentifier =
createIdentifier<BlockMarkdownAdapterMatcher>('BlockMarkdownAdapterMatcher');
export function BlockMarkdownAdapterExtension(
matcher: BlockMarkdownAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockMarkdownAdapterMatcher>;
} {
const identifier = BlockMarkdownAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}

View File

@@ -0,0 +1,332 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { DeltaInsert, ExtensionType } from '@blocksuite/store';
import type { Root } from 'hast';
import type { PhrasingContent } from 'mdast';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import type { AffineTextAttributes } from '../../types/index.js';
import type { HtmlDeltaConverter } from '../html/delta-converter.js';
import {
rehypeInlineToBlock,
rehypeWrapInlineElements,
} from '../html/rehype-plugins/index.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type InlineDeltaMatcher,
} from '../types/delta-converter.js';
import type { MarkdownAST } from './type.js';
const INLINE_HTML_TAGS = new Set([
'span',
'strong',
'b',
'em',
'i',
'del',
'u',
'mark',
'code',
'ins',
'bdi',
'bdo',
]);
const VOID_HTML_TAGS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]);
const ALLOWED_INLINE_HTML_TAGS = new Set([
...INLINE_HTML_TAGS,
...VOID_HTML_TAGS,
]);
const isHtmlNode = (
node: MarkdownAST
): node is MarkdownAST & { type: 'html'; value: string } =>
node.type === 'html' && 'value' in node && typeof node.value === 'string';
const isTextNode = (
node: MarkdownAST
): node is MarkdownAST & { type: 'text'; value: string } =>
node.type === 'text' && 'value' in node && typeof node.value === 'string';
type HtmlTagInfo =
| { name: string; kind: 'open' | 'self' }
| { name: string; kind: 'close' };
const getHtmlTagInfo = (value: string): HtmlTagInfo | null => {
const closingMatch = value.match(/^<\/([A-Za-z][A-Za-z0-9-]*)\s*>$/);
if (closingMatch) {
return {
name: closingMatch[1].toLowerCase(),
kind: 'close',
};
}
const selfClosingMatch = value.match(
/^<([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/>$/i
);
if (selfClosingMatch) {
return {
name: selfClosingMatch[1].toLowerCase(),
kind: 'self',
};
}
const openingMatch = value.match(/^<([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?>$/);
if (openingMatch) {
const name = openingMatch[1].toLowerCase();
return {
name,
kind: VOID_HTML_TAGS.has(name) ? 'self' : 'open',
};
}
return null;
};
export type InlineDeltaToMarkdownAdapterMatcher =
InlineDeltaMatcher<PhrasingContent>;
export const InlineDeltaToMarkdownAdapterMatcherIdentifier =
createIdentifier<InlineDeltaToMarkdownAdapterMatcher>(
'InlineDeltaToMarkdownAdapterMatcher'
);
export function InlineDeltaToMarkdownAdapterExtension(
matcher: InlineDeltaToMarkdownAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<InlineDeltaToMarkdownAdapterMatcher>;
} {
const identifier = InlineDeltaToMarkdownAdapterMatcherIdentifier(
matcher.name
);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export type MarkdownASTToDeltaMatcher = ASTToDeltaMatcher<MarkdownAST>;
export const MarkdownASTToDeltaMatcherIdentifier =
createIdentifier<MarkdownASTToDeltaMatcher>('MarkdownASTToDeltaMatcher');
export function MarkdownASTToDeltaExtension(
matcher: MarkdownASTToDeltaMatcher
): ExtensionType & {
identifier: ServiceIdentifier<MarkdownASTToDeltaMatcher>;
} {
const identifier = MarkdownASTToDeltaMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export class MarkdownDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
MarkdownAST
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToMarkdownAdapterMatcher[],
readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[],
readonly htmlDeltaConverter?: HtmlDeltaConverter
) {
super();
}
private _convertHtmlToDelta(
html: string
): DeltaInsert<AffineTextAttributes>[] {
if (!this.htmlDeltaConverter) {
return [{ insert: html }];
}
try {
const processor = unified()
.use(rehypeParse, { fragment: true })
.use(rehypeInlineToBlock)
.use(rehypeWrapInlineElements);
const ast = processor.runSync(processor.parse(html)) as Root;
return this.htmlDeltaConverter.astToDelta(ast, { trim: false });
} catch {
return [{ insert: html }];
}
}
applyTextFormatting(
delta: DeltaInsert<AffineTextAttributes>
): PhrasingContent {
let mdast: PhrasingContent = {
type: 'text',
value: delta.attributes?.underline
? `<u>${delta.insert}</u>`
: delta.insert,
};
const context: {
configs: Map<string, string>;
current: PhrasingContent;
} = {
configs: this.configs,
current: mdast,
};
for (const matcher of this.inlineDeltaMatchers) {
if (matcher.match(delta)) {
mdast = matcher.toAST(delta, context);
context.current = mdast;
}
}
return mdast;
}
private _mergeInlineHtml(
children: MarkdownAST[],
startIndex: number
): {
endIndex: number;
deltas: DeltaInsert<AffineTextAttributes>[];
} | null {
const startNode = children[startIndex];
if (!isHtmlNode(startNode)) {
return null;
}
const startTag = getHtmlTagInfo(startNode.value);
if (
!startTag ||
startTag.kind !== 'open' ||
!INLINE_HTML_TAGS.has(startTag.name)
) {
return null;
}
const stack = [startTag.name];
let html = startNode.value;
let endIndex = startIndex;
for (let i = startIndex + 1; i < children.length; i++) {
const node = children[i];
if (isHtmlNode(node)) {
const info = getHtmlTagInfo(node.value);
if (!info) {
html += node.value;
continue;
}
if (info.kind === 'open') {
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
return null;
}
stack.push(info.name);
html += node.value;
continue;
}
if (info.kind === 'self') {
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
return null;
}
html += node.value;
continue;
}
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
return null;
}
const last = stack[stack.length - 1];
if (last !== info.name) {
return null;
}
stack.pop();
html += node.value;
endIndex = i;
if (stack.length === 0) {
return {
endIndex,
deltas: this._convertHtmlToDelta(html),
};
}
continue;
}
if (isTextNode(node)) {
html += node.value;
continue;
}
return null;
}
return null;
}
private _astChildrenToDelta(
children: MarkdownAST[]
): DeltaInsert<AffineTextAttributes>[] {
const deltas: DeltaInsert<AffineTextAttributes>[] = [];
for (let i = 0; i < children.length; i++) {
const merged = this._mergeInlineHtml(children, i);
if (merged) {
deltas.push(...merged.deltas);
i = merged.endIndex;
continue;
}
deltas.push(...this.astToDelta(children[i]));
}
return deltas;
}
astToDelta(ast: MarkdownAST): DeltaInsert<AffineTextAttributes>[] {
const context = {
configs: this.configs,
options: Object.create(null),
toDelta: (ast: MarkdownAST) => this.astToDelta(ast),
htmlToDelta: (html: string) => this._convertHtmlToDelta(html),
};
for (const matcher of this.markdownASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
return 'children' in ast
? this._astChildrenToDelta(ast.children as MarkdownAST[])
: [];
}
deltaToAST(
deltas: DeltaInsert<AffineTextAttributes>[],
depth = 0
): PhrasingContent[] {
if (depth > 0) {
deltas.unshift({ insert: ' '.repeat(4).repeat(depth) });
}
return deltas.map(delta => this.applyTextFormatting(delta));
}
}

View File

@@ -0,0 +1,76 @@
/*
MIT License
Copyright (c) 2020 Titus Wormer <tituswormer@gmail.com>
mdast-util-gfm-autolink-literal is from markdown only.
*/
import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal';
import {
gfmFootnoteFromMarkdown,
gfmFootnoteToMarkdown,
} from 'mdast-util-gfm-footnote';
import {
gfmStrikethroughFromMarkdown,
gfmStrikethroughToMarkdown,
} from 'mdast-util-gfm-strikethrough';
import { gfmTableFromMarkdown, gfmTableToMarkdown } from 'mdast-util-gfm-table';
import {
gfmTaskListItemFromMarkdown,
gfmTaskListItemToMarkdown,
} from 'mdast-util-gfm-task-list-item';
import { gfmAutolinkLiteral } from 'micromark-extension-gfm-autolink-literal';
import { gfmFootnote } from 'micromark-extension-gfm-footnote';
import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
import { gfmTable } from 'micromark-extension-gfm-table';
import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
import { combineExtensions } from 'micromark-util-combine-extensions';
import type { Processor } from 'unified';
export function gfm() {
return combineExtensions([
gfmAutolinkLiteral(),
gfmStrikethrough(),
gfmTable(),
gfmTaskListItem(),
gfmFootnote(),
]);
}
function gfmFromMarkdown() {
return [
gfmStrikethroughFromMarkdown(),
gfmTableFromMarkdown(),
gfmTaskListItemFromMarkdown(),
gfmAutolinkLiteralFromMarkdown(),
gfmFootnoteFromMarkdown(),
];
}
function gfmToMarkdown() {
return {
extensions: [
gfmStrikethroughToMarkdown(),
gfmTableToMarkdown(),
gfmTaskListItemToMarkdown(),
gfmFootnoteToMarkdown(),
],
};
}
export function remarkGfm(this: Processor) {
// oxlint-disable-next-line typescript/no-this-alias
const self = this;
const data = self.data();
const micromarkExtensions =
data.micromarkExtensions || (data.micromarkExtensions = []);
const fromMarkdownExtensions =
data.fromMarkdownExtensions || (data.fromMarkdownExtensions = []);
const toMarkdownExtensions =
data.toMarkdownExtensions || (data.toMarkdownExtensions = []);
micromarkExtensions.push(gfm());
fromMarkdownExtensions.push(gfmFromMarkdown());
toMarkdownExtensions.push(gfmToMarkdown());
}

View File

@@ -0,0 +1,5 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './markdown.js';
export * from './preprocessor.js';
export * from './type.js';

View File

@@ -0,0 +1,432 @@
import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model';
import type { ServiceProvider } from '@blocksuite/global/di';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import type { Root } from 'mdast';
import remarkMath from 'remark-math';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { unified } from 'unified';
import {
HtmlASTToDeltaMatcherIdentifier,
HtmlDeltaConverter,
InlineDeltaToHtmlAdapterMatcherIdentifier,
} from '../html/delta-converter.js';
import { type AdapterContext, AdapterFactoryIdentifier } from '../types';
import {
type BlockMarkdownAdapterMatcher,
BlockMarkdownAdapterMatcherIdentifier,
} from './block-adapter';
import {
InlineDeltaToMarkdownAdapterMatcherIdentifier,
MarkdownASTToDeltaMatcherIdentifier,
MarkdownDeltaConverter,
} from './delta-converter';
import { remarkGfm } from './gfm';
import { MarkdownPreprocessorManager } from './preprocessor';
import { remarkCallout } from './remark-plugins/remark-callout';
import type { Markdown, MarkdownAST } from './type';
type MarkdownToSliceSnapshotPayload = {
file: Markdown;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class MarkdownAdapter extends BaseAdapter<Markdown> {
private readonly _traverseMarkdown = (
markdown: MarkdownAST,
snapshot: BlockSnapshot,
assets?: AssetsManager
) => {
const walker = new ASTWalker<MarkdownAST, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is MarkdownAST =>
!Array.isArray(node) &&
'type' in (node as object) &&
(node as MarkdownAST).type !== undefined
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
MarkdownAST,
BlockSnapshot,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
MarkdownAST,
BlockSnapshot,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer: { content: '' },
assets,
};
await matcher.toBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return walker.walk(markdown, snapshot);
};
private readonly _traverseSnapshot = async (
snapshot: BlockSnapshot,
markdown: MarkdownAST,
assets?: AssetsManager
) => {
const assetsIds: string[] = [];
const walker = new ASTWalker<BlockSnapshot, MarkdownAST>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
MarkdownAST,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer: { content: '' },
assets,
updateAssetIds: (assetsId: string) => {
assetsIds.push(assetsId);
},
};
await matcher.fromBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<
BlockSnapshot,
MarkdownAST,
MarkdownDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer: { content: '' },
assets,
};
await matcher.fromBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return {
ast: (await walker.walk(snapshot, markdown)) as Root,
assetsIds,
};
};
deltaConverter: MarkdownDeltaConverter;
preprocessorManager: MarkdownPreprocessorManager;
readonly blockMatchers: BlockMarkdownAdapterMatcher[];
constructor(job: Transformer, provider: ServiceProvider) {
super(job, provider);
const blockMatchers = Array.from(
provider.getAll(BlockMarkdownAdapterMatcherIdentifier).values()
);
const inlineDeltaToMarkdownAdapterMatchers = Array.from(
provider.getAll(InlineDeltaToMarkdownAdapterMatcherIdentifier).values()
);
const markdownInlineToDeltaMatchers = Array.from(
provider.getAll(MarkdownASTToDeltaMatcherIdentifier).values()
);
const inlineDeltaToHtmlAdapterMatchers = Array.from(
provider.getAll(InlineDeltaToHtmlAdapterMatcherIdentifier).values()
);
const htmlInlineToDeltaMatchers = Array.from(
provider.getAll(HtmlASTToDeltaMatcherIdentifier).values()
);
const htmlDeltaConverter = new HtmlDeltaConverter(
job.adapterConfigs,
inlineDeltaToHtmlAdapterMatchers,
htmlInlineToDeltaMatchers,
provider
);
this.blockMatchers = blockMatchers;
this.deltaConverter = new MarkdownDeltaConverter(
job.adapterConfigs,
inlineDeltaToMarkdownAdapterMatchers,
markdownInlineToDeltaMatchers,
htmlDeltaConverter
);
this.preprocessorManager = new MarkdownPreprocessorManager(provider);
}
private _astToMarkdown(ast: Root) {
return unified()
.use(remarkGfm)
.use(remarkStringify, {
resourceLink: true,
})
.use(remarkMath)
.stringify(ast)
.replace(/&#x20;\n/g, ' \n');
}
private _markdownToAst(markdown: Markdown) {
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkCallout);
const ast = processor.parse(markdown);
return processor.runSync(ast);
}
async fromBlockSnapshot({
snapshot,
assets,
}: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<Markdown>> {
const root: Root = {
type: 'root',
children: [],
};
const { ast, assetsIds } = await this._traverseSnapshot(
snapshot,
root,
assets
);
return {
file: this._astToMarkdown(ast),
assetsIds,
};
}
async fromDocSnapshot({
snapshot,
assets,
}: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<Markdown>> {
let buffer = '';
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: snapshot.blocks,
assets,
});
buffer += file;
return {
file: buffer,
assetsIds,
};
}
async fromSliceSnapshot({
snapshot,
assets,
}: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<Markdown>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of snapshot.content) {
const root: Root = {
type: 'root',
children: [],
};
const { ast, assetsIds } = await this._traverseSnapshot(
contentSlice,
root,
assets
);
sliceAssetsIds.push(...assetsIds);
buffer += this._astToMarkdown(ast);
}
const markdown =
buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer;
return {
file: markdown,
assetsIds: sliceAssetsIds,
};
}
async toBlockSnapshot(
payload: ToBlockSnapshotPayload<Markdown>
): Promise<BlockSnapshot> {
const markdownFile = this.preprocessorManager.process(
'block',
payload.file
);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return this._traverseMarkdown(
markdownAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
);
}
async toDocSnapshot(
payload: ToDocSnapshotPayload<Markdown>
): Promise<DocSnapshot> {
const markdownFile = this.preprocessorManager.process('doc', payload.file);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return {
type: 'page',
meta: {
id: nanoid(),
title: 'Untitled',
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Untitled',
},
],
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
await this._traverseMarkdown(
markdownAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
),
],
},
};
}
async toSliceSnapshot(
payload: MarkdownToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const markdownFile = this.preprocessorManager.process(
'slice',
payload.file
);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
} as BlockSnapshot;
const contentSlice = (await this._traverseMarkdown(
markdownAst,
blockSnapshotRoot,
payload.assets
)) as BlockSnapshot;
if (contentSlice.children.length === 0) {
return null;
}
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const MarkdownAdapterFactoryIdentifier =
AdapterFactoryIdentifier('Markdown');
export const MarkdownAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(MarkdownAdapterFactoryIdentifier, provider => ({
get: (job: Transformer) => new MarkdownAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,40 @@
import {
createIdentifier,
type ServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import {
type AdapterPreprocessor,
PreprocessorManager,
} from '../types/preprocessor';
import type { Markdown } from './type';
export type MarkdownAdapterPreprocessor = AdapterPreprocessor<Markdown>;
const MarkdownPreprocessorIdentifier =
createIdentifier<MarkdownAdapterPreprocessor>('MarkdownPreprocessor');
export const MarkdownPreprocessorExtension = (
preprocessor: MarkdownAdapterPreprocessor
): ExtensionType & {
identifier: ServiceIdentifier<MarkdownAdapterPreprocessor>;
} => {
const identifier = MarkdownPreprocessorIdentifier(preprocessor.name);
return {
setup: di => {
di.addImpl(identifier, () => preprocessor);
},
identifier,
};
};
export class MarkdownPreprocessorManager extends PreprocessorManager<
Markdown,
MarkdownAdapterPreprocessor
> {
constructor(provider: ServiceProvider) {
super(provider, MarkdownPreprocessorIdentifier);
}
}

View File

@@ -0,0 +1 @@
export * from './remark-callout';

View File

@@ -0,0 +1,70 @@
import type { Root } from 'mdast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
/**
* The regex for the callout
* The emoji is optional, so we use `[\p{Extended_Pictographic}]?` to match it
* The `u` flag is for Unicode support
* And only match the line start
* @example
* ```md
* [!💡]
* ```
*/
const calloutRegex = /^\[!([\p{Extended_Pictographic}]?)\]/u;
export const remarkCallout: Plugin<[], Root> = () => {
return tree => {
visit(tree, 'blockquote', node => {
// Only process the first child of the blockquote
const firstChild = node.children[0];
let children = node.children;
if (firstChild?.type === 'paragraph') {
const firstNode = firstChild.children[0];
if (firstNode?.type === 'text') {
const text = firstNode.value;
const match = text.match(calloutRegex);
if (match) {
const calloutEmoji = match[1];
// Set the callout data
node.data = {
isCallout: true,
calloutEmoji,
};
// Remove the matched callout pattern
const currentText = text
.replace(calloutRegex, '')
.replace(/^\n/, '');
// If only one child node and it's empty text, remove all children
if (firstChild.children.length === 1 && currentText.length === 0) {
firstChild.children = [];
// If the first child only has one text node, and the text is only whitespace, remove the first child of blockquote
children = children.slice(1);
} else {
// Otherwise keep remaining children with the callout pattern removed
firstChild.children[0] = {
type: 'text',
value: currentText.trim(),
};
}
}
}
}
node.children = [...children];
});
};
};
/**
* Extend the BlockquoteData interface to include isCallout and calloutEmoji properties
*/
declare module 'mdast' {
interface BlockquoteData {
isCallout?: boolean;
calloutEmoji?: string;
}
}

View File

@@ -0,0 +1,45 @@
import type {
Blockquote,
FootnoteDefinition,
Root,
RootContentMap,
} from 'mdast';
export type Markdown = string;
type MdastUnionType<
K extends keyof RootContentMap,
V extends RootContentMap[K],
> = V;
export type MarkdownAST =
| MdastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
| Root;
export const isMarkdownAST = (node: unknown): node is MarkdownAST =>
!Array.isArray(node) &&
'type' in (node as object) &&
(node as MarkdownAST).type !== undefined;
export const isFootnoteDefinitionNode = (
node: MarkdownAST
): node is FootnoteDefinition => node.type === 'footnoteDefinition';
export const getFootnoteDefinitionText = (node: FootnoteDefinition) => {
const childNode = node.children[0];
if (childNode.type !== 'paragraph') return '';
const paragraph = childNode.children[0];
if (paragraph.type !== 'text') return '';
return paragraph.value;
};
export const isCalloutNode = (node: MarkdownAST): node is Blockquote => {
return node.type === 'blockquote' && !!node.data?.isCallout;
};
export const getCalloutEmoji = (node: Blockquote) => {
return node.data?.calloutEmoji ?? '';
};
export const FOOTNOTE_DEFINITION_PREFIX = 'footnoteDefinition:';
export const IN_PARAGRAPH_NODE_CONTEXT_KEY = 'mdast:paragraph';

View File

@@ -0,0 +1,45 @@
import type { TransformerMiddleware } from '@blocksuite/store';
import { z } from 'zod';
export const CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY =
'calloutMarkdownExportOptions';
export enum CalloutExportStyle {
GFM = 'GFM',
Admonitions = 'Admonitions',
}
export enum CalloutAdmonitionType {
Info = 'info',
Tip = 'tip',
Warning = 'warning',
Danger = 'danger',
Details = 'details',
}
export const DEFAULT_ADMONITION_TYPE = CalloutAdmonitionType.Info;
export const CalloutAdmonitionTypeSet: Set<string> = new Set(
Object.values(CalloutAdmonitionType)
);
export const calloutMarkdownExportOptionsSchema = z.object({
style: z.nativeEnum(CalloutExportStyle),
admonitionType: z.nativeEnum(CalloutAdmonitionType).optional(),
});
export type CalloutMarkdownExportOptions = z.infer<
typeof calloutMarkdownExportOptionsSchema
>;
/**
* Middleware to set the export style of the callout block
* @param style - The markdown export style of the callout block
* @returns A TransformerMiddleware that sets the markdown export style of the callout block
*/
export const calloutMarkdownExportMiddleware = (
options: CalloutMarkdownExportOptions
): TransformerMiddleware => {
return ({ adapterConfigs }) => {
adapterConfigs.set(CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY, options);
};
};

View File

@@ -0,0 +1,11 @@
import type { TransformerMiddleware } from '@blocksuite/store';
export const CODE_BLOCK_WRAP_KEY = 'codeBlockWrap';
export const codeBlockWrapMiddleware = (
wrap: boolean
): TransformerMiddleware => {
return ({ adapterConfigs }) => {
adapterConfigs.set(CODE_BLOCK_WRAP_KEY, String(wrap));
};
};

View File

@@ -0,0 +1,66 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
type BlockStdScope,
type EditorHost,
type TextRangePoint,
TextSelection,
} from '@blocksuite/std';
import type {
BlockSnapshot,
DraftModel,
TransformerMiddleware,
TransformerSlots,
} from '@blocksuite/store';
const isRootDraftModel = (
model: DraftModel
): model is DraftModel<RootBlockModel> => model.flavour === 'affine:root';
const handlePoint = (
point: TextRangePoint,
snapshot: BlockSnapshot,
model: DraftModel
) => {
const { index, length } = point;
if (isRootDraftModel(model)) {
if (length === 0) return;
(snapshot.props.title as Record<string, unknown>).delta =
model.props.title.sliceToDelta(index, length + index);
return;
}
if (!snapshot.props.text || length === 0) {
return;
}
(snapshot.props.text as Record<string, unknown>).delta =
model.text?.sliceToDelta(index, length + index);
};
const sliceText = (slots: TransformerSlots, std: EditorHost['std']) => {
const afterExportSubscription = slots.afterExport.subscribe(payload => {
if (payload.type === 'block') {
const snapshot = payload.snapshot;
const model = payload.model;
const text = std.selection.find(TextSelection);
if (text && text.from.blockId === model.id) {
handlePoint(text.from, snapshot, model);
return;
}
if (text && text.to && text.to.blockId === model.id) {
handlePoint(text.to, snapshot, model);
return;
}
}
});
return () => {
afterExportSubscription.unsubscribe();
};
};
export const copyMiddleware = (std: BlockStdScope): TransformerMiddleware => {
return ({ slots }) => {
return sliceText(slots, std);
};
};

View File

@@ -0,0 +1,44 @@
import type { TransformerMiddleware } from '@blocksuite/store';
const customDocLinkBaseUrlMiddleware = (
baseUrl: string,
collectionId: string
): TransformerMiddleware => {
return ({ adapterConfigs }) => {
const docLinkBaseUrl = baseUrl
? `${baseUrl}/workspace/${collectionId}`
: '';
adapterConfigs.set('docLinkBaseUrl', docLinkBaseUrl);
};
};
export const docLinkBaseURLMiddlewareBuilder = (
baseUrl: string,
collectionId: string
) => {
let middleware = customDocLinkBaseUrlMiddleware(baseUrl, collectionId);
return {
get: () => middleware,
set: (url: string) => {
middleware = customDocLinkBaseUrlMiddleware(url, collectionId);
},
};
};
const defaultDocLinkBaseURLMiddlewareBuilder = (collectionId: string) =>
docLinkBaseURLMiddlewareBuilder(
typeof window !== 'undefined' ? window.location.origin : '.',
collectionId
);
export const docLinkBaseURLMiddleware = (collectionId: string) =>
defaultDocLinkBaseURLMiddlewareBuilder(collectionId).get();
export const setDocLinkBaseURLMiddleware = (collectionId: string) =>
defaultDocLinkBaseURLMiddlewareBuilder(collectionId).set;
export const embedSyncedDocMiddleware =
(type: 'content'): TransformerMiddleware =>
({ adapterConfigs }) => {
adapterConfigs.set('embedSyncedDocExportType', type);
};

View File

@@ -0,0 +1,27 @@
import type { TransformerMiddleware } from '@blocksuite/store';
export const fileNameMiddleware =
(fileName?: string): TransformerMiddleware =>
({ slots }) => {
const beforeImportSubscription = slots.beforeImport.subscribe(payload => {
if (payload.type !== 'page') {
return;
}
if (!fileName) {
return;
}
payload.snapshot.meta.title = fileName;
payload.snapshot.blocks.props.title = {
'$blocksuite:internal:text$': true,
delta: [
{
insert: fileName,
},
],
};
});
return () => {
beforeImportSubscription.unsubscribe();
};
};

View File

@@ -0,0 +1,14 @@
import type { TransformerMiddleware } from '@blocksuite/store';
export const FULL_FILE_PATH_KEY = 'fullFilePath';
/**
* Middleware to set the full file path of the imported file
* @param filePath - The full file path of the imported file
* @returns A TransformerMiddleware that sets the full file path of the imported file
*/
export const filePathMiddleware = (filePath: string): TransformerMiddleware => {
return ({ adapterConfigs }) => {
adapterConfigs.set(FULL_FILE_PATH_KEY, filePath);
};
};

View File

@@ -0,0 +1,12 @@
export * from './callout-export-options';
export * from './code';
export * from './copy';
export * from './doc-link';
export * from './file-name';
export * from './file-path';
export * from './paste';
export * from './proxy';
export * from './replace-id';
export * from './surface-ref-to-embed';
export * from './title';
export * from './upload';

View File

@@ -0,0 +1,596 @@
import {
CodeBlockModel,
type DocMode,
DocModes,
ImageBlockModel,
type ParagraphBlockModel,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
BLOCK_ID_ATTR,
type BlockComponent,
BlockSelection,
type EditorHost,
type TextRangePoint,
TextSelection,
} from '@blocksuite/std';
import {
type BlockModel,
type BlockSnapshot,
type DeltaOperation,
fromJSON,
type SliceSnapshot,
type Text,
type TransformerMiddleware,
} from '@blocksuite/store';
import * as Y from 'yjs';
import { REFERENCE_NODE } from '../../consts';
import { ImageSelection } from '../../selection';
import {
ParseDocUrlProvider,
type ParseDocUrlService,
TelemetryProvider,
} from '../../services';
import type { AffineTextAttributes } from '../../types';
import { matchModels, referenceToNode } from '../../utils';
function findLastMatchingNode(
root: BlockSnapshot[],
fn: (node: BlockSnapshot) => boolean
): BlockSnapshot | null {
let lastMatchingNode: BlockSnapshot | null = null;
function traverse(node: BlockSnapshot) {
if (fn(node)) {
lastMatchingNode = node;
}
if (node.children) {
for (const child of node.children) {
traverse(child);
}
}
}
root.forEach(traverse);
return lastMatchingNode;
}
// find last child that has text as prop
const findLast = (snapshot: SliceSnapshot): BlockSnapshot | null => {
return findLastMatchingNode(snapshot.content, node => !!node.props.text);
};
class PointState {
private readonly _blockFromPath = (id: string) => {
const block = this.std.view.getBlock(id);
if (!block) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
`Block not found when pasting: ${id}`
);
}
return block;
};
readonly block: BlockComponent;
readonly model: BlockModel;
readonly text: Text;
constructor(
readonly std: EditorHost['std'],
readonly point: TextRangePoint
) {
this.block = this._blockFromPath(point.blockId);
this.model = this.block.model;
const text = this.model.text;
if (!text) {
console.error(this.point);
throw new BlockSuiteError(
ErrorCode.TransformerError,
'Text point without text model'
);
}
this.text = text;
}
}
class PasteTr {
private readonly _getDeltas = () => {
const firstTextSnapshot = this._textFromSnapshot(this.firstSnapshot!);
const lastTextSnapshot = this._textFromSnapshot(this.lastSnapshot!);
const fromDelta = this.pointState.text.sliceToDelta(
0,
this.pointState.point.index
);
const toDelta = this.pointState.text.sliceToDelta(
this.pointState.point.index + this.pointState.point.length,
this.pointState.text.length
);
const firstDelta = firstTextSnapshot.delta;
const lastDelta = lastTextSnapshot.delta;
return {
firstTextSnapshot,
lastTextSnapshot,
fromDelta,
toDelta,
firstDelta,
lastDelta,
};
};
private readonly _mergeCode = () => {
const deltas: DeltaOperation[] = [{ retain: this.pointState.point.index }];
// if there is text selection, delete the text selected
if (this.pointState.text.length - this.pointState.point.index > 0) {
deltas.push({
delete: this.pointState.text.length - this.pointState.point.index,
});
}
// paste the text from the snapshot to code block
this.snapshot.content.forEach((blockSnapshot, i) => {
if (blockSnapshot.props.text) {
const text = this._textFromSnapshot(blockSnapshot);
if (i > 0) {
deltas.push({ insert: '\n' });
}
deltas.push(...text.delta);
}
});
// paste the text after the text selection from the snapshot to code block
const { toDelta } = this._getDeltas();
if (toDelta.length > 0) {
deltas.push(...toDelta);
}
this.pointState.text.applyDelta(deltas);
this.snapshot.content = [];
};
private readonly _mergeMultiple = () => {
this._updateFlavour();
const { lastTextSnapshot, toDelta, firstDelta, lastDelta } =
this._getDeltas();
this.pointState.text.applyDelta([
{ retain: this.pointState.point.index },
this.pointState.text.length - this.pointState.point.index > 0
? { delete: this.pointState.text.length - this.pointState.point.index }
: {},
...firstDelta,
]);
const removedFirstSnapshot = this.snapshot.content.shift();
removedFirstSnapshot?.children.forEach(block => {
this.snapshot.content.unshift(block);
});
this.pasteStartModelChildrenCount =
removedFirstSnapshot?.children.length ?? 0;
this._updateSnapshot();
lastTextSnapshot.delta = [...lastDelta, ...toDelta];
};
private readonly _mergeSingle = () => {
this._updateFlavour();
const { firstDelta } = this._getDeltas();
const { index, length } = this.pointState.point;
// Pastes a link
if (length && firstDelta.length === 1 && firstDelta[0].attributes?.link) {
this.pointState.text.format(index, length, firstDelta[0].attributes);
} else {
const ops: DeltaOperation[] = [{ retain: index }];
if (length) ops.push({ delete: length });
ops.push(...firstDelta);
this.pointState.text.applyDelta(ops);
}
this.snapshot.content.splice(0, 1);
this._updateSnapshot();
};
private readonly _textFromSnapshot = (snapshot: BlockSnapshot) => {
return (snapshot.props.text ?? { delta: [] }) as Record<
'delta',
DeltaOperation[]
>;
};
private readonly _updateSnapshot = () => {
if (this.snapshot.content.length === 0) {
this.firstSnapshot = this.lastSnapshot = undefined;
return;
}
this.firstSnapshot = this.snapshot.content[0];
this.lastSnapshot = findLast(this.snapshot) ?? this.firstSnapshot;
};
private firstSnapshot?: BlockSnapshot;
private readonly firstSnapshotIsPlainText: boolean;
private readonly lastIndex: number;
private lastSnapshot?: BlockSnapshot;
private needCleanup = false;
private pasteStartModelChildrenCount = 0;
private readonly pointState: PointState;
canMerge = () => {
if (this.snapshot.content.length === 0) {
return false;
}
if (!this.firstSnapshot!.props.text) {
return false;
}
const firstTextSnapshot = this._textFromSnapshot(this.firstSnapshot!);
const lastTextSnapshot = this._textFromSnapshot(this.lastSnapshot!);
return (
firstTextSnapshot &&
lastTextSnapshot &&
(this.pointState.text.length > 0 || this.firstSnapshotIsPlainText)
);
};
convertToLinkedDoc = () => {
const parseDocUrlService = this.std.getOptional(ParseDocUrlProvider);
if (!parseDocUrlService) {
return;
}
const linkToDocId = new Map<string, string | null>();
for (const blockSnapshot of this.snapshot.content) {
if (blockSnapshot.props.text) {
const [delta, transformed] = this._transformLinkDelta(
this._textFromSnapshot(blockSnapshot).delta,
linkToDocId,
parseDocUrlService
);
const model = this.std.store.getBlock(blockSnapshot.id)?.model;
if (transformed && model) {
this.std.store.captureSync();
this.std.store.transact(() => {
const text = model.text as Text;
text.clear();
text.applyDelta(delta);
});
}
}
}
const fromPointStateText = this.pointState.model.text;
if (!fromPointStateText) {
return;
}
const [delta, transformed] = this._transformLinkDelta(
fromPointStateText.toDelta(),
linkToDocId,
parseDocUrlService
);
if (!transformed) {
return;
}
this.std.store.captureSync();
this.std.store.transact(() => {
fromPointStateText.clear();
fromPointStateText.applyDelta(delta);
});
};
focusPasted = () => {
const host = this.std.host;
const cursorBlock =
this.pointState.model.flavour === 'affine:code' || !this.lastSnapshot
? this.std.store.getBlock(this.pointState.model.id)
: this.std.store.getBlock(this.lastSnapshot.id);
if (!cursorBlock) {
return;
}
const { model: cursorModel } = cursorBlock;
host.updateComplete
.then(() => {
const target = this.std.host.querySelector<BlockComponent>(
`[${BLOCK_ID_ATTR}="${cursorModel.id}"]`
);
if (!target) {
return;
}
if (!cursorModel.text) {
if (matchModels(cursorModel, [ImageBlockModel])) {
const selection = this.std.selection.create(ImageSelection, {
blockId: target.blockId,
});
this.std.selection.setGroup('note', [selection]);
return;
}
const selection = this.std.selection.create(BlockSelection, {
blockId: target.blockId,
});
this.std.selection.setGroup('note', [selection]);
return;
}
const selection = this.std.selection.create(TextSelection, {
from: {
blockId: target.blockId,
index: cursorModel.text ? this.lastIndex : 0,
length: 0,
},
to: null,
});
this.std.selection.setGroup('note', [selection]);
})
.catch(console.error);
};
pasted = () => {
if (!(this.needCleanup || this.pointState.text.length === 0)) {
return;
}
if (this.lastSnapshot) {
const lastModel = this.std.store.getBlock(this.lastSnapshot.id)?.model;
if (!lastModel) {
return;
}
this.std.store.moveBlocks(this.pointState.model.children, lastModel);
}
this.std.store.moveBlocks(
this.std.store
.getNexts(this.pointState.model.id)
.slice(0, this.pasteStartModelChildrenCount),
this.pointState.model
);
if (!this.firstSnapshotIsPlainText && this.pointState.text.length == 0) {
this.std.store.deleteBlock(this.pointState.model);
}
};
constructor(
readonly std: EditorHost['std'],
readonly text: TextSelection,
readonly snapshot: SliceSnapshot
) {
const { from } = text;
this.pointState = new PointState(std, from);
this.firstSnapshot = snapshot.content[0];
this.lastSnapshot = findLast(snapshot) ?? this.firstSnapshot;
if (
this.firstSnapshot !== this.lastSnapshot &&
this.lastSnapshot.props.text &&
!matchModels(this.pointState.model, [CodeBlockModel])
) {
const text = fromJSON(this.lastSnapshot.props.text) as Text;
const doc = new Y.Doc();
const temp = doc.getMap('temp');
temp.set('text', text.yText);
this.lastIndex = text.length;
} else {
this.lastIndex =
this.pointState.point.index +
this.snapshot.content
.map(snapshot =>
this._textFromSnapshot(snapshot)
.delta.map(op => {
if (op.insert) {
return op.insert.length;
} else if (op.delete) {
return -op.delete;
} else {
return 0;
}
})
.reduce((a, b) => a + b, 0)
)
.reduce((a, b) => a + b + 1, -1);
}
this.firstSnapshotIsPlainText =
this.firstSnapshot.flavour === 'affine:paragraph' &&
this.firstSnapshot.props.type === 'text';
}
private _transformLinkDelta(
delta: DeltaOperation[],
linkToDocId: Map<string, string | null>,
parseDocUrlService: ParseDocUrlService
): [DeltaOperation[], boolean] {
let transformed = false;
const needToConvert = new Map<DeltaOperation, string>();
for (const op of delta) {
if (op.attributes?.link) {
let docId = linkToDocId.get(op.attributes.link);
if (!docId) {
const searchResult = parseDocUrlService.parseDocUrl(
op.attributes.link
);
if (searchResult) {
const doc = this.std.workspace.getDoc(searchResult.docId);
if (doc) {
docId = doc.id;
linkToDocId.set(op.attributes.link, doc.id);
}
}
}
if (docId) {
needToConvert.set(op, docId);
}
}
}
const newDelta = delta.map(op => {
if (!needToConvert.has(op)) {
return { ...op };
}
const link = op.attributes?.link;
if (!link) {
return { ...op };
}
const pageId = needToConvert.get(op);
if (!pageId) {
// External link
this.std.getOptional(TelemetryProvider)?.track('Link', {
page: 'doc editor',
category: 'pasted link',
other: 'external link',
type: 'link',
});
return { ...op };
}
const reference: AffineTextAttributes['reference'] = {
pageId,
type: 'LinkedPage',
};
// Title alias
if (op.insert && op.insert !== REFERENCE_NODE && op.insert !== link) {
reference.title = op.insert;
}
const extractedParams = extractSearchParams(link);
const isLinkedBlock = extractedParams
? referenceToNode({ pageId, ...extractedParams })
: false;
Object.assign(reference, extractedParams);
// Internal link
this.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
page: 'doc editor',
category: 'pasted link',
other: 'existing doc',
type: isLinkedBlock ? 'block' : 'doc',
});
transformed = true;
return {
...op,
attributes: { reference },
insert: REFERENCE_NODE,
};
});
return [newDelta, transformed];
}
private _updateFlavour() {
this.firstSnapshot!.flavour = this.pointState.model.flavour;
if (this.firstSnapshot!.props.type) {
this.firstSnapshot!.props.type = (
this.pointState.model as ParagraphBlockModel
).props.type;
}
}
merge() {
if (this.firstSnapshot === this.lastSnapshot) {
this._mergeSingle();
return;
}
if (this.pointState.model.flavour === 'affine:code') {
this._mergeCode();
return;
}
this.needCleanup = true;
this._mergeMultiple();
}
}
function flatNote(snapshot: SliceSnapshot) {
if (snapshot.content[0]?.flavour === 'affine:note') {
snapshot.content = snapshot.content[0].children;
}
}
export const pasteMiddleware = (
std: EditorHost['std']
): TransformerMiddleware => {
return ({ slots }) => {
let tr: PasteTr | undefined;
const beforeImportSubscription = slots.beforeImport.subscribe(payload => {
if (payload.type === 'slice') {
const { snapshot } = payload;
flatNote(snapshot);
const text = std.selection.find(TextSelection);
if (!text) {
return;
}
tr = new PasteTr(std, text, payload.snapshot);
if (tr.canMerge()) {
tr.merge();
}
}
});
const afterImportSubscription = slots.afterImport.subscribe(payload => {
if (tr && payload.type === 'slice') {
tr.pasted();
tr.focusPasted();
tr.convertToLinkedDoc();
}
});
return () => {
beforeImportSubscription.unsubscribe();
afterImportSubscription.unsubscribe();
};
};
};
function extractSearchParams(link: string) {
try {
const url = new URL(link);
const mode = url.searchParams.get('mode') as DocMode | undefined;
if (mode && DocModes.includes(mode)) {
const params: ReferenceInfo['params'] = { mode: mode as DocMode };
const blockIds = url.searchParams
.get('blockIds')
?.trim()
.split(',')
.map(id => id.trim())
.filter(id => id.length);
const elementIds = url.searchParams
.get('elementIds')
?.trim()
.split(',')
.map(id => id.trim())
.filter(id => id.length);
if (blockIds?.length) {
params.blockIds = blockIds;
}
if (elementIds?.length) {
params.elementIds = elementIds;
}
return { params };
}
} catch (err) {
console.error(err);
}
return null;
}

View File

@@ -0,0 +1,65 @@
import type { TransformerMiddleware } from '@blocksuite/store';
import { StoreExtension } from '@blocksuite/store';
import { DEFAULT_IMAGE_PROXY_ENDPOINT } from '../../consts';
export const customImageProxyMiddleware = (
imageProxyURL: string
): TransformerMiddleware => {
return ({ adapterConfigs }) => {
adapterConfigs.set('imageProxy', imageProxyURL);
};
};
const imageProxyMiddlewareBuilder = () => {
let middleware = customImageProxyMiddleware(DEFAULT_IMAGE_PROXY_ENDPOINT);
return {
get: () => middleware,
set: (url: string) => {
middleware = customImageProxyMiddleware(url);
},
};
};
const IMAGE_PROXY_PATH = '/api/worker/image-proxy';
export const isImageProxyURL = (imageUrl: string) => {
try {
const url = new URL(imageUrl, globalThis.location.origin);
return url.pathname === IMAGE_PROXY_PATH && url.searchParams.has('url');
} catch {
return false;
}
};
const defaultImageProxyMiddlewarBuilder = imageProxyMiddlewareBuilder();
export const setImageProxyMiddlewareURL = defaultImageProxyMiddlewarBuilder.set;
export const defaultImageProxyMiddleware: TransformerMiddleware = args => {
return defaultImageProxyMiddlewarBuilder.get()(args);
};
// TODO(@mirone): this should be configured when setup instead of runtime
export class ImageProxyService extends StoreExtension {
static override key = 'image-proxy';
private _imageProxyURL = DEFAULT_IMAGE_PROXY_ENDPOINT;
setImageProxyURL(url: string) {
this._imageProxyURL = url;
setImageProxyMiddlewareURL(url);
}
buildUrl(imageUrl: string) {
if (imageUrl.startsWith(this.imageProxyURL) || isImageProxyURL(imageUrl)) {
return imageUrl;
}
return `${this.imageProxyURL}?url=${encodeURIComponent(imageUrl)}`;
}
get imageProxyURL() {
return this._imageProxyURL;
}
}

View File

@@ -0,0 +1,256 @@
import {
DatabaseBlockModel,
EmbedLinkedDocModel,
EmbedSyncedDocModel,
ListBlockModel,
ParagraphBlockModel,
SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import type {
AfterImportBlockPayload,
BeforeImportBlockPayload,
DeltaOperation,
TransformerMiddleware,
} from '@blocksuite/store';
import { filter, map } from 'rxjs';
import { matchModels } from '../../utils';
export const replaceIdMiddleware =
(idGenerator: () => string): TransformerMiddleware =>
({ slots, docCRUD, assetsManager }) => {
const idMap = new Map<string, string>();
// After Import
const afterImportBlock$ = slots.afterImport.pipe(
filter(
(payload): payload is AfterImportBlockPayload =>
payload.type === 'block'
),
map(({ model }) => model)
);
const afterImportBlockSubscription = afterImportBlock$
.pipe(filter(model => matchModels(model, [DatabaseBlockModel])))
.subscribe(model => {
Object.keys(model.props.cells).forEach(cellId => {
if (idMap.has(cellId)) {
model.props.cells[idMap.get(cellId)!] = model.props.cells[cellId];
delete model.props.cells[cellId];
}
});
});
// replace LinkedPage pageId with new id in paragraph blocks
const replaceLinkedPageIdSubscription = afterImportBlock$
.pipe(
filter(model =>
matchModels(model, [ParagraphBlockModel, ListBlockModel])
)
)
.subscribe(model => {
let prev = 0;
const delta: DeltaOperation[] = [];
for (const d of model.props.text.toDelta()) {
if (d.attributes?.reference?.pageId) {
const newId = idMap.get(d.attributes.reference.pageId);
if (!newId) {
prev += d.insert?.length ?? 0;
continue;
}
if (prev > 0) {
delta.push({ retain: prev });
}
delta.push({
retain: d.insert?.length ?? 0,
attributes: {
reference: {
...d.attributes.reference,
pageId: newId,
},
},
});
prev = 0;
} else {
prev += d.insert?.length ?? 0;
}
}
if (delta.length > 0) {
model.props.text.applyDelta(delta);
}
});
const replaceSurfaceRefIdSubscription = afterImportBlock$
.pipe(filter(model => matchModels(model, [SurfaceRefBlockModel])))
.subscribe(model => {
const original = model.props.reference;
// If there exists a replacement, replace the reference with the new id.
// Otherwise,
// 1. If the reference is an affine:frame not in doc, generate a new id.
// 2. If the reference is graph, keep the original id.
if (idMap.has(original)) {
model.props.reference = idMap.get(original)!;
} else if (
model.props.refFlavour === 'affine:frame' &&
!model.store.hasBlock(original)
) {
const newId = idGenerator();
idMap.set(original, newId);
model.props.reference = newId;
}
});
// TODO(@fundon): process linked block/element
const replaceLinkedDocIdSubscription = afterImportBlock$
.pipe(
filter(model =>
matchModels(model, [EmbedLinkedDocModel, EmbedSyncedDocModel])
)
)
.subscribe(model => {
const original = model.props.pageId;
// If the pageId is not in the doc, generate a new id.
// If we already have a replacement, use it.
if (!docCRUD.get(original)) {
if (idMap.has(original)) {
model.props.pageId = idMap.get(original)!;
} else {
const newId = idGenerator();
idMap.set(original, newId);
model.props.pageId = newId;
}
}
});
// Before Import
const beforeImportPageSubscription = slots.beforeImport
.pipe(filter(payload => payload.type === 'page'))
.subscribe(payload => {
if (idMap.has(payload.snapshot.meta.id)) {
payload.snapshot.meta.id = idMap.get(payload.snapshot.meta.id)!;
return;
}
const newId = idGenerator();
idMap.set(payload.snapshot.meta.id, newId);
payload.snapshot.meta.id = newId;
});
const beforeImportBlockSubscription = slots.beforeImport
.pipe(
filter(
(payload): payload is BeforeImportBlockPayload =>
payload.type === 'block'
)
)
.subscribe(payload => {
const { snapshot } = payload;
if (snapshot.flavour === 'affine:page') {
const index = snapshot.children.findIndex(
c => c.flavour === 'affine:surface'
);
if (index !== -1) {
const [surface] = snapshot.children.splice(index, 1);
snapshot.children.push(surface);
}
}
const original = snapshot.id;
let newId: string;
if (idMap.has(original)) {
newId = idMap.get(original)!;
} else {
newId = idGenerator();
idMap.set(original, newId);
}
snapshot.id = newId;
// Should be re-paired.
if (['affine:attachment', 'affine:image'].includes(snapshot.flavour)) {
if (!assetsManager.uploadingAssetsMap.has(original)) return;
const data = assetsManager.uploadingAssetsMap.get(original)!;
assetsManager.uploadingAssetsMap.set(newId, data);
assetsManager.uploadingAssetsMap.delete(original);
return;
}
if (snapshot.flavour === 'affine:surface') {
// Generate new IDs for images and frames in advance.
snapshot.children.forEach(child => {
const original = child.id;
if (idMap.has(original)) {
newId = idMap.get(original)!;
} else {
newId = idGenerator();
idMap.set(original, newId);
}
});
Object.entries(
snapshot.props.elements as Record<string, Record<string, unknown>>
).forEach(([_, value]) => {
switch (value.type) {
case 'connector': {
let connection = value.source as Record<string, string>;
if (idMap.has(connection.id)) {
const newId = idMap.get(connection.id);
if (!newId) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.TransformerError,
`reference id must exist: ${connection.id}`
);
}
connection.id = newId;
}
connection = value.target as Record<string, string>;
if (idMap.has(connection.id)) {
const newId = idMap.get(connection.id);
if (!newId) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.TransformerError,
`reference id must exist: ${connection.id}`
);
}
connection.id = newId;
}
break;
}
case 'group': {
const json = (value.children as Record<string, unknown>)
.json as Record<string, unknown>;
Object.entries(json).forEach(([key, value]) => {
if (idMap.has(key)) {
delete json[key];
const newKey = idMap.get(key);
if (!newKey) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.TransformerError,
`reference id must exist: ${key}`
);
}
json[newKey] = value;
}
});
break;
}
default:
break;
}
});
}
});
return () => {
afterImportBlockSubscription.unsubscribe();
replaceLinkedPageIdSubscription.unsubscribe();
replaceSurfaceRefIdSubscription.unsubscribe();
replaceLinkedDocIdSubscription.unsubscribe();
beforeImportPageSubscription.unsubscribe();
beforeImportBlockSubscription.unsubscribe();
};
};

View File

@@ -0,0 +1,46 @@
import type { BlockStdScope } from '@blocksuite/std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const surfaceRefToEmbed =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
let pageId: string | null = null;
const beforeImportSliceSubscription = slots.beforeImport.subscribe(
payload => {
if (payload.type === 'slice') {
pageId = payload.snapshot.pageId;
}
}
);
const beforeImportBlockSubscription = slots.beforeImport.subscribe(
payload => {
// only handle surface-ref block snapshot
if (
payload.type !== 'block' ||
payload.snapshot.flavour !== 'affine:surface-ref'
)
return;
// turn into embed-linked-doc if the current doc is different from the pageId of the surface-ref block
const isNotSameDoc = pageId !== std.store.doc.id;
if (pageId && isNotSameDoc) {
// The blockId of the original surface-ref block
const blockId = payload.snapshot.id;
payload.snapshot.id = std.workspace.idGenerator();
payload.snapshot.flavour = 'affine:embed-linked-doc';
payload.snapshot.props = {
pageId,
params: {
mode: 'page',
blockIds: [blockId],
},
};
}
}
);
return () => {
beforeImportSliceSubscription.unsubscribe();
beforeImportBlockSubscription.unsubscribe();
};
};

View File

@@ -0,0 +1,15 @@
import type { DocMeta, TransformerMiddleware } from '@blocksuite/store';
export const titleMiddleware =
(metas: DocMeta[]): TransformerMiddleware =>
({ slots, adapterConfigs }) => {
const beforeExportSubscription = slots.beforeExport.subscribe(() => {
for (const meta of metas) {
adapterConfigs.set('title:' + meta.id, meta.title);
}
});
return () => {
beforeExportSubscription.unsubscribe();
};
};

View File

@@ -0,0 +1,118 @@
import { sha } from '@blocksuite/global/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type {
BlockModel,
BlockProps,
TransformerMiddleware,
} from '@blocksuite/store';
import { filter, from, map, mergeMap } from 'rxjs';
const ALLOWED_FLAVOURS = new Set(['affine:attachment', 'affine:image']);
export const uploadMiddleware = (
std: BlockStdScope,
concurrent = 5
): TransformerMiddleware => {
const blockView$ = std.view.viewUpdated.pipe(
filter(payload => payload.type === 'block'),
filter(payload => ALLOWED_FLAVOURS.has(payload.view.model.flavour))
);
return ({ assetsManager }) => {
async function upload(
model: BlockModel,
{
blob,
mapInto,
abortController,
}: {
blob: Blob;
mapInto: (blobId: string) => Partial<BlockProps>;
abortController?: AbortController;
}
) {
if (!abortController) return null;
const signal = abortController.signal;
if (signal.aborted) return null;
// Double check
if (!model.store.hasBlock(model.id)) return null;
try {
signal.throwIfAborted();
const blobId = await Promise.race([
(async function processUpload() {
const blobId = await sha(await blob.arrayBuffer());
assetsManager.getAssets().set(blobId, blob);
await assetsManager.writeToBlob(blobId);
return await new Promise<string | null>(resolve => {
model.store.withoutTransact(() => {
if (signal.aborted) return resolve(null);
model.store.updateBlock(model, mapInto(blobId));
resolve(blobId);
});
});
})(),
// If the signal is not aborted, it will be in the pending state.
new Promise<null>(resolve => {
signal.addEventListener('abort', () => resolve(null), {
once: true,
});
if (signal.aborted) {
resolve(null);
}
}),
]);
return blobId;
} catch (err) {
console.error(err);
return null;
}
}
const blockViewSubscription = blockView$
.pipe(
map(payload => {
if (assetsManager.uploadingAssetsMap.size === 0) return null;
const model = payload.view.model;
if (!assetsManager.uploadingAssetsMap.has(model.id)) return null;
const state = assetsManager.uploadingAssetsMap.get(model.id)!;
if (payload.method === 'add') {
state.abortController = new AbortController();
return { model, state };
} else {
state.abortController?.abort();
assetsManager.uploadingAssetsMap.delete(model.id);
return null;
}
}),
filter(Boolean),
mergeMap(
({ model, state }) =>
from(
upload(model, state).then(() => {
assetsManager.uploadingAssetsMap.delete(model.id);
})
),
concurrent
)
)
.subscribe();
return () => {
blockViewSubscription.unsubscribe();
};
};
};

View File

@@ -0,0 +1,360 @@
import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model';
import type { ServiceProvider } from '@blocksuite/global/di';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DeltaInsert,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import { MarkdownAdapter } from './markdown/markdown';
import { AdapterFactoryIdentifier } from './types/adapter';
export type MixText = string;
type MixTextToSliceSnapshotPayload = {
file: MixText;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class MixTextAdapter extends BaseAdapter<MixText> {
private readonly _markdownAdapter: MarkdownAdapter;
constructor(job: Transformer, provider: ServiceProvider) {
super(job, provider);
this._markdownAdapter = new MarkdownAdapter(job, provider);
}
private _splitDeltas(deltas: DeltaInsert[]): DeltaInsert[][] {
const result: DeltaInsert[][] = [[]];
const pending: DeltaInsert[] = deltas;
while (pending.length > 0) {
const delta = pending.shift();
if (!delta) {
break;
}
if (delta.insert.includes('\n')) {
const splitIndex = delta.insert.indexOf('\n');
const line = delta.insert.slice(0, splitIndex);
const rest = delta.insert.slice(splitIndex + 1);
result[result.length - 1].push({ ...delta, insert: line });
result.push([]);
if (rest) {
pending.unshift({ ...delta, insert: rest });
}
} else {
result[result.length - 1].push(delta);
}
}
return result;
}
private async _traverseSnapshot(
snapshot: BlockSnapshot
): Promise<{ mixtext: string }> {
let buffer = '';
const walker = new ASTWalker<BlockSnapshot, never>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(o => {
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
if (buffer.length > 0) {
buffer += '\n';
}
switch (o.node.flavour) {
case 'affine:code': {
buffer += text.delta.map(delta => delta.insert).join('');
break;
}
case 'affine:paragraph': {
buffer += text.delta.map(delta => delta.insert).join('');
break;
}
case 'affine:list': {
buffer += text.delta.map(delta => delta.insert).join('');
break;
}
case 'affine:divider': {
buffer += '---';
break;
}
}
});
await walker.walkONode(snapshot);
return {
mixtext: buffer,
};
}
async fromBlockSnapshot({
snapshot,
}: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<MixText>> {
const { mixtext } = await this._traverseSnapshot(snapshot);
return {
file: mixtext,
assetsIds: [],
};
}
async fromDocSnapshot({
snapshot,
assets,
}: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<MixText>> {
let buffer = '';
if (snapshot.meta.title) {
buffer += `${snapshot.meta.title}\n\n`;
}
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: snapshot.blocks,
assets,
});
buffer += file;
return {
file: buffer,
assetsIds,
};
}
async fromSliceSnapshot({
snapshot,
}: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<MixText>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of snapshot.content) {
const { mixtext } = await this._traverseSnapshot(contentSlice);
buffer += mixtext;
}
const mixtext =
buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer;
return {
file: mixtext,
assetsIds: sliceAssetsIds,
};
}
toBlockSnapshot(payload: ToBlockSnapshotPayload<MixText>): BlockSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
};
}
toDocSnapshot(payload: ToDocSnapshotPayload<MixText>): DocSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'page',
meta: {
id: nanoid(),
title: 'Untitled',
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Untitled',
},
],
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
{
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
},
],
},
};
}
async toSliceSnapshot(
payload: MixTextToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
if (payload.file.trim().length === 0) {
return null;
}
payload.file = payload.file.replaceAll('\r', '');
const sliceSnapshot = await this._markdownAdapter.toSliceSnapshot({
file: payload.file,
assets: payload.assets,
workspaceId: payload.workspaceId,
pageId: payload.pageId,
});
if (!sliceSnapshot) {
return null;
}
for (const contentSlice of sliceSnapshot.content) {
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
} as BlockSnapshot;
const walker = new ASTWalker<BlockSnapshot, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter((o, context) => {
switch (o.node.flavour) {
case 'affine:note': {
break;
}
case 'affine:paragraph': {
if (o.parent?.node.flavour !== 'affine:note') {
context.openNode({ ...o.node, children: [] });
break;
}
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
const newDeltas = this._splitDeltas(text.delta);
for (const [i, delta] of newDeltas.entries()) {
context.openNode({
...o.node,
id: i === 0 ? o.node.id : nanoid(),
props: {
...o.node.props,
text: {
'$blocksuite:internal:text$': true,
delta,
},
},
children: [],
});
if (i < newDeltas.length - 1) {
context.closeNode();
}
}
break;
}
default: {
context.openNode({ ...o.node, children: [] });
}
}
});
walker.setLeave((o, context) => {
switch (o.node.flavour) {
case 'affine:note': {
break;
}
default: {
context.closeNode();
}
}
});
await walker.walk(contentSlice, blockSnapshotRoot);
contentSlice.children = blockSnapshotRoot.children;
}
return sliceSnapshot;
}
}
export const MixTextAdapterFactoryIdentifier =
AdapterFactoryIdentifier('MixText');
export const MixTextAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(MixTextAdapterFactoryIdentifier, provider => ({
get: (job: Transformer) => new MixTextAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,33 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { BlockAdapterMatcher } from '../types/adapter.js';
import type { HtmlAST } from '../types/hast.js';
import type { NotionHtmlDeltaConverter } from './delta-converter.js';
export type BlockNotionHtmlAdapterMatcher = BlockAdapterMatcher<
HtmlAST,
NotionHtmlDeltaConverter
>;
export const BlockNotionHtmlAdapterMatcherIdentifier =
createIdentifier<BlockNotionHtmlAdapterMatcher>(
'BlockNotionHtmlAdapterMatcher'
);
export function BlockNotionHtmlAdapterExtension(
matcher: BlockNotionHtmlAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockNotionHtmlAdapterMatcher>;
} {
const identifier = BlockNotionHtmlAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}

View File

@@ -0,0 +1,105 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { DeltaInsert, ExtensionType } from '@blocksuite/store';
import isEqual from 'lodash-es/isEqual';
import type { AffineTextAttributes } from '../../types/index.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type DeltaASTConverterOptions,
type InlineDeltaMatcher,
} from '../types/delta-converter.js';
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
export type InlineDeltaToNotionHtmlAdapterMatcher =
InlineDeltaMatcher<InlineHtmlAST>;
export type NotionHtmlASTToDeltaMatcher = ASTToDeltaMatcher<HtmlAST>;
export const NotionHtmlASTToDeltaMatcherIdentifier =
createIdentifier<NotionHtmlASTToDeltaMatcher>('NotionHtmlASTToDeltaMatcher');
export function NotionHtmlASTToDeltaExtension(
matcher: NotionHtmlASTToDeltaMatcher
): ExtensionType & {
identifier: ServiceIdentifier<NotionHtmlASTToDeltaMatcher>;
} {
const identifier = NotionHtmlASTToDeltaMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export class NotionHtmlDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
HtmlAST
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToNotionHtmlAdapterMatcher[],
readonly htmlASTToDeltaMatchers: NotionHtmlASTToDeltaMatcher[]
) {
super();
}
private _spreadAstToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
const context = {
configs: this.configs,
options,
toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) =>
this._spreadAstToDelta(ast, options),
};
for (const matcher of this.htmlASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
const result =
'children' in ast
? ast.children.flatMap(child => this._spreadAstToDelta(child, options))
: [];
if (options.removeLastBr && result.length > 0) {
const lastItem = result[result.length - 1];
if (lastItem.insert === '\n') {
result.pop();
}
}
return result;
}
astToDelta(
ast: HtmlAST,
options: DeltaASTConverterOptions = Object.create(null)
): DeltaInsert<AffineTextAttributes>[] {
return this._spreadAstToDelta(ast, options).reduce((acc, cur) => {
if (acc.length === 0) {
return [cur];
}
const last = acc[acc.length - 1];
if (
typeof last.insert === 'string' &&
typeof cur.insert === 'string' &&
isEqual(last.attributes, cur.attributes)
) {
last.insert += cur.insert;
return acc;
}
return [...acc, cur];
}, [] as DeltaInsert<AffineTextAttributes>[]);
}
deltaToAST(_: DeltaInsert<AffineTextAttributes>[]): InlineHtmlAST[] {
return [];
}
}

View File

@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './notion-html.js';

View File

@@ -0,0 +1,300 @@
import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model';
import type { ServiceProvider } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type Transformer,
} from '@blocksuite/store';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import {
type AdapterContext,
AdapterFactoryIdentifier,
} from '../types/adapter';
import type { HtmlAST } from '../types/hast';
import { HastUtils } from '../utils/hast';
import {
type BlockNotionHtmlAdapterMatcher,
BlockNotionHtmlAdapterMatcherIdentifier,
} from './block-adapter';
import {
NotionHtmlASTToDeltaMatcherIdentifier,
NotionHtmlDeltaConverter,
} from './delta-converter';
export type NotionHtml = string;
type NotionHtmlToSliceSnapshotPayload = {
file: NotionHtml;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
type NotionHtmlToDocSnapshotPayload = {
file: NotionHtml;
assets?: AssetsManager;
pageId?: string;
pageMap?: Map<string, string>;
};
type NotionHtmlToBlockSnapshotPayload = NotionHtmlToDocSnapshotPayload;
export class NotionHtmlAdapter extends BaseAdapter<NotionHtml> {
private readonly _traverseNotionHtml = async (
html: HtmlAST,
snapshot: BlockSnapshot,
assets?: AssetsManager,
pageMap?: Map<string, string>
) => {
const walker = new ASTWalker<HtmlAST, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is HtmlAST =>
'type' in (node as object) && (node as HtmlAST).type !== undefined
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
NotionHtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
pageMap,
};
await matcher.toBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
NotionHtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
pageMap,
};
await matcher.toBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return walker.walk(html, snapshot);
};
deltaConverter: NotionHtmlDeltaConverter;
readonly blockMatchers: BlockNotionHtmlAdapterMatcher[];
constructor(job: Transformer, provider: ServiceProvider) {
super(job, provider);
const blockMatchers = Array.from(
provider.getAll(BlockNotionHtmlAdapterMatcherIdentifier).values()
);
const notionHtmlInlineToDeltaMatchers = Array.from(
provider.getAll(NotionHtmlASTToDeltaMatcherIdentifier).values()
);
this.blockMatchers = blockMatchers;
this.deltaConverter = new NotionHtmlDeltaConverter(
job.adapterConfigs,
[],
notionHtmlInlineToDeltaMatchers
);
}
private _htmlToAst(notionHtml: NotionHtml) {
return unified().use(rehypeParse).parse(notionHtml);
}
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromBlockSnapshot is not implemented'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromDocSnapshot is not implemented'
);
}
override fromSliceSnapshot(
_payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromSliceSnapshot is not implemented'
);
}
override toBlockSnapshot(
payload: NotionHtmlToBlockSnapshotPayload
): Promise<BlockSnapshot> {
const notionHtmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets,
payload.pageMap
);
}
override async toDoc(payload: NotionHtmlToDocSnapshotPayload) {
const snapshot = await this.toDocSnapshot(payload);
return this.job.snapshotToDoc(snapshot);
}
override async toDocSnapshot(
payload: NotionHtmlToDocSnapshotPayload
): Promise<DocSnapshot> {
const notionHtmlAst = this._htmlToAst(payload.file);
const titleAst = HastUtils.querySelector(notionHtmlAst, 'title');
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return {
type: 'page',
meta: {
id: payload.pageId ?? nanoid(),
title: HastUtils.getTextContent(titleAst, ''),
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: this.deltaConverter.astToDelta(
titleAst ?? {
type: 'text',
value: '',
}
),
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
await this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets,
payload.pageMap
),
],
},
};
}
override async toSliceSnapshot(
payload: NotionHtmlToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const notionHtmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
const contentSlice = (await this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
)) as BlockSnapshot;
if (contentSlice.children.length === 0) {
return null;
}
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const NotionHtmlAdapterFactoryIdentifier =
AdapterFactoryIdentifier('NotionHtml');
export const NotionHtmlAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(NotionHtmlAdapterFactoryIdentifier, provider => ({
get: (job: Transformer) => new NotionHtmlAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,191 @@
import { DefaultTheme } from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type AssetsManager,
BaseAdapter,
type BlockSnapshot,
type DeltaInsert,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotResult,
type FromDocSnapshotResult,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type Transformer,
} from '@blocksuite/store';
import type { AffineTextAttributes } from '../types';
import { AdapterFactoryIdentifier } from './types/adapter';
type NotionEditingStyle = {
0: string;
};
type NotionEditing = {
0: string;
1: Array<NotionEditingStyle>;
};
export type NotionTextSerialized = {
blockType: string;
editing: Array<NotionEditing>;
};
export type NotionText = string;
type NotionHtmlToSliceSnapshotPayload = {
file: NotionText;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class NotionTextAdapter extends BaseAdapter<NotionText> {
override fromBlockSnapshot():
| FromBlockSnapshotResult<NotionText>
| Promise<FromBlockSnapshotResult<NotionText>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.fromBlockSnapshot is not implemented.'
);
}
override fromDocSnapshot():
| FromDocSnapshotResult<NotionText>
| Promise<FromDocSnapshotResult<NotionText>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.fromDocSnapshot is not implemented.'
);
}
override fromSliceSnapshot():
| FromSliceSnapshotResult<NotionText>
| Promise<FromSliceSnapshotResult<NotionText>> {
return {
file: JSON.stringify({
blockType: 'text',
editing: [
['Notion Text is not supported to be exported from BlockSuite', []],
],
}),
assetsIds: [],
};
}
override toBlockSnapshot(): Promise<BlockSnapshot> | BlockSnapshot {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.toBlockSnapshot is not implemented.'
);
}
override toDocSnapshot(): Promise<DocSnapshot> | DocSnapshot {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionTextAdapter.toDocSnapshot is not implemented.'
);
}
override toSliceSnapshot(
payload: NotionHtmlToSliceSnapshotPayload
): SliceSnapshot | null {
const notionText = JSON.parse(payload.file) as NotionTextSerialized;
const content: SliceSnapshot['content'] = [];
const deltas: DeltaInsert<AffineTextAttributes>[] = [];
// Check if the notionText.editing is an array
if (!Array.isArray(notionText.editing)) {
return null;
}
for (const editing of notionText.editing) {
const delta: DeltaInsert<AffineTextAttributes> = {
insert: editing[0],
};
// Check if the stylesArray of editing[1] is an array
const stylesArray = editing[1];
if (Array.isArray(stylesArray)) {
for (const styleElement of stylesArray) {
// Skip invalid style entries
if (!styleElement || typeof styleElement[0] !== 'string') {
continue;
}
// Check if the delta.attributes exists, if not, create a new object
if (!delta.attributes) {
delta.attributes = Object.create(null);
}
// Add the style to the delta.attributes
switch (styleElement[0]) {
case 'b':
delta.attributes!.bold = true;
break;
case 'i':
delta.attributes!.italic = true;
break;
case '_':
delta.attributes!.underline = true;
break;
case 'c':
delta.attributes!.code = true;
break;
case 's':
delta.attributes!.strike = true;
break;
}
}
}
deltas.push(delta);
}
content.push({
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: 'both',
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltas,
},
},
children: [],
},
],
});
return {
type: 'slice',
content,
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const NotionTextAdapterFactoryIdentifier =
AdapterFactoryIdentifier('NotionText');
export const NotionTextAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(NotionTextAdapterFactoryIdentifier, provider => ({
get: (job: Transformer) => new NotionTextAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,25 @@
/**
* Resolve CSS variable color (var(--affine-xxx)) using computed styles
*/
export function resolveCssVariable(color: string): string | null {
if (!color || typeof color !== 'string') {
return null;
}
if (!color.startsWith('var(')) {
return color;
}
if (typeof document === 'undefined') {
return null;
}
const rootComputedStyle = getComputedStyle(document.documentElement);
const match = color.match(/var\(([^)]+)\)/);
if (!match || !match[1]) {
return null;
}
const variable = match[1].trim();
if (!variable.startsWith('--')) {
return null;
}
const value = rootComputedStyle.getPropertyValue(variable).trim();
return value || null;
}

View File

@@ -0,0 +1,122 @@
/**
* Delta to PDF content converter
*/
import { resolveCssVariable } from './css-utils.js';
/**
* Extract text from delta operations, preserving inline properties
* Returns normalized format: string if simple, array if complex (with inline styles)
*/
export function extractTextWithInline(
props: Record<string, any>,
configs: Map<string, string>
): string | Array<string | { text: string; [key: string]: any }> {
const delta = props?.text?.delta;
if (!Array.isArray(delta)) {
return ' ';
}
const result: Array<string | { text: string; [key: string]: any }> = [];
for (const op of delta) {
if (typeof op.insert !== 'string') {
continue;
}
const text = op.insert;
const attrs = op.attributes;
if (!attrs || Object.keys(attrs).length === 0) {
result.push(text);
continue;
}
const styleObj: { text: string; [key: string]: any } = { text };
if (attrs.bold === true) {
styleObj.bold = true;
}
if (attrs.italic === true) {
styleObj.italics = true;
}
const decorations: string[] = [];
if (attrs.strike === true) {
decorations.push('lineThrough');
}
if (attrs.underline === true) {
decorations.push('underline');
}
if (decorations.length > 0) {
styleObj.decoration = decorations;
}
if (attrs.code === true) {
styleObj.font = 'Inter';
styleObj.background = '#f5f5f5';
styleObj.fontSize = 10;
styleObj.text = ' ' + text + ' ';
}
if (attrs.color && typeof attrs.color === 'string') {
const resolved = resolveCssVariable(attrs.color);
if (resolved) {
styleObj.color = resolved;
}
}
if (
attrs.background &&
typeof attrs.background === 'string' &&
!attrs.code
) {
const resolvedBg = resolveCssVariable(attrs.background);
if (resolvedBg) {
styleObj.background = resolvedBg;
}
}
if (attrs.link) {
styleObj.link = attrs.link;
styleObj.color = '#0066cc';
}
if (attrs.reference) {
const ref = attrs.reference;
if (ref.type === 'LinkedPage' || ref.type === 'Subpage') {
const docLinkBaseUrl = configs.get('docLinkBaseUrl') || '';
const linkUrl = docLinkBaseUrl ? `${docLinkBaseUrl}/${ref.pageId}` : '';
const pageTitle = configs.get('title:' + ref.pageId);
const isPageFound = pageTitle !== undefined;
const displayTitle = pageTitle || 'Page not found';
if (!text || text.trim() === '' || text === ' ') {
styleObj.text = displayTitle;
}
styleObj.color = '#0066cc';
if (!isPageFound && styleObj.decoration) {
if (!Array.isArray(styleObj.decoration)) {
styleObj.decoration = [styleObj.decoration];
}
if (!styleObj.decoration.includes('lineThrough')) {
styleObj.decoration.push('lineThrough');
}
}
if (linkUrl) {
styleObj.link = linkUrl;
}
}
}
if (attrs.latex) {
styleObj.text = attrs.latex;
styleObj.italics = true;
styleObj.color = '#666666';
}
result.push(styleObj);
}
if (result.length === 0) {
return ' ';
}
if (result.length === 1 && typeof result[0] === 'string') {
return result[0] || ' ';
}
return result;
}

View File

@@ -0,0 +1,114 @@
/**
* Image dimension utilities
*/
import { MAX_PAPER_HEIGHT, MAX_PAPER_WIDTH } from './utils.js';
/**
* Calculate image dimensions respecting props, original size, and paper constraints
*/
export function calculateImageDimensions(
blockWidth: number | undefined,
blockHeight: number | undefined,
originalWidth: number | undefined,
originalHeight: number | undefined
): { width?: number; height?: number } {
let targetWidth =
blockWidth && blockWidth > 0
? blockWidth
: originalWidth && originalWidth > 0
? originalWidth
: undefined;
let targetHeight =
blockHeight && blockHeight > 0
? blockHeight
: originalHeight && originalHeight > 0
? originalHeight
: undefined;
if (!targetWidth && !targetHeight) {
return {};
}
if (targetWidth && targetWidth > MAX_PAPER_WIDTH) {
const ratio = MAX_PAPER_WIDTH / targetWidth;
targetWidth = MAX_PAPER_WIDTH;
if (targetHeight) {
targetHeight = targetHeight * ratio;
}
}
if (targetHeight && targetHeight > MAX_PAPER_HEIGHT) {
const ratio = MAX_PAPER_HEIGHT / targetHeight;
targetHeight = MAX_PAPER_HEIGHT;
if (targetWidth) {
targetWidth = targetWidth * ratio;
}
}
return {
width: targetWidth,
height: targetHeight,
};
}
/**
* Extract dimensions from SVG
*/
export function extractSvgDimensions(svgText: string): {
width?: number;
height?: number;
} {
const widthMatch = svgText.match(/width\s*=\s*["']?(\d+(?:\.\d+)?)/i);
const heightMatch = svgText.match(/height\s*=\s*["']?(\d+(?:\.\d+)?)/i);
const viewBoxMatch = svgText.match(
/viewBox\s*=\s*["']?\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)/i
);
let width: number | undefined;
let height: number | undefined;
if (widthMatch) {
width = parseFloat(widthMatch[1]);
}
if (heightMatch) {
height = parseFloat(heightMatch[1]);
}
if ((!width || !height) && viewBoxMatch) {
const viewBoxWidth = parseFloat(viewBoxMatch[1]);
const viewBoxHeight = parseFloat(viewBoxMatch[2]);
if (!width) width = viewBoxWidth;
if (!height) height = viewBoxHeight;
}
return { width, height };
}
/**
* Extract dimensions from JPEG/PNG using Image API
*/
export async function extractImageDimensions(
blob: Blob
): Promise<{ width?: number; height?: number }> {
return new Promise(resolve => {
const img = new Image();
const url = URL.createObjectURL(blob);
const timeout = setTimeout(() => {
URL.revokeObjectURL(url);
resolve({});
}, 5000);
img.onload = () => {
clearTimeout(timeout);
URL.revokeObjectURL(url);
resolve({ width: img.width, height: img.height });
};
img.onerror = () => {
clearTimeout(timeout);
URL.revokeObjectURL(url);
resolve({});
};
img.src = url;
});
}

View File

@@ -0,0 +1,6 @@
export * from './css-utils.js';
export * from './delta-converter.js';
export * from './image-utils.js';
export * from './pdf.js';
export * from './svg-utils.js';
export * from './utils.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
/**
* SVG icon generation utilities
*/
import { resolveCssVariable } from './css-utils.js';
/**
* Get SVG string for bulleted list icon based on depth
*/
export function getBulletIconSvg(depth: number): string {
const bulletIndex = depth % 4;
const blueColor = resolveCssVariable('var(--affine-blue-700)') || '#1E96EB';
const bulletSvgs = [
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><circle cx="7" cy="12" r="3" fill="${blueColor}"/></svg>`,
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M7 14.25C8.243 14.25 9.25 13.243 9.25 12C9.25 10.757 8.243 9.75 7 9.75C5.757 9.75 4.75 10.757 4.75 12C4.75 13.243 5.757 14.25 7 14.25ZM7 15C8.657 15 10 13.657 10 12C10 10.343 8.657 9 7 9C5.343 9 4 10.343 4 12C4 13.657 5.343 15 7 15Z" fill="${blueColor}" fill-rule="evenodd"/></svg>`,
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M6.408 9.245C6.735 8.918 7.265 8.918 7.592 9.245L9.755 11.408C10.082 11.735 10.082 12.265 9.755 12.592L7.592 14.755C7.265 15.082 6.735 15.082 6.408 14.755L4.245 12.592C3.918 12.265 3.918 11.735 4.245 11.408L6.408 9.245Z" fill="${blueColor}"/></svg>`,
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M9.163 12L7 9.837L4.837 12L7 14.163L9.163 12ZM7.592 9.245C7.265 8.918 6.735 8.918 6.408 9.245L4.245 11.408C3.918 11.735 3.918 12.265 4.245 12.592L6.408 14.755C6.735 15.082 7.265 15.082 7.592 14.755L9.755 12.592C10.082 12.265 10.082 11.735 9.755 11.408L7.592 9.245Z" fill="${blueColor}" fill-rule="evenodd"/></svg>`,
];
return bulletSvgs[bulletIndex];
}
/**
* Get SVG string for checkbox icon (checked or unchecked)
*/
export function getCheckboxIconSvg(checked: boolean): string {
if (checked) {
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M3.25 6C3.25 4.48122 4.48122 3.25 6 3.25H18C19.5188 3.25 20.75 4.48122 20.75 6V18C20.75 19.5188 19.5188 20.75 18 20.75H6C4.48122 20.75 3.25 19.5188 3.25 18V6ZM16.5303 9.53033C16.8232 9.23744 16.8232 8.76256 16.5303 8.46967C16.2374 8.17678 15.7626 8.17678 15.4697 8.46967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.5303 9.53033Z" fill="#1E96EB" fill-rule="evenodd"/></svg>';
} else {
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6Z" fill="#666666" fill-rule="evenodd"/></svg>';
}
}
/**
* Get SVG string for toggle icon (down or right)
*/
export function getToggleIconSvg(expanded: boolean): string {
if (expanded) {
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17L16.59 8.59L18 10L12 16L6 10L7.41 8.59Z" fill="#666666"/></svg>';
} else {
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12L8.59 7.41L10 6L16 12L10 18L8.59 16.59Z" fill="#666666"/></svg>';
}
}

View File

@@ -0,0 +1,71 @@
/**
* Pure utility functions for PDF adapter
*/
// Layout constants
export const BLOCK_CHILDREN_CONTAINER_PADDING_LEFT = 24;
export const MAX_PAPER_WIDTH = 550;
export const MAX_PAPER_HEIGHT = 800;
// Color constants
export const PDF_COLORS = {
/** Primary link color */
link: '#0066cc',
/** Primary text color */
text: '#333333',
/** Secondary/muted text color */
textMuted: '#666666',
/** Tertiary/disabled text color */
textDisabled: '#999999',
/** Border/divider color */
border: '#cccccc',
/** Code block background */
codeBackground: '#f5f5f5',
/** Card/container background */
cardBackground: '#f9f9f9',
} as const;
/**
* Table layout with no borders (for custom styled containers)
*/
export const TABLE_LAYOUT_NO_BORDERS = {
hLineWidth: () => 0,
vLineWidth: () => 0,
paddingLeft: () => 0,
paddingRight: () => 0,
paddingTop: () => 0,
paddingBottom: () => 0,
} as const;
/**
* Generate placeholder text for images that cannot be rendered
*/
export function getImagePlaceholder(caption?: string): string {
return caption ? `[Image: ${caption}]` : '[Image]';
}
/**
* Check if text content has meaningful content
*/
export function hasTextContent(
textContent: string | Array<string | { text: string; [key: string]: any }>
): boolean {
if (typeof textContent === 'string') {
return textContent.trim() !== '';
}
return textContent.length > 0;
}
/**
* Convert text content array to plain string
*/
export function textContentToString(
textContent: string | Array<string | { text: string; [key: string]: any }>
): string {
if (typeof textContent === 'string') {
return textContent;
}
return textContent
.map(item => (typeof item === 'string' ? item : item.text))
.join('');
}

View File

@@ -0,0 +1,28 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { BlockAdapterMatcher, TextBuffer } from '../types/adapter.js';
export type BlockPlainTextAdapterMatcher = BlockAdapterMatcher<TextBuffer>;
export const BlockPlainTextAdapterMatcherIdentifier =
createIdentifier<BlockPlainTextAdapterMatcher>(
'BlockPlainTextAdapterMatcher'
);
export function BlockPlainTextAdapterExtension(
matcher: BlockPlainTextAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<BlockPlainTextAdapterMatcher>;
} {
const identifier = BlockPlainTextAdapterMatcherIdentifier(matcher.flavour);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}

View File

@@ -0,0 +1,81 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { DeltaInsert, ExtensionType } from '@blocksuite/store';
import type { AffineTextAttributes } from '../../types/index.js';
import type { TextBuffer } from '../types/adapter.js';
import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type InlineDeltaMatcher,
} from '../types/delta-converter.js';
export type InlineDeltaToPlainTextAdapterMatcher =
InlineDeltaMatcher<TextBuffer>;
export const InlineDeltaToPlainTextAdapterMatcherIdentifier =
createIdentifier<InlineDeltaToPlainTextAdapterMatcher>(
'InlineDeltaToPlainTextAdapterMatcher'
);
export function InlineDeltaToPlainTextAdapterExtension(
matcher: InlineDeltaToPlainTextAdapterMatcher
): ExtensionType & {
identifier: ServiceIdentifier<InlineDeltaToPlainTextAdapterMatcher>;
} {
const identifier = InlineDeltaToPlainTextAdapterMatcherIdentifier(
matcher.name
);
return {
setup: di => {
di.addImpl(identifier, () => matcher);
},
identifier,
};
}
export type PlainTextASTToDeltaMatcher = ASTToDeltaMatcher<string>;
export class PlainTextDeltaConverter extends DeltaASTConverter<
AffineTextAttributes,
string
> {
constructor(
readonly configs: Map<string, string>,
readonly inlineDeltaMatchers: InlineDeltaToPlainTextAdapterMatcher[],
readonly plainTextASTToDeltaMatchers: PlainTextASTToDeltaMatcher[]
) {
super();
}
astToDelta(ast: string) {
const context = {
configs: this.configs,
options: Object.create(null),
toDelta: (ast: string) => this.astToDelta(ast),
};
for (const matcher of this.plainTextASTToDeltaMatchers) {
if (matcher.match(ast)) {
return matcher.toDelta(ast, context);
}
}
return [];
}
deltaToAST(deltas: DeltaInsert<AffineTextAttributes>[]): string[] {
return deltas.map(delta => {
const context = {
configs: this.configs,
current: { content: delta.insert },
};
for (const matcher of this.inlineDeltaMatchers) {
if (matcher.match(delta)) {
context.current = matcher.toAST(delta, context);
}
}
return context.current.content;
});
}
}

View File

@@ -0,0 +1,3 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,324 @@
import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model';
import type { ServiceProvider } from '@blocksuite/global/di';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type DocSnapshot,
type ExtensionType,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
nanoid,
type SliceSnapshot,
type ToBlockSnapshotPayload,
type ToDocSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import {
type AdapterContext,
AdapterFactoryIdentifier,
type TextBuffer,
} from '../types';
import {
type BlockPlainTextAdapterMatcher,
BlockPlainTextAdapterMatcherIdentifier,
} from './block-adapter';
import {
InlineDeltaToPlainTextAdapterMatcherIdentifier,
PlainTextDeltaConverter,
} from './delta-converter';
export type PlainText = string;
type PlainTextToSliceSnapshotPayload = {
file: PlainText;
assets?: AssetsManager;
workspaceId: string;
pageId: string;
};
export class PlainTextAdapter extends BaseAdapter<PlainText> {
deltaConverter: PlainTextDeltaConverter;
readonly blockMatchers: BlockPlainTextAdapterMatcher[];
constructor(job: Transformer, provider: ServiceProvider) {
super(job, provider);
const blockMatchers = Array.from(
provider.getAll(BlockPlainTextAdapterMatcherIdentifier).values()
);
const inlineDeltaToPlainTextAdapterMatchers = Array.from(
provider.getAll(InlineDeltaToPlainTextAdapterMatcherIdentifier).values()
);
this.blockMatchers = blockMatchers;
this.deltaConverter = new PlainTextDeltaConverter(
job.adapterConfigs,
inlineDeltaToPlainTextAdapterMatchers,
[]
);
}
private async _traverseSnapshot(
snapshot: BlockSnapshot
): Promise<{ plaintext: string }> {
const textBuffer: TextBuffer = {
content: '',
};
const walker = new ASTWalker<BlockSnapshot, TextBuffer>();
walker.setONodeTypeGuard(
(node): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer,
};
await matcher.fromBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.fromMatch(o)) {
const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
provider: this.provider,
textBuffer,
};
await matcher.fromBlockSnapshot.leave?.(o, adapterContext);
}
}
});
await walker.walkONode(snapshot);
return {
plaintext: textBuffer.content,
};
}
async fromBlockSnapshot({
snapshot,
}: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<PlainText>> {
const { plaintext } = await this._traverseSnapshot(snapshot);
return {
file: plaintext,
assetsIds: [],
};
}
async fromDocSnapshot({
snapshot,
assets,
}: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<PlainText>> {
let buffer = '';
if (snapshot.meta.title) {
buffer += `${snapshot.meta.title}\n\n`;
}
const { file, assetsIds } = await this.fromBlockSnapshot({
snapshot: snapshot.blocks,
assets,
});
buffer += file;
return {
file: buffer,
assetsIds,
};
}
async fromSliceSnapshot({
snapshot,
}: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<PlainText>> {
let buffer = '';
const sliceAssetsIds: string[] = [];
for (const contentSlice of snapshot.content) {
const { plaintext } = await this._traverseSnapshot(contentSlice);
buffer += plaintext;
}
const plaintext =
buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer;
return {
file: plaintext,
assetsIds: sliceAssetsIds,
};
}
toBlockSnapshot(payload: ToBlockSnapshotPayload<PlainText>): BlockSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
};
}
toDocSnapshot(payload: ToDocSnapshotPayload<PlainText>): DocSnapshot {
payload.file = payload.file.replaceAll('\r', '');
return {
type: 'page',
meta: {
id: nanoid(),
title: 'Untitled',
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Untitled',
},
],
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
{
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
},
],
},
};
}
toSliceSnapshot(
payload: PlainTextToSliceSnapshotPayload
): SliceSnapshot | null {
if (payload.file.trim().length === 0) {
return null;
}
payload.file = payload.file.replaceAll('\r', '');
const contentSlice = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: payload.file.split('\n').map((line): BlockSnapshot => {
return {
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: line,
},
],
},
},
children: [],
};
}),
} as BlockSnapshot;
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const PlainTextAdapterFactoryIdentifier =
AdapterFactoryIdentifier('PlainText');
export const PlainTextAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(PlainTextAdapterFactoryIdentifier, provider => ({
get: (job: Transformer) => new PlainTextAdapter(job, provider),
}));
},
};

View File

@@ -0,0 +1,124 @@
import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di';
import {
type AssetsManager,
type ASTWalker,
type ASTWalkerContext,
type BaseAdapter,
type BlockSnapshot,
BlockSnapshotSchema,
type NodeProps,
type Transformer,
} from '@blocksuite/store';
import type { DeltaASTConverter } from './delta-converter.js';
export const isBlockSnapshotNode = (node: unknown): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success;
export type TextBuffer = {
content: string;
};
export type AdapterContext<
ONode extends object,
TNode extends object = never,
TConverter extends DeltaASTConverter = DeltaASTConverter,
> = {
walker: ASTWalker<ONode, TNode>;
walkerContext: ASTWalkerContext<TNode>;
configs: Map<string, string>;
job: Transformer;
deltaConverter: TConverter;
textBuffer: TextBuffer;
provider?: ServiceProvider;
assets?: AssetsManager;
pageMap?: Map<string, string>;
updateAssetIds?: (assetsId: string) => void;
};
/**
* Defines the interface for adapting between different blocks and target formats.
* Used to convert blocks between a source format (TNode) and BlockSnapshot format.
*
* @template TNode - The source/target node type to convert from/to
* @template TConverter - The converter used for handling delta format conversions
*/
export type BlockAdapterMatcher<
TNode extends object = never,
TConverter extends DeltaASTConverter = DeltaASTConverter,
> = {
/** The block flavour identifier */
flavour: string;
/**
* Function to check if a target node matches this adapter
* @param o - The target node properties to check
* @returns true if this adapter can handle the node
*/
toMatch: (o: NodeProps<TNode>) => boolean;
/**
* Function to check if a BlockSnapshot matches this adapter
* @param o - The BlockSnapshot properties to check
* @returns true if this adapter can handle the snapshot
*/
fromMatch: (o: NodeProps<BlockSnapshot>) => boolean;
/**
* Handlers for converting from target format to BlockSnapshot
*/
toBlockSnapshot: {
/**
* Called when entering a target walker node during traversal
* @param o - The target node properties
* @param context - The adapter context
*/
enter?: (
o: NodeProps<TNode>,
context: AdapterContext<TNode, BlockSnapshot, TConverter>
) => void | Promise<void>;
/**
* Called when leaving a target walker node during traversal
* @param o - The target node properties
* @param context - The adapter context
*/
leave?: (
o: NodeProps<TNode>,
context: AdapterContext<TNode, BlockSnapshot, TConverter>
) => void | Promise<void>;
};
/**
* Handlers for converting from BlockSnapshot to target format
*/
fromBlockSnapshot: {
/**
* Called when entering a BlockSnapshot walker node during traversal
* @param o - The BlockSnapshot properties
* @param context - The adapter context
*/
enter?: (
o: NodeProps<BlockSnapshot>,
context: AdapterContext<BlockSnapshot, TNode, TConverter>
) => void | Promise<void>;
/**
* Called when leaving a BlockSnapshot walker node during traversal
* @param o - The BlockSnapshot properties
* @param context - The adapter context
*/
leave?: (
o: NodeProps<BlockSnapshot>,
context: AdapterContext<BlockSnapshot, TNode, TConverter>
) => void | Promise<void>;
};
};
export type AdapterFactory = {
// TODO(@chen): Make it return the specific adapter type
get: (job: Transformer) => BaseAdapter;
};
export const AdapterFactoryIdentifier =
createIdentifier<AdapterFactory>('AdapterFactory');

View File

@@ -0,0 +1,62 @@
import type { ServiceProvider } from '@blocksuite/global/di';
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
import type { AffineTextAttributes } from '../../types';
export type DeltaASTConverterOptions = {
trim?: boolean;
pre?: boolean;
pageMap?: Map<string, string>;
removeLastBr?: boolean;
};
export abstract class DeltaASTConverter<
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
AST = unknown,
> {
/**
* Convert AST format to delta format
*/
abstract astToDelta(
ast: AST,
options?: unknown
): DeltaInsert<TextAttributes>[];
/**
* Convert delta format to AST format
*/
abstract deltaToAST(
deltas: DeltaInsert<TextAttributes>[],
options?: unknown
): AST[];
}
export type InlineDeltaMatcher<TNode extends object = never> = {
name: keyof AffineTextAttributes | string;
match: (delta: DeltaInsert<AffineTextAttributes>) => boolean;
toAST: (
delta: DeltaInsert<AffineTextAttributes>,
context: {
configs: Map<string, string>;
current: TNode;
},
provider?: ServiceProvider
) => TNode;
};
export type ASTToDeltaMatcher<AST> = {
name: string;
match: (ast: AST) => boolean;
toDelta: (
ast: AST,
context: {
configs: Map<string, string>;
options: DeltaASTConverterOptions;
toDelta: (
ast: AST,
options?: DeltaASTConverterOptions
) => DeltaInsert<AffineTextAttributes>[];
htmlToDelta?: (html: string) => DeltaInsert<AffineTextAttributes>[];
}
) => DeltaInsert<AffineTextAttributes>[];
};

View File

@@ -0,0 +1,12 @@
import type { Element, Root, RootContentMap, Text } from 'hast';
export type HastUnionType<
K extends keyof RootContentMap,
V extends RootContentMap[K],
> = V;
export type HtmlAST =
| HastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
| Root;
export type InlineHtmlAST = Element | Text;

View File

@@ -0,0 +1,4 @@
export * from './adapter.js';
export * from './delta-converter.js';
export * from './hast.js';
export * from './preprocessor.js';

View File

@@ -0,0 +1,88 @@
import type { ServiceIdentifier, ServiceProvider } from '@blocksuite/global/di';
/**
* Level of preprocessing
* - doc: Process at to doc snapshot level
* - slice: Process at to slice snapshot level
* - block: Process at to block snapshot level
*/
export type PreprocessLevel = 'doc' | 'slice' | 'block';
/**
* Interface for adapter preprocessor
* @template T Type of content to process, defaults to string
*/
export type AdapterPreprocessor<T = string> = {
/**
* Unique name of the preprocessor
*/
name: string;
/**
* Levels this preprocessor supports
*/
levels: PreprocessLevel[];
/**
* Process the content
* @param content Content to process
* @returns Processed content
*/
preprocess: (content: T) => T;
};
/**
* Manager class for handling preprocessors
* @template T Type of content to process
* @template P Type of preprocessor
*/
export abstract class PreprocessorManager<T, P extends AdapterPreprocessor<T>> {
protected readonly preprocessors: Map<PreprocessLevel, Set<P>>;
constructor(
protected readonly provider: ServiceProvider,
protected readonly identifier: ServiceIdentifier<P>
) {
this.preprocessors = new Map();
// Initialize Sets for each level
this.preprocessors.set('doc', new Set());
this.preprocessors.set('slice', new Set());
this.preprocessors.set('block', new Set());
// Register all preprocessors from provider
this.initializePreprocessors();
}
/**
* Initialize preprocessors from provider
*/
private initializePreprocessors(): void {
const preprocessors = Array.from(
this.provider.getAll(this.identifier).values()
);
for (const preprocessor of preprocessors) {
for (const level of preprocessor.levels) {
const levelSet = this.preprocessors.get(level);
if (levelSet) {
levelSet.add(preprocessor);
}
}
}
}
/**
* Pre process content at specified level
* @param level Level to process at
* @param content Content to process
* @returns Processed content
*/
process(level: PreprocessLevel, content: T): T {
const processors = this.preprocessors.get(level) ?? new Set();
return Array.from(processors).reduce(
(result, preprocessor) => preprocessor.preprocess(result),
content
);
}
}

View File

@@ -0,0 +1,37 @@
const fetchImage = async (url: string, init?: RequestInit, proxy?: string) => {
try {
if (!proxy) {
return await fetch(url, init);
}
if (url.startsWith('blob:')) {
return await fetch(url, init);
}
if (url.startsWith('data:')) {
return await fetch(url, init);
}
if (url.startsWith(window.location.origin)) {
return await fetch(url, init);
}
return await fetch(proxy + '?url=' + encodeURIComponent(url), init)
.then(res => {
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res;
})
.catch(() => fetch(url, init));
} catch (error) {
console.warn('Error fetching image:', error);
return null;
}
};
const fetchable = (url: string) =>
url.startsWith('http:') ||
url.startsWith('https:') ||
url.startsWith('data:');
export const FetchUtils = {
fetchImage,
fetchable,
};

View File

@@ -0,0 +1,73 @@
function safeDecodePathReference(path: string): string {
try {
return decodeURIComponent(path);
} catch {
return path;
}
}
export function normalizeFilePathReference(path: string): string {
return safeDecodePathReference(path)
.trim()
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '')
.replace(/\/+/g, '/');
}
/**
* Normalizes a relative path by resolving all relative path segments
* @param basePath The base path (markdown file's directory)
* @param relativePath The relative path to normalize
* @returns The full path
*/
function resolveFullPath(basePath: string, relativePath: string): string {
// Split both paths into segments
const baseSegments = basePath.split('/').filter(Boolean);
const relativeSegments = relativePath.split('/').filter(Boolean);
// Handle each segment of the relative path
for (const segment of relativeSegments) {
if (segment === '.') {
// Current directory, do nothing
continue;
} else if (segment === '..') {
// Parent directory, remove last segment from base
if (baseSegments.length > 0) {
baseSegments.pop();
}
} else {
// Regular directory or file, add to base
baseSegments.push(segment);
}
}
// Join segments back into a path
return baseSegments.join('/');
}
/**
* Get the full path of the reference image from the file path and the image reference
* @param filePath The full path of the file containing the image reference
* @param imageReference The image reference from the file (can be relative or absolute path)
* @returns The full path of the reference image
*/
export function getImageFullPath(
filePath: string,
imageReference: string
): string {
// Decode the image reference in case it contains URL-encoded characters
const decodedReference = safeDecodePathReference(imageReference);
// Get the directory of the file path
const markdownDir = filePath.substring(0, filePath.lastIndexOf('/'));
// Check if the image reference is a relative path
const isRelative = !decodedReference.startsWith('/');
// If the image reference is a relative path, resolve it against the file path's directory
// Otherwise, it is an absolute path, remove the leading slash if it exists
return isRelative
? resolveFullPath(markdownDir, decodedReference)
: decodedReference.replace(/^\//, '');
}

View File

@@ -0,0 +1,295 @@
import type { Element, ElementContent, Text } from 'hast';
import type { HtmlAST } from '../types/hast.js';
// Block elements that html adapter supports
const blockElements = [
'div',
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'pre',
];
const blockElementsSet = new Set(blockElements);
// Phrasing content
const inlineElements = [
'a',
'abbr',
'audio',
'b',
'bdi',
'bdo',
'br',
'button',
'canvas',
'cite',
'code',
'data',
'datalist',
'del',
'dfn',
'em',
'embed',
'i',
// 'iframe' is not included because it needs special handling
// 'img' is not included because it needs special handling
'input',
'ins',
'kbd',
'label',
'link',
'map',
'mark',
'math',
'meta',
'meter',
'noscript',
'object',
'output',
'picture',
'progress',
'q',
'ruby',
's',
'samp',
'script',
'select',
'slot',
'small',
'span',
'strong',
'sub',
'sup',
'svg',
'template',
'textarea',
'time',
'u',
'var',
'video',
'wbr',
];
const inlineElementsSet = new Set(inlineElements);
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const getTextContent = (ast: HtmlAST | undefined, defaultStr = ''): string => {
if (!ast) {
return defaultStr;
}
switch (ast.type) {
case 'text': {
return ast.value.replace(/\s+/g, ' ');
}
case 'element': {
switch (ast.tagName) {
case 'br': {
return '\n';
}
}
return ast.children.map(child => getTextContent(child)).join('');
}
}
return defaultStr;
};
const getElementChildren = (ast: HtmlAST | undefined): Element[] => {
if (!ast) {
return [];
}
if (ast.type === 'element') {
return ast.children.filter(child => child.type === 'element') as Element[];
}
return [];
};
const getTextChildren = (ast: HtmlAST | undefined): Text[] => {
if (!ast) {
return [];
}
if (ast.type === 'element') {
return ast.children.filter(child => child.type === 'text') as Text[];
}
return [];
};
const getTextChildrenOnlyAst = (ast: Element): Element => {
return {
...ast,
children: getTextChildren(ast),
};
};
const isTagBlock = (tagName: string): boolean => {
return blockElementsSet.has(tagName);
};
const isTagInline = (tagName: string): boolean => {
return inlineElementsSet.has(tagName);
};
const isElementInline = (element: Element): boolean => {
return (
isTagInline(element.tagName) ||
// Inline elements
!!(
typeof element.properties?.style === 'string' &&
element.properties.style.match(/display:\s*inline/)
)
);
};
const getInlineElementsAndText = (ast: Element): (Element | Text)[] => {
if (!ast || !ast.children) {
return [];
}
return ast.children.filter((child): child is Element | Text => {
if (child.type === 'text') {
return true;
}
if (child.type === 'element' && child.tagName && isElementInline(child)) {
return true;
}
return false;
});
};
const getInlineOnlyElementAST = (ast: Element): Element => {
return {
...ast,
children: getInlineElementsAndText(ast),
};
};
const querySelectorTag = (
ast: HtmlAST,
tagName: string
): Element | undefined => {
if (ast.type === 'element') {
if (ast.tagName === tagName) {
return ast;
}
for (const child of ast.children) {
const result = querySelectorTag(child, tagName);
if (result) {
return result;
}
}
}
return undefined;
};
const querySelectorClass = (
ast: HtmlAST,
className: string
): Element | undefined => {
if (ast.type === 'element') {
if (
Array.isArray(ast.properties?.className) &&
ast.properties.className.includes(className)
) {
return ast;
}
for (const child of ast.children) {
const result = querySelectorClass(child, className);
if (result) {
return result;
}
}
}
return undefined;
};
const querySelectorId = (ast: HtmlAST, id: string): Element | undefined => {
if (ast.type === 'element') {
if (ast.properties.id === id) {
return ast;
}
for (const child of ast.children) {
const result = querySelectorId(child, id);
if (result) {
return result;
}
}
}
return undefined;
};
const querySelector = (ast: HtmlAST, selector: string): Element | undefined => {
if (ast.type === 'root') {
for (const child of ast.children) {
const result = querySelector(child, selector);
if (result) {
return result;
}
}
} else if (ast.type === 'element') {
if (selector.startsWith('.')) {
return querySelectorClass(ast, selector.slice(1));
} else if (selector.startsWith('#')) {
return querySelectorId(ast, selector.slice(1));
} else {
return querySelectorTag(ast, selector);
}
}
return undefined;
};
const flatNodes = (
ast: HtmlAST,
expression: (tagName: string) => boolean
): HtmlAST => {
if (ast.type === 'element') {
const children = ast.children.map(child => flatNodes(child, expression));
return {
...ast,
children: children.flatMap(child => {
if (child.type === 'element' && expression(child.tagName)) {
return child.children;
}
return child;
}) as ElementContent[],
};
}
return ast;
};
// Check if it is a paragraph like element
// https://html.spec.whatwg.org/#paragraph
const isParagraphLike = (node: Element): boolean => {
// Flex container
return (
(typeof node.properties?.style === 'string' &&
node.properties.style.match(/display:\s*flex/) !== null) ||
getElementChildren(node).every(child => isElementInline(child))
);
};
export const HastUtils = {
isElement,
getTextContent,
getElementChildren,
getTextChildren,
getTextChildrenOnlyAst,
getInlineOnlyElementAST,
querySelector,
flatNodes,
isParagraphLike,
isTagBlock,
isTagInline,
isElementInline,
};

View File

@@ -0,0 +1,4 @@
export * from './fetch.js';
export * from './file-path.js';
export * from './hast.js';
export * from './text.js';

View File

@@ -0,0 +1,96 @@
import type { ReferenceParams } from '@blocksuite/affine-model';
import type { DeltaInsert } from '@blocksuite/store';
import isEqual from 'lodash-es/isEqual';
const mergeDeltas = (
acc: DeltaInsert[],
cur: DeltaInsert,
options: { force?: boolean } = { force: false }
) => {
if (acc.length === 0) {
return [cur];
}
const last = acc[acc.length - 1];
if (options?.force) {
last.insert = last.insert + cur.insert;
last.attributes = Object.create(null);
return acc;
} else if (
typeof last.insert === 'string' &&
typeof cur.insert === 'string' &&
(isEqual(last.attributes, cur.attributes) ||
(last.attributes === undefined && cur.attributes === undefined))
) {
last.insert += cur.insert;
return acc;
}
return [...acc, cur];
};
const isNullish = (value: unknown) => value === null || value === undefined;
const createText = (s: string) => {
return {
'$blocksuite:internal:text$': true,
delta: s.length === 0 ? [] : [{ insert: s }],
};
};
const isText = (o: unknown) => {
if (
typeof o === 'object' &&
o !== null &&
'$blocksuite:internal:text$' in o
) {
return o['$blocksuite:internal:text$'] === true;
}
return false;
};
function toURLSearchParams(
params?: Partial<Record<string, string | string[]>>
) {
if (!params) return;
const items = Object.entries(params)
.filter(([_, v]) => !isNullish(v))
.filter(([_, v]) => {
if (typeof v === 'string') {
return v.length > 0;
}
if (Array.isArray(v)) {
return v.length > 0;
}
return false;
})
.map(([k, v]) => [k, Array.isArray(v) ? v.filter(v => v.length) : v]) as [
string,
string | string[],
][];
return new URLSearchParams(
items
.filter(([_, v]) => v.length)
.map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v])
);
}
function generateDocUrl(
docBaseUrl: string,
pageId: string,
params: ReferenceParams
) {
const search = toURLSearchParams(params);
const query = search?.size ? `?${search.toString()}` : '';
const url = docBaseUrl ? `${docBaseUrl}/${pageId}${query}` : '';
return url;
}
export const AdapterTextUtils = {
mergeDeltas,
isNullish,
createText,
isText,
toURLSearchParams,
generateDocUrl,
};

View File

@@ -0,0 +1,4 @@
# @blocksuite/affine-shared/commands
This package contains the common commands used in the affine blocks.
Keep in mind that you should not put commands that are specific to a single kind of block here.

View File

@@ -0,0 +1,33 @@
import type { BlockComponent, Command } from '@blocksuite/std';
export const getBlockIndexCommand: Command<
{
currentSelectionPath?: string;
path?: string;
},
{
blockIndex?: number;
parentBlock?: BlockComponent;
}
> = (ctx, next) => {
const path = ctx.path ?? ctx.currentSelectionPath;
if (!path) {
console.error(
'`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.'
);
return;
}
const parentModel = ctx.std.store.getParent(path);
if (!parentModel) return;
const parentBlock = ctx.std.view.getBlock(parentModel.id);
if (!parentBlock) return;
const blockIndex = parentBlock.childBlocks.findIndex(x => x.blockId === path);
next({
blockIndex,
parentBlock,
});
};

View File

@@ -0,0 +1,65 @@
import type { Command } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
type Role = 'content' | 'hub';
/**
* Get the first block with specified roles and flavours in the document
*
* @param ctx - Command context
* @param ctx.root - The root note block model
* @param ctx.role - The roles to match, can be string or string array. If not provided, default to all supported roles.
* @param ctx.flavour - The flavours to match, can be string or string array. If not provided, match any flavour.
* @param next - Next handler function
* @returns The first block model matched or null
*/
export const getFirstBlockCommand: Command<
{
root?: BlockModel;
role?: Role | Role[];
flavour?: string | string[];
},
{
firstBlock: BlockModel | null;
}
> = (ctx, next) => {
const root = ctx.root || ctx.std.host.store.root;
if (!root) {
next({
firstBlock: null,
});
return;
}
const defaultRoles = ['content', 'hub'];
const rolesToMatch = ctx.role
? Array.isArray(ctx.role)
? ctx.role
: [ctx.role]
: defaultRoles;
const flavoursToMatch = ctx.flavour
? Array.isArray(ctx.flavour)
? ctx.flavour
: [ctx.flavour]
: null;
for (const child of root.children) {
const roleMatches = rolesToMatch.includes(child.role);
const flavourMatches =
!flavoursToMatch || flavoursToMatch.includes(child.flavour);
if (roleMatches && flavourMatches) {
next({
firstBlock: child,
});
return;
}
}
next({
firstBlock: null,
});
};

View File

@@ -0,0 +1,67 @@
import type { Command } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
type Role = 'content' | 'hub';
/**
* Get the last block with specified roles and flavours in the document
*
* @param ctx - Command context
* @param ctx.root - The root note block model
* @param ctx.role - The roles to match, can be string or string array. If not provided, default to all supported roles.
* @param ctx.flavour - The flavours to match, can be string or string array. If not provided, match any flavour.
* @param next - Next handler function
* @returns The last block model matched or null
*/
export const getLastBlockCommand: Command<
{
root?: BlockModel;
role?: Role | Role[];
flavour?: string | string[];
},
{
lastBlock: BlockModel | null;
}
> = (ctx, next) => {
const root = ctx.root || ctx.std.host.store.root;
if (!root) {
next({
lastBlock: null,
});
return;
}
const defaultRoles = ['content', 'hub'];
const rolesToMatch = ctx.role
? Array.isArray(ctx.role)
? ctx.role
: [ctx.role]
: defaultRoles;
const flavoursToMatch = ctx.flavour
? Array.isArray(ctx.flavour)
? ctx.flavour
: [ctx.flavour]
: null;
const children = root.children;
for (let i = children.length - 1; i >= 0; i--) {
const roleMatches = rolesToMatch.includes(children[i].role);
const flavourMatches =
!flavoursToMatch || flavoursToMatch.includes(children[i].flavour);
// Both role and flavour must match
if (roleMatches && flavourMatches) {
next({
lastBlock: children[i],
});
return;
}
}
next({
lastBlock: null,
});
};

View File

@@ -0,0 +1,36 @@
import type { BlockComponent, BlockStdScope, Command } from '@blocksuite/std';
import { getNextContentBlock } from '../../utils/index.js';
function getNextBlock(std: BlockStdScope, path: string) {
const view = std.view;
const model = std.store.getBlock(path)?.model;
if (!model) return null;
const nextModel = getNextContentBlock(std.host, model);
if (!nextModel) return null;
return view.getBlock(nextModel.id);
}
export const getNextBlockCommand: Command<
{
currentSelectionPath?: string;
path?: string;
},
{
nextBlock?: BlockComponent;
}
> = (ctx, next) => {
const path = ctx.path ?? ctx.currentSelectionPath;
if (!path) {
console.error(
'`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.'
);
return;
}
const nextBlock = getNextBlock(ctx.std, path);
if (nextBlock) {
next({ nextBlock });
}
};

View File

@@ -0,0 +1,37 @@
import type { BlockComponent, BlockStdScope, Command } from '@blocksuite/std';
import { getPrevContentBlock } from '../../utils/index.js';
function getPrevBlock(std: BlockStdScope, path: string) {
const view = std.view;
const model = std.store.getBlock(path)?.model;
if (!model) return null;
const prevModel = getPrevContentBlock(std.host, model);
if (!prevModel) return null;
return view.getBlock(prevModel.id);
}
export const getPrevBlockCommand: Command<
{
currentSelectionPath?: string;
path?: string;
},
{
prevBlock?: BlockComponent;
}
> = (ctx, next) => {
const path = ctx.path ?? ctx.currentSelectionPath;
if (!path) {
console.error(
'`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.'
);
return;
}
const prevBlock = getPrevBlock(ctx.std, path);
if (prevBlock) {
next({ prevBlock });
}
};

View File

@@ -0,0 +1,165 @@
import type {
BlockSelection,
Command,
SurfaceSelection,
TextSelection,
} from '@blocksuite/std';
import { BlockComponent } from '@blocksuite/std';
import type { BlockModel, RoleType } from '@blocksuite/store';
import type { ImageSelection } from '../../selection/index.js';
export const getSelectedBlocksCommand: Command<
{
currentTextSelection?: TextSelection;
currentBlockSelections?: BlockSelection[];
currentImageSelections?: ImageSelection[];
currentSurfaceSelection?: SurfaceSelection;
textSelection?: TextSelection;
blockSelections?: BlockSelection[];
imageSelections?: ImageSelection[];
surfaceSelection?: SurfaceSelection;
filter?: (el: BlockComponent) => boolean;
types?: Array<'image' | 'text' | 'block' | 'surface'>;
roles?: RoleType[];
mode?: 'all' | 'flat' | 'highest';
},
{
selectedBlocks: BlockComponent[];
}
> = (ctx, next) => {
const {
types = ['block', 'text', 'image', 'surface'],
roles = ['content'],
mode = 'flat',
} = ctx;
let dirtyResult: BlockComponent[] = [];
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
if (types.includes('text') && textSelection) {
try {
const range = ctx.std.range.textSelectionToRange(textSelection);
if (!range) return;
const selectedBlocks = ctx.std.range.getSelectedBlockComponentsByRange(
range,
{
match: (el: BlockComponent) => roles.includes(el.model.role),
mode,
}
);
dirtyResult.push(...selectedBlocks);
} catch {
return;
}
}
const blockSelections = ctx.blockSelections ?? ctx.currentBlockSelections;
if (types.includes('block') && blockSelections) {
const viewStore = ctx.std.view;
const doc = ctx.std.store;
const selectedBlockComponents = blockSelections.flatMap(selection => {
const el = viewStore.getBlock(selection.blockId);
if (!el) {
return [];
}
const blocks: BlockComponent[] = [el];
let selectionPath = selection.blockId;
if (mode === 'all') {
let parent = null;
do {
parent = doc.getParent(selectionPath);
if (!parent) {
break;
}
const view = parent;
if (
view instanceof BlockComponent &&
!roles.includes(view.model.role)
) {
break;
}
selectionPath = parent.id;
} while (parent);
parent = viewStore.getBlock(selectionPath);
if (parent) {
blocks.push(parent);
}
}
if (['all', 'flat'].includes(mode)) {
viewStore.walkThrough(node => {
const view = node;
if (!(view instanceof BlockComponent)) {
return true;
}
if (roles.includes(view.model.role)) {
blocks.push(view);
}
return;
}, selectionPath);
}
return blocks;
});
dirtyResult.push(...selectedBlockComponents);
}
const imageSelections = ctx.imageSelections ?? ctx.currentImageSelections;
if (types.includes('image') && imageSelections) {
const viewStore = ctx.std.view;
const selectedBlocks = imageSelections
.map(selection => {
const el = viewStore.getBlock(selection.blockId);
return el;
})
.filter((el): el is BlockComponent => Boolean(el));
dirtyResult.push(...selectedBlocks);
}
const surfaceSelection = ctx.surfaceSelection ?? ctx.currentSurfaceSelection;
if (types.includes('surface') && surfaceSelection) {
const viewStore = ctx.std.view;
const selectedBlocks = surfaceSelection.elements
.map(id => viewStore.getBlock(id))
.filter(block => !!block);
dirtyResult.push(...selectedBlocks);
}
if (ctx.filter) {
dirtyResult = dirtyResult.filter(ctx.filter);
}
const seen = new Set<BlockComponent>();
// remove duplicate elements
const result: BlockComponent[] = dirtyResult.filter(el => {
if (seen.has(el)) return false;
seen.add(el);
return true;
});
if (result.length > 1) {
const modelOrder = new Map<string, number>();
const visit = (model: BlockModel) => {
modelOrder.set(model.id, modelOrder.size);
model.children.forEach(visit);
};
const root = ctx.std.store.root;
if (root) {
visit(root);
}
// sort by model tree position, which is the order used for paste/export
result.sort(
(a, b) =>
(modelOrder.get(a.blockId) ?? Number.MAX_SAFE_INTEGER) -
(modelOrder.get(b.blockId) ?? Number.MAX_SAFE_INTEGER)
);
}
if (result.length === 0) return;
next({
selectedBlocks: result,
});
};

View File

@@ -0,0 +1,6 @@
export { getBlockIndexCommand } from './get-block-index.js';
export { getFirstBlockCommand } from './get-first-content-block.js';
export { getLastBlockCommand } from './get-last-content-block.js';
export { getNextBlockCommand } from './get-next-block.js';
export { getPrevBlockCommand } from './get-prev-block.js';
export { getSelectedBlocksCommand } from './get-selected-blocks.js';

View File

@@ -0,0 +1,5 @@
// This is a dev-only file to make other packages use commands from this package.
export type * from './block-crud/index.js';
export type * from './model-crud/index.js';
export type * from './selection/index.js';

View File

@@ -0,0 +1,29 @@
export {
getBlockIndexCommand,
getFirstBlockCommand,
getLastBlockCommand,
getNextBlockCommand,
getPrevBlockCommand,
getSelectedBlocksCommand,
} from './block-crud/index.js';
export {
clearAndSelectFirstModelCommand,
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
getSelectedModelsCommand,
replaceSelectedTextWithBlocksCommand,
retainFirstModelCommand,
} from './model-crud/index.js';
export {
focusBlockEnd,
focusBlockStart,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getRangeRects,
getSelectionRectsCommand,
getTextSelectionCommand,
isNothingSelectedCommand,
type SelectionRect,
} from './selection/index.js';

View File

@@ -0,0 +1,33 @@
import { type Command, TextSelection } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
export const clearAndSelectFirstModelCommand: Command<{
selectedModels?: BlockModel[];
}> = (ctx, next) => {
const models = ctx.selectedModels;
if (!models) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
if (models.length > 0) {
const firstModel = models[0];
if (firstModel.text) {
firstModel.text.clear();
const selection = ctx.std.selection.create(TextSelection, {
from: {
blockId: firstModel.id,
index: 0,
length: 0,
},
to: null,
});
ctx.std.selection.setGroup('note', [selection]);
}
}
return next();
};

View File

@@ -0,0 +1,25 @@
import type { Command } from '@blocksuite/std';
import { type BlockModel, type DraftModel, Slice } from '@blocksuite/store';
export const copySelectedModelsCommand: Command<{
draftedModels?: Promise<DraftModel<BlockModel<object>>[]>;
onCopy?: () => void;
}> = (ctx, next) => {
const models = ctx.draftedModels;
if (!models) {
console.error(
'`draftedModels` is required, you need to use `draftSelectedModels` command before adding this command to the pipeline.'
);
return;
}
models
.then(models => {
const slice = Slice.fromModels(ctx.std.store, models);
return ctx.std.clipboard.copy(slice);
})
.then(() => ctx.onCopy?.())
.catch(console.error);
return next();
};

View File

@@ -0,0 +1,21 @@
import type { Command } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
export const deleteSelectedModelsCommand: Command<{
selectedModels?: BlockModel[];
}> = (ctx, next) => {
const models = ctx.selectedModels;
if (!models) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
models.forEach(model => {
ctx.std.store.deleteBlock(model);
});
return next();
};

View File

@@ -0,0 +1,50 @@
import type { Command } from '@blocksuite/std';
import {
type BlockModel,
type DraftModel,
toDraftModel,
} from '@blocksuite/store';
export const draftSelectedModelsCommand: Command<
{
selectedModels?: BlockModel[];
},
{
draftedModels: Promise<DraftModel<BlockModel<object>>[]>;
}
> = (ctx, next) => {
const models = ctx.selectedModels;
if (!models) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
const draftedModelsPromise = new Promise<DraftModel[]>(resolve => {
const draftedModels = models.map(toDraftModel);
const modelMap = new Map(draftedModels.map(model => [model.id, model]));
const traverse = (model: DraftModel) => {
const isDatabase = model.flavour === 'affine:database';
const children = isDatabase
? model.children
: model.children.filter(child => modelMap.has(child.id));
children.forEach(child => {
modelMap.delete(child.id);
traverse(child);
});
model.children = children;
};
draftedModels.forEach(traverse);
const remainingDraftedModels = Array.from(modelMap.values());
resolve(remainingDraftedModels);
});
return next({ draftedModels: draftedModelsPromise });
};

View File

@@ -0,0 +1,55 @@
import type { Command } from '@blocksuite/std';
import { type BlockModel, Slice } from '@blocksuite/store';
import { draftSelectedModelsCommand } from './draft-selected-models';
/**
* @description Duplicate the selected models
* @param selectedModels - The selected models for duplicate
* @param parentModel - The parent model of duplicated models, default is the last selected model's parent model
* @param index - The index of the duplicated models in the parent model, default is the last selected model's index + 1
*/
export const duplicateSelectedModelsCommand: Command<{
selectedModels?: BlockModel[];
parentModel?: BlockModel;
index?: number;
}> = (ctx, next) => {
const { std, selectedModels } = ctx;
let { parentModel, index } = ctx;
if (!selectedModels) return;
const [_, { draftedModels }] = ctx.std.command.exec(
draftSelectedModelsCommand,
{ selectedModels }
);
if (!draftedModels) return;
if (parentModel) {
if (
index === undefined ||
index < 0 ||
index >= parentModel.children.length
) {
index = parentModel.children.length;
}
} else {
const model = selectedModels[selectedModels.length - 1];
parentModel = std.store.getParent(model.id) ?? undefined;
if (!parentModel) return;
index = parentModel.children.findIndex(x => x.id === model.id) + 1;
}
draftedModels
.then(models => {
const slice = Slice.fromModels(std.store, models);
return std.clipboard.duplicateSlice(
slice,
std.store,
parentModel.id,
index
);
})
.catch(console.error);
return next();
};

View File

@@ -0,0 +1,66 @@
import type { Command } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { getSelectedBlocksCommand } from '../block-crud/get-selected-blocks';
import {
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSurfaceSelectionCommand,
getTextSelectionCommand,
} from '../selection';
/**
* Retrieves the selected models based on the provided selection types and mode.
*
* @param ctx - The command context, which includes the types of selections to be retrieved and the mode of the selection.
* @param ctx.types - The selection types to be retrieved. Can be an array of 'block', 'text', 'image', or 'surface'.
* @param ctx.mode - The mode of the selection. Can be 'all', 'flat', or 'highest'.
* @example
* // Assuming `commandContext` is an instance of the command context
* getSelectedModelsCommand(commandContext, (result) => {
* console.log(result.selectedModels);
* });
*
* // Example selection:
* // aaa
* // b[bb
* // ccc
* // ddd
* // ee]e
*
* // all mode: [aaa, bbb, ccc, ddd, eee]
* // flat mode: [bbb, ccc, ddd, eee]
* // highest mode: [bbb, ddd]
*
* // The match function will be evaluated before filtering using mode
* @param next - The next function to be called.
* @returns An object containing the selected models as an array of BlockModel instances.
*/
export const getSelectedModelsCommand: Command<
{
types?: Array<'image' | 'text' | 'block' | 'surface'>;
mode?: 'all' | 'flat' | 'highest';
},
{
selectedModels: BlockModel[];
}
> = (ctx, next) => {
const types = ctx.types ?? ['block', 'text', 'image', 'surface'];
const mode = ctx.mode ?? 'flat';
const selectedModels: BlockModel[] = [];
ctx.std.command
.chain()
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
chain.pipe(getSurfaceSelectionCommand),
])
.pipe(getSelectedBlocksCommand, { types, mode })
.pipe(ctx => {
selectedModels.push(...ctx.selectedBlocks.map(el => el.model));
})
.run();
next({ selectedModels });
};

View File

@@ -0,0 +1,8 @@
export { clearAndSelectFirstModelCommand } from './clear-and-select-first-model.js';
export { copySelectedModelsCommand } from './copy-selected-models.js';
export { deleteSelectedModelsCommand } from './delete-selected-models.js';
export { draftSelectedModelsCommand } from './draft-selected-models.js';
export { duplicateSelectedModelsCommand } from './duplicate-selected-model.js';
export { getSelectedModelsCommand } from './get-selected-models.js';
export { replaceSelectedTextWithBlocksCommand } from './replace-selected-text-with-blocks.js';
export { retainFirstModelCommand } from './retain-first-model.js';

View File

@@ -0,0 +1,399 @@
import type { BlockModel, Store } from '@blocksuite/affine/store';
import type { Command, TextSelection } from '@blocksuite/std';
import { match } from 'ts-pattern';
import { getBlockProps } from '../../utils';
/**
* Determines if blocks can be merged based on their types
* - Paragraphs can always be merged to
* - Other blocks can only be merged with blocks of the same type
* @FIXME: decouple the mergeable block types from the command
*/
const canMergeBlocks = (blockA: BlockModel, blockB: BlockModel): boolean => {
// Paragraphs can always be merged to
if (blockB.flavour === 'affine:paragraph') {
return true;
}
// Other blocks can only be merged with blocks of the same type
return blockA.flavour === blockB.flavour;
};
/**
* Check if a block is mergeable in general
* @FIXME: decouple the mergeable block types from the command
*/
const isMergableBlock = (block: BlockModel): boolean => {
// Blocks that can potentially be merged
const mergableTypes = ['affine:paragraph', 'affine:list'];
return mergableTypes.includes(block.flavour);
};
type SnapshotPattern = {
multiple: boolean;
canMergeWithStart?: boolean;
canMergeWithEnd?: boolean;
};
const getBlocksPattern = (
blocks: BlockModel[],
startBlockModel: BlockModel,
endBlockModel?: BlockModel
): SnapshotPattern => {
const firstBlock = blocks[0];
const lastBlock = blocks[blocks.length - 1];
const isFirstMergable = isMergableBlock(firstBlock);
const isLastMergable = isMergableBlock(lastBlock);
return {
multiple: blocks.length > 1,
canMergeWithStart:
isFirstMergable && canMergeBlocks(startBlockModel, firstBlock),
canMergeWithEnd:
isLastMergable && endBlockModel
? canMergeBlocks(endBlockModel, lastBlock)
: false,
};
};
const mergeText = (
targetModel: BlockModel,
sourceBlock: BlockModel,
offset: number
) => {
if (targetModel.text && sourceBlock.text) {
const sourceText = sourceBlock.text.toString();
if (sourceText.length > 0) {
targetModel.text.insert(sourceText, offset);
}
}
};
const splitParagraph = (
doc: any,
parent: any,
blockModel: BlockModel,
index: number,
splitOffset: number
): BlockModel => {
// Create a new block of the same type as the original
const newBlockId = doc.addBlock(blockModel.flavour, {}, parent, index + 1);
const nextBlock = doc.getBlock(newBlockId);
if (nextBlock?.model.text && blockModel.text) {
const textToMove = blockModel.text.toString().slice(splitOffset);
nextBlock.model.text.insert(textToMove, 0);
blockModel.text.delete(splitOffset, blockModel.text.length - splitOffset);
}
return nextBlock.model;
};
const getSelectedBlocks = (
doc: Store,
textSelection: TextSelection
): BlockModel[] | null => {
const selectedBlocks: BlockModel[] = [];
const fromBlock = doc.getBlock(textSelection.from.blockId)?.model;
if (!fromBlock) return null;
selectedBlocks.push(fromBlock);
// If the selection spans multiple blocks, add the blocks in between
if (textSelection.to) {
const toBlock = doc.getBlock(textSelection.to.blockId)?.model;
if (!toBlock) return null;
if (fromBlock.id !== toBlock.id) {
let currentBlock = fromBlock;
while (currentBlock.id !== toBlock.id) {
const nextBlock = doc.getNext(currentBlock);
if (!nextBlock) break;
selectedBlocks.push(nextBlock);
currentBlock = nextBlock;
}
}
}
return selectedBlocks;
};
const deleteSelectedText = (doc: Store, textSelection: TextSelection) => {
const selectedBlocks = getSelectedBlocks(doc, textSelection);
if (!selectedBlocks || selectedBlocks.length === 0) return null;
const firstBlock = selectedBlocks[0];
const lastBlock = selectedBlocks[selectedBlocks.length - 1];
const startOffset = textSelection.from.index;
if (textSelection.to) {
// Delete text from startOffset to the end in the first block
if (firstBlock.text) {
firstBlock.text.delete(startOffset, firstBlock.text.length - startOffset);
}
// Delete text from the beginning to endOffset in the last block
if (lastBlock.text) {
lastBlock.text.delete(textSelection.to.index, textSelection.to.length);
}
// Merge first block and last block
if (firstBlock.text && lastBlock.text) {
firstBlock.text.insert(lastBlock.text.toString(), startOffset);
}
// Delete the blocks in between
selectedBlocks.slice(1).forEach(block => {
doc.deleteBlock(block);
});
} else {
// Single block selection case
if (firstBlock.text) {
firstBlock.text.delete(startOffset, textSelection.from.length);
}
}
return { startBlockModel: firstBlock, endBlockModel: lastBlock, startOffset };
};
const addBlocks = (
doc: any,
blocks: BlockModel[],
parent: any,
from: number
) => {
blocks.forEach((block, index) => {
const blockProps = {
...getBlockProps(block),
text: block.text?.clone(),
children: block.children,
};
doc.addBlock(block.flavour, blockProps, parent, from + index);
});
};
/**
* Replace the selected text with the given blocks
*
* @warning This command is currently being optimized, please do not use it.
* @param ctx
* @param next
* @returns
*/
export const replaceSelectedTextWithBlocksCommand: Command<{
textSelection: TextSelection;
blocks: BlockModel[];
}> = (ctx, next) => {
const { textSelection, blocks, std } = ctx;
const doc = std.host.store;
// Delete selected text and get startOffset
const result = deleteSelectedText(doc, textSelection);
if (!result) return next();
const { startBlockModel, endBlockModel, startOffset } = result;
const parent = doc.getParent(startBlockModel.id);
if (!parent) return next();
const pattern = getBlocksPattern(blocks, startBlockModel, endBlockModel);
const startIndex = parent.children.findIndex(
x => x.id === startBlockModel.id
);
match(pattern)
.with({ multiple: false, canMergeWithStart: true }, () => {
/**
* Case: Single block that can merge with start block
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <paragraph>111</paragraph>,
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel111ld</paragraph>
* </doc>
* );
* ```
*/
mergeText(startBlockModel, blocks[0], startOffset);
})
.with(
{
multiple: true,
canMergeWithStart: true,
canMergeWithEnd: true,
},
() => {
/**
* Case: Both first and last blocks are mergable with start and end blocks
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <paragraph>111</paragraph>,
* <code />
* <code />
* <paragraph>222</paragraph>,
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel111</paragraph>
* <code />
* <code />
* <paragraph>222ld</paragraph>
* </doc>
* );
*/
const nextBlockModel = splitParagraph(
doc,
parent,
startBlockModel,
startIndex,
startOffset
);
mergeText(startBlockModel, blocks[0], startOffset);
mergeText(nextBlockModel, blocks[blocks.length - 1], 0);
const restBlocks = blocks.slice(1, -1);
if (restBlocks.length > 0) {
addBlocks(doc, restBlocks, parent, startIndex + 1);
}
}
)
.with(
{
multiple: true,
canMergeWithStart: true,
canMergeWithEnd: false,
},
() => {
/**
* Case: First block is mergable with start block, but last block isn't with end block
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <paragraph>111</paragraph>,
* <code />
* <code />
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel111</paragraph>
* <code />
* <code />
* <paragraph>ld</paragraph>
* </doc>
* );
* ```
*/
splitParagraph(doc, parent, startBlockModel, startIndex, startOffset);
mergeText(startBlockModel, blocks[0], startOffset);
const restBlocks = blocks.slice(1);
if (restBlocks.length > 0) {
addBlocks(doc, restBlocks, parent, startIndex + 1);
}
}
)
.with(
{
multiple: true,
canMergeWithStart: false,
canMergeWithEnd: true,
},
() => {
/**
* Case: First block isn't mergable with start block, but last block is with end block
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <code />
* <code />
* <paragraph>222</paragraph>
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel</paragraph>
* <code />
* <code />
* <paragraph>222ld</paragraph>
* </doc>
* );
* ```
*/
const nextBlockModel = splitParagraph(
doc,
parent,
startBlockModel,
startIndex,
startOffset
);
mergeText(nextBlockModel as BlockModel, blocks[blocks.length - 1], 0);
const restBlocks = blocks.slice(0, -1);
if (restBlocks.length > 0) {
addBlocks(doc, restBlocks, parent, startIndex + 1);
}
}
)
.otherwise(() => {
/**
* Default case: No mergable blocks or blocks that can't be merged
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <code />
* <code />
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel</paragraph>
* <code />
* <code />
* <paragraph>ld</paragraph>
* </doc>
* );
* ```
*/
splitParagraph(doc, parent, startBlockModel, startIndex, startOffset);
addBlocks(doc, blocks, parent, startIndex + 1);
});
return next();
};

View File

@@ -0,0 +1,19 @@
import type { Command } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
export const retainFirstModelCommand: Command<{
selectedModels?: BlockModel[];
}> = (ctx, next) => {
if (!ctx.selectedModels) {
console.error(
'`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.'
);
return;
}
if (ctx.selectedModels.length > 0) {
ctx.selectedModels.shift();
}
return next();
};

View File

@@ -0,0 +1,37 @@
import {
type BlockComponent,
type Command,
TextSelection,
} from '@blocksuite/std';
/**
* Focus the end of the block
* @param focusBlock - The block to focus
* @param force - If set to true, the selection will be cleared.
* It is useful when the selection is same.
*/
export const focusBlockEnd: Command<{
focusBlock?: BlockComponent;
force?: boolean;
}> = (ctx, next) => {
const { focusBlock, force, std } = ctx;
if (!focusBlock || !focusBlock.model.text) return;
const { selection } = std;
std.event.active = true;
if (force) selection.clear();
selection.setGroup('note', [
selection.create(TextSelection, {
from: {
blockId: focusBlock.blockId,
index: focusBlock.model.text.length,
length: 0,
},
to: null,
}),
]);
return next();
};

View File

@@ -0,0 +1,23 @@
import {
type BlockComponent,
type Command,
TextSelection,
} from '@blocksuite/std';
export const focusBlockStart: Command<{
focusBlock?: BlockComponent;
}> = (ctx, next) => {
const { focusBlock, std } = ctx;
if (!focusBlock || !focusBlock.model.text) return;
const { selection } = std;
selection.setGroup('note', [
selection.create(TextSelection, {
from: { blockId: focusBlock.blockId, index: 0, length: 0 },
to: null,
}),
]);
return next();
};

View File

@@ -0,0 +1,10 @@
import { BlockSelection } from '@blocksuite/std';
import type { GetSelectionCommand } from './types';
export const getBlockSelectionsCommand: GetSelectionCommand = (ctx, next) => {
const currentBlockSelections = ctx.std.selection.filter(BlockSelection);
if (currentBlockSelections.length === 0) return;
next({ currentBlockSelections });
};

View File

@@ -0,0 +1,9 @@
import { ImageSelection } from '../../selection/index.js';
import type { GetSelectionCommand } from './types.js';
export const getImageSelectionsCommand: GetSelectionCommand = (ctx, next) => {
const currentImageSelections = ctx.std.selection.filter(ImageSelection);
if (currentImageSelections.length === 0) return;
next({ currentImageSelections });
};

Some files were not shown because too many files have changed in this diff Show More