snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (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 4s
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 / 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 / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (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 / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
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 / 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 / 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 @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 / 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 (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (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 (5, 5) (push) Has been skipped
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 / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (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 (x86_64-apple-darwin) (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

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
{
"name": "@blocksuite/affine-block-paragraph",
"description": "Paragraph block for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts",
"./turbo-painter": "./src/turbo/paragraph-painter.worker.ts",
"./store": "./src/store.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,13 @@
import type { ExtensionType } from '@blocksuite/store';
import { ParagraphBlockHtmlAdapterExtension } from './html.js';
import { ParagraphBlockMarkdownAdapterExtension } from './markdown.js';
import { ParagraphBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { ParagraphBlockPlainTextAdapterExtension } from './plain-text.js';
export const ParagraphBlockAdapterExtensions: ExtensionType[] = [
ParagraphBlockHtmlAdapterExtension,
ParagraphBlockMarkdownAdapterExtension,
ParagraphBlockPlainTextAdapterExtension,
ParagraphBlockNotionHtmlAdapterExtension,
];

View File

@@ -0,0 +1,478 @@
import { ParagraphBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
type HtmlAST,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert, NodeProps } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
const paragraphBlockMatchTags = new Set([
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'body',
'div',
'span',
'footer',
]);
const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
let parent = o.parent;
while (parent) {
if (
HastUtils.isElement(parent.node) &&
tagNames.includes(parent.node.tagName)
) {
return true;
}
parent = parent.parent;
}
return false;
};
const splitDeltaByNewline = (delta: DeltaInsert[]) => {
const lines: DeltaInsert[][] = [[]];
const pending = [...delta];
while (pending.length > 0) {
const op = pending.shift();
if (!op) continue;
const insert = op.insert;
if (typeof insert !== 'string') {
lines[lines.length - 1].push(op);
continue;
}
if (!insert.includes('\n')) {
if (insert.length === 0) {
continue;
}
lines[lines.length - 1].push(op);
continue;
}
const splitIndex = insert.indexOf('\n');
const linePart = insert.slice(0, splitIndex);
const remainPart = insert.slice(splitIndex + 1);
if (linePart.length > 0) {
lines[lines.length - 1].push({ ...op, insert: linePart });
}
lines.push([]);
if (remainPart) {
pending.unshift({ ...op, insert: remainPart });
}
}
return lines;
};
const hasBlockElementDescendant = (node: HtmlAST): boolean => {
if (!HastUtils.isElement(node)) {
return false;
}
return node.children.some(child => {
if (!HastUtils.isElement(child)) {
return false;
}
return (
(HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
hasBlockElementDescendant(child)
);
});
};
const getParagraphDeltas = (
node: HtmlAST,
delta: DeltaInsert[]
): DeltaInsert[][] => {
if (!HastUtils.isElement(node)) return [delta];
if (hasBlockElementDescendant(node)) return [delta];
const hasBr = !!HastUtils.querySelector(node, 'br');
if (!hasBr) return [delta];
const hasNewline = delta.some(
op => typeof op.insert === 'string' && op.insert.includes('\n')
);
if (!hasNewline) return [delta];
return splitDeltaByNewline(delta);
};
const openParagraphBlocks = (
deltas: DeltaInsert[][],
type: string,
// AST walker context from html adapter transform pipeline.
walkerContext: any
) => {
for (const delta of deltas) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: { type, text: { '$blocksuite:internal:text$': true, delta } },
children: [],
},
'children'
)
.closeNode();
}
};
const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
'affine:paragraph:multi-emitted-nodes';
const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
const emittedNodes =
(walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined) ?? new WeakSet<object>();
emittedNodes.add(node as object);
walkerContext.setGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
emittedNodes
);
};
const consumeMultiParagraphEmittedMark = (
walkerContext: any,
node: HtmlAST
) => {
const emittedNodes = walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined;
if (!emittedNodes) {
return false;
}
return emittedNodes.delete(node as object);
};
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: ParagraphBlockSchema.model.flavour,
toMatch: o =>
HastUtils.isElement(o.node) && paragraphBlockMatchTags.has(o.node.tagName),
fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext, deltaConverter } = context;
switch (o.node.tagName) {
case 'blockquote': {
walkerContext.setGlobalContext('hast:blockquote', true);
// Special case for no paragraph in blockquote
const texts = HastUtils.getTextChildren(o.node);
// check if only blank text
const onlyBlankText = texts.every(text => !text.value.trim());
if (texts && !onlyBlankText) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'quote',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(
HastUtils.getTextChildrenOnlyAst(o.node)
),
},
},
children: [],
},
'children'
)
.closeNode();
}
break;
}
case 'body':
case 'div':
case 'span':
case 'footer': {
if (
o.parent?.node.type === 'element' &&
!tagsInAncestor(o, ['p', 'li']) &&
HastUtils.isParagraphLike(o.node)
) {
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
openParagraphBlocks(deltas, 'text', walkerContext);
walkerContext.skipAllChildren();
}
break;
}
case 'p': {
const type = walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text';
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
if (deltas.length > 1) {
openParagraphBlocks(deltas, type, walkerContext);
markMultiParagraphEmitted(walkerContext, o.node);
walkerContext.skipAllChildren();
break;
}
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type,
text: {
'$blocksuite:internal:text$': true,
delta,
},
},
children: [],
},
'children'
);
break;
}
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6': {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: o.node.tagName,
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
},
leave: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
switch (o.node.tagName) {
case 'div': {
// oxlint-disable-next-line sonarjs/no-collapsible-if
if (
o.parent?.node.type === 'element' &&
o.parent.node.tagName !== 'li' &&
Array.isArray(o.node.properties?.className)
) {
if (
o.node.properties.className.includes(
'affine-paragraph-block-container'
) ||
o.node.properties.className.includes(
'affine-block-children-container'
) ||
o.node.properties.className.includes('indented')
) {
walkerContext.closeNode();
}
}
break;
}
case 'blockquote': {
walkerContext.setGlobalContext('hast:blockquote', false);
break;
}
case 'p': {
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
break;
}
if (
o.next?.type === 'element' &&
o.next.tagName === 'div' &&
Array.isArray(o.next.properties?.className) &&
(o.next.properties.className.includes(
'affine-block-children-container'
) ||
o.next.properties.className.includes('indented'))
) {
// Close the node when leaving div indented
break;
}
walkerContext.closeNode();
break;
}
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
const { walkerContext, deltaConverter } = context;
switch (o.node.props.type) {
case 'text': {
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'p',
properties: {},
children: deltaConverter.deltaToAST(text.delta),
},
'children'
)
.closeNode()
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-block-children-container'],
style: 'padding-left: 26px;',
},
children: [],
},
'children'
);
break;
}
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6': {
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: o.node.props.type,
properties: {},
children: deltaConverter.deltaToAST(text.delta),
},
'children'
)
.closeNode()
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-block-children-container'],
style: 'padding-left: 26px;',
},
children: [],
},
'children'
);
break;
}
case 'quote': {
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'blockquote',
properties: {
className: ['quote'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'p',
properties: {},
children: deltaConverter.deltaToAST(text.delta),
},
'children'
)
.closeNode()
.closeNode()
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-block-children-container'],
style: 'padding-left: 26px;',
},
children: [],
},
'children'
);
break;
}
}
},
leave: (_, context) => {
const { walkerContext } = context;
walkerContext.closeNode().closeNode();
},
},
};
export const ParagraphBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
paragraphBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,4 @@
export * from './html.js';
export * from './markdown.js';
export * from './notion-html.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,334 @@
import { ParagraphBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
IN_PARAGRAPH_NODE_CONTEXT_KEY,
isCalloutNode,
type MarkdownAST,
type MarkdownDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import type { Blockquote, Heading, List, ListItem } from 'mdast';
/**
* Extend the HeadingData type to include the collapsed property
*/
declare module 'mdast' {
interface HeadingData {
collapsed?: boolean;
}
}
const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
const isParagraphMDASTType = (node: MarkdownAST) =>
PARAGRAPH_MDAST_TYPE.has(node.type);
const joinDeltaLines = (
lines: DeltaInsert[][],
prefix?: string
): DeltaInsert[] => {
const deltas: DeltaInsert[] = [];
lines.forEach(line => {
if (deltas.length) deltas.push({ insert: '\n' });
if (prefix) deltas.push({ insert: prefix });
deltas.push(...line);
});
return deltas;
};
const flattenListItemToDelta = (
node: ListItem,
deltaConverter: MarkdownDeltaConverter,
prefix: string,
depth: number
): DeltaInsert[] => {
const firstParagraph = node.children[0];
const lines: DeltaInsert[][] = [];
if (firstParagraph?.type === 'paragraph') {
lines.push([
{ insert: prefix },
...deltaConverter.astToDelta(firstParagraph),
]);
} else {
lines.push([{ insert: prefix.trimEnd() }]);
}
node.children
.slice(firstParagraph?.type === 'paragraph' ? 1 : 0)
.forEach(child => {
const delta = flattenMarkdownBlockToDelta(
child as MarkdownAST,
deltaConverter,
depth + 1
);
if (delta.length) {
lines.push(delta);
}
});
return joinDeltaLines(lines);
};
const flattenMarkdownBlockToDelta = (
node: MarkdownAST,
deltaConverter: MarkdownDeltaConverter,
depth = 0
): DeltaInsert[] => {
switch (node.type) {
case 'paragraph':
case 'heading':
return deltaConverter.astToDelta(node);
case 'list': {
const list = node as List;
return joinDeltaLines(
list.children.map((item, index) => {
const order = (list.start ?? 1) + index;
const prefix =
' '.repeat(depth) + (list.ordered ? `${order}. ` : '- ');
return flattenListItemToDelta(item, deltaConverter, prefix, depth);
})
);
}
case 'blockquote':
return flattenBlockquoteToDelta(node as Blockquote, deltaConverter);
default:
return 'children' in node
? joinDeltaLines(
(node.children as MarkdownAST[]).map(child =>
flattenMarkdownBlockToDelta(child, deltaConverter, depth)
)
)
: [];
}
};
const flattenBlockquoteToDelta = (
node: Blockquote,
deltaConverter: MarkdownDeltaConverter
) =>
joinDeltaLines(
node.children.map(child =>
flattenMarkdownBlockToDelta(child as MarkdownAST, deltaConverter)
)
);
const getSnapshotTextDelta = (node: BlockSnapshot): DeltaInsert[] => {
const text = (node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
return text.delta;
};
const flattenSnapshotBlockToDelta = (
node: BlockSnapshot,
depth = 0
): DeltaInsert[] => {
if (node.flavour === 'affine:list') {
const type = node.props.type;
const order = (node.props.order as number | undefined) ?? 1;
const prefix =
' '.repeat(depth) + (type === 'numbered' ? `${order}. ` : '- ');
return joinDeltaLines([
[{ insert: prefix }, ...getSnapshotTextDelta(node)],
...node.children.map(child =>
flattenSnapshotBlockToDelta(child, depth + 1)
),
]);
}
return joinDeltaLines([
getSnapshotTextDelta(node),
...node.children.map(child => flattenSnapshotBlockToDelta(child, depth)),
]);
};
const flattenQuoteSnapshotToDelta = (
text: DeltaInsert[],
children: BlockSnapshot[]
) =>
joinDeltaLines([
text,
...children.map(child => flattenSnapshotBlockToDelta(child)),
]);
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: ParagraphBlockSchema.model.flavour,
toMatch: o => isParagraphMDASTType(o.node) && !isCalloutNode(o.node),
fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
switch (o.node.type) {
case 'paragraph': {
walkerContext.setGlobalContext(IN_PARAGRAPH_NODE_CONTEXT_KEY, true);
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
},
'children'
)
.closeNode();
break;
}
case 'heading': {
const isCollapsed = !!o.node.data?.collapsed;
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: `h${o.node.depth}`,
collapsed: isCollapsed,
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
},
'children'
)
.closeNode();
break;
}
case 'blockquote': {
if (isCalloutNode(o.node)) {
return;
}
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'quote',
text: {
'$blocksuite:internal:text$': true,
delta: flattenBlockquoteToDelta(
o.node as Blockquote,
deltaConverter
),
},
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
},
leave: (o, context) => {
if (o.node.type === 'paragraph') {
const { walkerContext } = context;
walkerContext.setGlobalContext(IN_PARAGRAPH_NODE_CONTEXT_KEY, false);
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
const paragraphDepth = (walkerContext.getGlobalContext(
'affine:paragraph:depth'
) ?? 0) as number;
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
switch (o.node.props.type) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6': {
walkerContext
.openNode(
{
type: 'heading',
depth: parseInt(o.node.props.type[1]) as Heading['depth'],
children: deltaConverter.deltaToAST(
text.delta,
paragraphDepth
),
},
'children'
)
.closeNode();
break;
}
case 'text': {
walkerContext
.openNode(
{
type: 'paragraph',
children: deltaConverter.deltaToAST(
text.delta,
paragraphDepth
),
},
'children'
)
.closeNode();
break;
}
case 'quote': {
const quoteDelta = flattenQuoteSnapshotToDelta(
text.delta,
o.node.children
);
walkerContext
.openNode(
{
type: 'blockquote',
children: [],
},
'children'
)
.openNode(
{
type: 'paragraph',
children: deltaConverter.deltaToAST(quoteDelta),
},
'children'
)
.closeNode()
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
walkerContext.setGlobalContext(
'affine:paragraph:depth',
paragraphDepth + 1
);
},
leave: (_, context) => {
const { walkerContext } = context;
walkerContext.setGlobalContext(
'affine:paragraph:depth',
(walkerContext.getGlobalContext('affine:paragraph:depth') as number) -
1
);
},
},
};
export const ParagraphBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(paragraphBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,239 @@
import { ParagraphBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
const paragraphBlockMatchTags = new Set([
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'div',
'span',
'figure',
]);
const NotionDatabaseTitleToken = '.collection-title';
const NotionPageLinkToken = '.link-to-page';
const NotionCalloutToken = '.callout';
const NotionCheckboxToken = '.checkbox';
export const paragraphBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: ParagraphBlockSchema.model.flavour,
toMatch: o =>
HastUtils.isElement(o.node) &&
paragraphBlockMatchTags.has(o.node.tagName),
fromMatch: () => false,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext, deltaConverter, pageMap } = context;
switch (o.node.tagName) {
case 'blockquote': {
walkerContext.setGlobalContext('hast:blockquote', true);
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'quote',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(
HastUtils.getInlineOnlyElementAST(o.node),
{ pageMap, removeLastBr: true }
),
},
},
children: [],
},
'children'
);
break;
}
case 'p': {
// Workaround for Notion's bug
// https://html.spec.whatwg.org/multipage/grouping-content.html#the-p-element
if (!o.node.properties.id) {
break;
}
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node, { pageMap }),
},
},
children: [],
},
'children'
);
break;
}
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6': {
if (HastUtils.querySelector(o.node, NotionDatabaseTitleToken)) {
break;
}
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: o.node.tagName,
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node, { pageMap }),
},
},
children: [],
},
'children'
)
.closeNode();
break;
}
case 'figure':
{
// Notion page link
if (HastUtils.querySelector(o.node, NotionPageLinkToken)) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node, { pageMap }),
},
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
// Notion callout
if (HastUtils.querySelector(o.node, NotionCalloutToken)) {
const firstElementChild = HastUtils.getElementChildren(o.node)[0];
const secondElementChild = HastUtils.getElementChildren(
o.node
)[1];
const iconSpan = HastUtils.querySelector(
firstElementChild,
'.icon'
);
const iconText = iconSpan
? HastUtils.getTextContent(iconSpan)
: '';
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'quote',
text: {
'$blocksuite:internal:text$': true,
delta: [
{ insert: iconText + '\n' },
...deltaConverter.astToDelta(secondElementChild, {
pageMap,
}),
],
},
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
},
leave: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
switch (o.node.tagName) {
case 'div': {
// oxlint-disable-next-line sonarjs/no-collapsible-if
if (
o.parent?.node.type === 'element' &&
!(
o.parent.node.tagName === 'li' &&
HastUtils.querySelector(o.parent.node, NotionCheckboxToken)
) &&
Array.isArray(o.node.properties?.className)
) {
if (o.node.properties.className.includes('indented')) {
walkerContext.closeNode();
}
}
break;
}
case 'blockquote': {
walkerContext.closeNode();
walkerContext.setGlobalContext('hast:blockquote', false);
break;
}
case 'p': {
if (!o.node.properties.id) {
break;
}
if (
o.next?.type === 'element' &&
o.next.tagName === 'div' &&
Array.isArray(o.next.properties?.className) &&
o.next.properties.className.includes('indented')
) {
// Close the node when leaving div indented
break;
}
walkerContext.closeNode();
break;
}
}
},
},
fromBlockSnapshot: {},
};
export const ParagraphBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(paragraphBlockNotionHtmlAdapterMatcher);

View File

@@ -0,0 +1,28 @@
import { ParagraphBlockSchema } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/store';
export const paragraphBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
{
flavour: ParagraphBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
const { deltaConverter } = context;
const buffer = deltaConverter.deltaToAST(text.delta).join('');
context.textBuffer.content += buffer;
context.textBuffer.content += '\n';
},
},
};
export const ParagraphBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(paragraphBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,56 @@
import { focusTextModel } from '@blocksuite/affine-rich-text';
import { type Command, TextSelection } from '@blocksuite/std';
/**
* Add a paragraph next to the current block.
*/
export const addParagraphCommand: Command<
{
blockId?: string;
},
{
paragraphConvertedId: string;
}
> = (ctx, next) => {
const { std } = ctx;
const { store, selection } = std;
store.captureSync();
let blockId = ctx.blockId;
if (!blockId) {
const text = selection.find(TextSelection);
blockId = text?.blockId;
}
if (!blockId) return;
const model = store.getBlock(blockId)?.model;
if (!model) return;
let id: string;
if (model.children.length > 0) {
// before:
// aaa|
// bbb
//
// after:
// aaa
// |
// bbb
id = store.addBlock('affine:paragraph', {}, model, 0);
} else {
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
if (index < 0) return;
// before:
// aaa|
//
// after:
// aaa
// |
id = store.addBlock('affine:paragraph', {}, parent, index + 1);
}
focusTextModel(std, id);
return next({ paragraphConvertedId: id });
};

View File

@@ -0,0 +1,30 @@
import { focusTextModel } from '@blocksuite/affine-rich-text';
import { getLastNoteBlock } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/std';
import { Text } from '@blocksuite/store';
/**
* Append a paragraph block at the end of the whole page.
*/
export const appendParagraphCommand: Command<{ text?: string }> = (
ctx,
next
) => {
const { std, text = '' } = ctx;
const { store } = std;
if (!store.root) return;
const note = getLastNoteBlock(store);
let noteId = note?.id;
if (!noteId) {
noteId = store.addBlock('affine:note', {}, store.root.id);
}
const id = store.addBlock(
'affine:paragraph',
{ text: new Text(text) },
noteId
);
focusTextModel(std, id, text.length);
next();
};

View File

@@ -0,0 +1,114 @@
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import type { IndentContext } from '@blocksuite/affine-shared/types';
import {
calculateCollapsedSiblings,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type Command, TextSelection } from '@blocksuite/std';
export const canDedentParagraphCommand: Command<
Partial<Omit<IndentContext, 'flavour' | 'type'>>,
{
indentContext: IndentContext;
}
> = (ctx, next) => {
let { blockId, inlineIndex } = ctx;
const { std } = ctx;
const { selection, store } = std;
const text = selection.find(TextSelection);
if (!blockId) {
/**
* Do nothing if the selection:
* - is not a text selection
* - or spans multiple blocks
*/
if (!text || (text.to && text.from.blockId !== text.to.blockId)) {
return;
}
blockId = text.from.blockId;
inlineIndex = text.from.index;
}
if (blockId == null || inlineIndex == null) {
return;
}
const model = store.getBlock(blockId)?.model;
if (!model || !matchModels(model, [ParagraphBlockModel])) {
return;
}
const parent = store.getParent(model);
if (store.readonly || !parent || parent.role !== 'content') {
// Top most, can not unindent, do nothing
return;
}
const grandParent = store.getParent(parent);
if (!grandParent) return;
return next({
indentContext: {
blockId,
inlineIndex,
type: 'dedent',
flavour: 'affine:paragraph',
},
});
};
export const dedentParagraphCommand: Command<{
indentContext: IndentContext;
}> = (ctx, next) => {
const { indentContext: dedentContext, std } = ctx;
const { store, selection, range, host } = std;
if (
!dedentContext ||
dedentContext.type !== 'dedent' ||
dedentContext.flavour !== 'affine:paragraph'
) {
console.warn(
'you need to use `canDedentParagraph` command before running `dedentParagraph` command'
);
return;
}
const { blockId } = dedentContext;
const model = store.getBlock(blockId)?.model;
if (!model) return;
const parent = store.getParent(model);
if (!parent) return;
const grandParent = store.getParent(parent);
if (!grandParent) return;
store.captureSync();
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type.startsWith('h') &&
model.props.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
store.moveBlocks([model, ...collapsedSiblings], grandParent, parent, false);
} else {
const nextSiblings = store.getNexts(model);
store.moveBlocks(nextSiblings, model);
store.moveBlocks([model], grandParent, parent, false);
}
const textSelection = selection.find(TextSelection);
if (textSelection) {
host.updateComplete
.then(() => {
range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
}
return next();
};

View File

@@ -0,0 +1,156 @@
import { ListBlockModel, ParagraphBlockModel } from '@blocksuite/affine-model';
import type { IndentContext } from '@blocksuite/affine-shared/types';
import {
calculateCollapsedSiblings,
getNearestHeadingBefore,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type Command, TextSelection } from '@blocksuite/std';
export const canIndentParagraphCommand: Command<
Partial<Omit<IndentContext, 'flavour' | 'type'>>,
{
indentContext: IndentContext;
}
> = (cxt, next) => {
let { blockId, inlineIndex } = cxt;
const { std } = cxt;
const { selection, store } = std;
const { schema } = store;
if (!blockId) {
const text = selection.find(TextSelection);
/**
* Do nothing if the selection:
* - is not a text selection
* - or spans multiple blocks
*/
if (!text || (text.to && text.from.blockId !== text.to.blockId)) {
return;
}
blockId = text.from.blockId;
inlineIndex = text.from.index;
}
if (blockId == null || inlineIndex == null) {
return;
}
const model = std.store.getBlock(blockId)?.model;
if (!model || !matchModels(model, [ParagraphBlockModel])) {
return;
}
const previousSibling = store.getPrev(model);
if (
store.readonly ||
!previousSibling ||
!schema.isValid(model.flavour, previousSibling.flavour)
) {
// Bottom, can not indent, do nothing
return;
}
return next({
indentContext: {
blockId,
inlineIndex,
type: 'indent',
flavour: 'affine:paragraph',
},
});
};
export const indentParagraphCommand: Command<{
indentContext: IndentContext;
}> = (ctx, next) => {
const { indentContext, std } = ctx;
const { store, selection, host, range } = std;
if (
!indentContext ||
indentContext.type !== 'indent' ||
indentContext.flavour !== 'affine:paragraph'
) {
console.warn(
'you need to use `canIndentParagraph` command before running `indentParagraph` command'
);
return;
}
const { blockId } = indentContext;
const model = store.getBlock(blockId)?.model;
if (!model) return;
const previousSibling = store.getPrev(model);
if (!previousSibling) return;
store.captureSync();
{
// > # 123
// > # 456
//
// we need to update 123 collapsed state to false when indent 456
const nearestHeading = getNearestHeadingBefore(model);
if (
nearestHeading &&
matchModels(nearestHeading, [ParagraphBlockModel]) &&
nearestHeading.props.collapsed
) {
store.updateBlock(nearestHeading, {
collapsed: false,
});
}
}
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type.startsWith('h') &&
model.props.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
store.moveBlocks([model, ...collapsedSiblings], previousSibling);
} else {
store.moveBlocks([model], previousSibling);
}
{
// 123
// > # 456
// 789
//
// we need to update 456 collapsed state to false when indent 789
const nearestHeading = getNearestHeadingBefore(model);
if (
nearestHeading &&
matchModels(nearestHeading, [ParagraphBlockModel]) &&
nearestHeading.props.collapsed
) {
store.updateBlock(nearestHeading, {
collapsed: false,
});
}
}
// update collapsed state of affine list
if (
matchModels(previousSibling, [ListBlockModel]) &&
previousSibling.props.collapsed
) {
store.updateBlock(previousSibling, {
collapsed: false,
});
}
const textSelection = selection.find(TextSelection);
if (textSelection) {
host.updateComplete
.then(() => {
range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
}
return next();
};

View File

@@ -0,0 +1,11 @@
export { addParagraphCommand } from './add-paragraph.js';
export { appendParagraphCommand } from './append-paragraph.js';
export {
canDedentParagraphCommand,
dedentParagraphCommand,
} from './dedent-paragraph.js';
export {
canIndentParagraphCommand,
indentParagraphCommand,
} from './indent-paragraph.js';
export { splitParagraphCommand } from './split-paragraph.js';

View File

@@ -0,0 +1,81 @@
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import {
focusTextModel,
getInlineEditorByModel,
} from '@blocksuite/affine-rich-text';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { type Command, TextSelection } from '@blocksuite/std';
export const splitParagraphCommand: Command<
{
blockId?: string;
},
{
paragraphConvertedId: string;
}
> = (ctx, next) => {
const { std } = ctx;
const { store, selection } = std;
let blockId = ctx.blockId;
if (!blockId) {
const text = selection.find(TextSelection);
blockId = text?.blockId;
}
if (!blockId) return;
const model = store.getBlock(blockId)?.model;
if (!model || !matchModels(model, [ParagraphBlockModel])) return;
const inlineEditor = getInlineEditorByModel(std, model);
const range = inlineEditor?.getInlineRange();
if (!range) return;
const splitIndex = range.index;
const splitLength = range.length;
// On press enter, it may convert symbols from yjs ContentString
// to yjs ContentFormat. Once it happens, the converted symbol will
// be deleted and not counted as model.text.yText.length.
// Example: "`a`[enter]" -> yText[<ContentFormat: Code>, "a", <ContentFormat: Code>]
// In this case, we should not split the block.
if (model.props.text.yText.length < splitIndex + splitLength) return;
if (model.children.length > 0 && splitIndex > 0) {
store.captureSync();
const right = model.props.text.split(splitIndex, splitLength);
const id = store.addBlock(
model.flavour,
{
text: right,
type: model.props.type,
},
model,
0
);
focusTextModel(std, id);
return next({ paragraphConvertedId: id });
}
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
if (index < 0) return;
store.captureSync();
const right = model.props.text.split(splitIndex, splitLength);
const id = store.addBlock(
model.flavour,
{
text: right,
type: model.props.type,
},
parent,
index + 1
);
const newModel = store.getBlock(id)?.model;
if (newModel) {
store.moveBlocks(model.children, newModel);
} else {
console.error('Failed to find the new model split from the paragraph');
}
focusTextModel(std, id);
return next({ paragraphConvertedId: id });
};

View File

@@ -0,0 +1,13 @@
import { effects as ParagraphHeadingIconEffects } from './heading-icon.js';
import { ParagraphBlockComponent } from './paragraph-block.js';
export function effects() {
ParagraphHeadingIconEffects();
customElements.define('affine-paragraph', ParagraphBlockComponent);
}
declare global {
interface HTMLElementTagNameMap {
'affine-paragraph': ParagraphBlockComponent;
}
}

View File

@@ -0,0 +1,92 @@
import {
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
} from '@blocksuite/affine-components/icons';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
function HeadingIcon(i: number) {
switch (i) {
case 1:
return Heading1Icon;
case 2:
return Heading2Icon;
case 3:
return Heading3Icon;
case 4:
return Heading4Icon;
case 5:
return Heading5Icon;
case 6:
return Heading6Icon;
default:
return Heading1Icon;
}
}
export class ParagraphHeadingIcon extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-paragraph-heading-icon .heading-icon {
display: flex;
align-items: start;
margin-top: 0.3em;
position: absolute;
left: 0;
transform: translateX(-80px);
border-radius: 4px;
padding: 2px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
pointer-events: none;
background: ${unsafeCSS(cssVarV2('button/iconButtonSolid', '#FFF'))};
color: ${unsafeCSS(cssVarV2('icon/primary', '#7A7A7A'))};
box-shadow:
var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px)
var(--Shadow-buttonShadow-1-blur, 1px) 0px
var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)),
var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px)
var(--Shadow-buttonShadow-2-blur, 5px) 0px
var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12));
}
.with-drag-handle .heading-icon {
opacity: 1;
}
`;
override render() {
const type = this.model.props.type$.value;
if (!type.startsWith('h')) return nothing;
const i = parseInt(type.slice(1));
return html`<div class="heading-icon" data-testid="heading-icon-${i}">
${HeadingIcon(i)}
</div>`;
}
@property({ attribute: false })
accessor model!: ParagraphBlockModel;
}
export function effects() {
customElements.define('affine-paragraph-heading-icon', ParagraphHeadingIcon);
}
declare global {
interface HTMLElementTagNameMap {
'affine-paragraph-heading-icon': ParagraphHeadingIcon;
}
}

View File

@@ -0,0 +1,6 @@
export * from './adapters/index.js';
export * from './commands';
export * from './paragraph-block.js';
export * from './paragraph-block-config.js';
export * from './turbo/paragraph-layout-handler';
export * from './turbo/paragraph-painter.worker';

View File

@@ -0,0 +1,74 @@
import {
ListBlockModel,
ParagraphBlockModel,
ParagraphBlockSchema,
type ParagraphType,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/std';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const ParagraphMarkdownExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'heading',
pattern: /^((#{1,6})|(>))\s$/,
action: ({ inlineEditor, pattern, inlineRange, prefixText }) => {
if (inlineEditor.yTextString.slice(0, inlineRange.index).includes('\n')) {
return;
}
const match = prefixText.match(pattern);
if (!match) return;
const type = (
match[2] ? `h${match[2].length}` : 'quote'
) as ParagraphType;
if (!inlineEditor.rootElement) return;
const blockComponent =
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
const { model, std, store } = blockComponent;
if (
!matchModels(model, [ParagraphBlockModel]) &&
matchModels(model, [ListBlockModel])
) {
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
store.captureSync();
inlineEditor.deleteText({
index: 0,
length: inlineRange.index,
});
store.deleteBlock(model, { deleteChildren: false });
const id = store.addBlock<ParagraphBlockModel>(
ParagraphBlockSchema.model.flavour,
{
type: type,
text: model.text?.clone(),
children: model.children,
},
parent,
index
);
focusTextModel(std, id);
} else if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type !== type
) {
store.captureSync();
inlineEditor.deleteText({
index: 0,
length: inlineRange.index,
});
store.updateBlock(model, { type });
focusTextModel(std, model.id);
}
},
});

View File

@@ -0,0 +1,9 @@
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import { ConfigExtensionFactory } from '@blocksuite/std';
export interface ParagraphBlockConfig {
getPlaceholder: (model: ParagraphBlockModel) => string;
}
export const ParagraphBlockConfigExtension =
ConfigExtensionFactory<ParagraphBlockConfig>('affine:paragraph');

View File

@@ -0,0 +1,404 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button';
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import {
BLOCK_CHILDREN_CONTAINER_PADDING_LEFT,
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR,
} from '@blocksuite/affine-shared/consts';
import {
BlockElementCommentManager,
CitationProvider,
DocModeProvider,
} from '@blocksuite/affine-shared/services';
import {
calculateCollapsedSiblings,
getNearestHeadingBefore,
getViewportElement,
} from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/std';
import { TextSelection } from '@blocksuite/std';
import {
getInlineRangeProvider,
type InlineRangeProvider,
} from '@blocksuite/std/inline';
import { computed, effect, signal } from '@preact/signals-core';
import { html, nothing, type TemplateResult } from 'lit';
import { query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ParagraphBlockConfigExtension } from './paragraph-block-config.js';
import { paragraphBlockStyles } from './styles.js';
export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBlockModel> {
static override styles = paragraphBlockStyles;
focused$ = computed(() => {
const selection = this.std.selection.value.find(
selection => selection.blockId === this.model?.id
);
if (!selection) return false;
return selection.is(TextSelection);
});
private readonly _composing = signal(false);
private readonly _displayPlaceholder = signal(false);
private readonly _readonlyCollapsed = signal(false);
private _inlineRangeProvider: InlineRangeProvider | null = null;
private readonly _isInDatabase = () => {
let parent = this.parentElement;
while (parent && parent !== document.body) {
if (parent.tagName.toLowerCase() === 'affine-database') {
return true;
}
parent = parent.parentElement;
}
return false;
};
private get _placeholder() {
return this.std
.get(ParagraphBlockConfigExtension.identifier)
?.getPlaceholder(this.model);
}
private _setReadonlyCollapsed(collapsed: boolean) {
this._readonlyCollapsed.value = collapsed;
this.requestUpdate();
}
get citationService() {
return this.std.get(CitationProvider);
}
get attributeRenderer() {
return this.inlineManager.getRenderer();
}
get attributesSchema() {
return this.inlineManager.getSchema();
}
get collapsedSiblings() {
return calculateCollapsedSiblings(this.model);
}
get embedChecker() {
return this.inlineManager.embedChecker;
}
get inEdgelessText() {
return (
this.topContenteditableElement?.tagName.toLowerCase() ===
'affine-edgeless-text'
);
}
get inlineEditor() {
return this._richTextElement?.inlineEditor;
}
get inlineManager() {
return this.std.get(DefaultInlineManagerExtension.identifier);
}
get hasCitationSiblings() {
return this.collapsedSiblings.some(sibling =>
this.citationService.isCitationModel(sibling)
);
}
get isCommentHighlighted() {
return (
this.std
.getOptional(BlockElementCommentManager)
?.isBlockCommentHighlighted(this.model) ?? false
);
}
override get topContenteditableElement() {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR
);
}
return this.rootComponent;
}
override connectedCallback() {
super.connectedCallback();
this.handleEvent(
'compositionStart',
() => {
this._composing.value = true;
},
{ flavour: true }
);
this.handleEvent(
'compositionEnd',
() => {
this._composing.value = false;
},
{ flavour: true }
);
this._inlineRangeProvider = getInlineRangeProvider(this);
this.disposables.add(
effect(() => {
const composing = this._composing.value;
if (composing || this.store.readonly) {
this._displayPlaceholder.value = false;
return;
}
const textSelection = this.host.selection.find(TextSelection);
const isCollapsed = textSelection?.isCollapsed() ?? false;
if (!this.focused$.value || !isCollapsed) {
this._displayPlaceholder.value = false;
return;
}
this.updateComplete
.then(() => {
if (
(this.inlineEditor?.yTextLength ?? 0) > 0 ||
this._isInDatabase()
) {
this._displayPlaceholder.value = false;
return;
}
this._displayPlaceholder.value = true;
return;
})
.catch(console.error);
})
);
this.disposables.add(
effect(() => {
const type = this.model.props.type$.value;
if (!type.startsWith('h') && this.model.props.collapsed) {
this.model.props.collapsed = false;
}
})
);
this.disposables.add(
effect(() => {
if (this.store.readonly$.value) {
this._setReadonlyCollapsed(this.model.props.collapsed$.value);
}
})
);
this.disposables.add(
effect(() => {
const collapsed = this.store.readonly
? this._readonlyCollapsed.value
: this.model.props.collapsed$.value;
// reset text selection when selected block is collapsed
if (this.model.props.type$.value.startsWith('h') && collapsed) {
const collapsedSiblings = this.collapsedSiblings;
const textSelection = this.host.selection.find(TextSelection);
if (
textSelection &&
collapsedSiblings.some(
sibling => sibling.id === textSelection.blockId
)
) {
this.host.selection.clear(['text']);
}
}
})
);
// > # 123
// # 456
//
// we need to update collapsed state of 123 when 456 converted to text
let beforeType = this.model.props.type$.peek();
this.disposables.add(
effect(() => {
const type = this.model.props.type$.value;
if (beforeType !== type && !type.startsWith('h')) {
const nearestHeading = getNearestHeadingBefore(this.model);
if (
nearestHeading &&
nearestHeading.props.type.startsWith('h') &&
nearestHeading.props.collapsed &&
!this.store.readonly
) {
nearestHeading.props.collapsed = false;
}
}
beforeType = type;
})
);
}
override async getUpdateComplete() {
const result = await super.getUpdateComplete();
await this._richTextElement?.updateComplete;
return result;
}
override renderBlock(): TemplateResult<1> {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const { type$ } = this.model.props;
const collapsed = this.store.readonly
? this._readonlyCollapsed.value
: this.model.props.collapsed;
const collapsedSiblings = this.collapsedSiblings;
let style = html``;
if (this.model.props.type$.value.startsWith('h') && collapsed) {
const collapsedSiblingStyles = collapsedSiblings
.map(
sibling => `
[data-block-id="${sibling.id}"] {
display: none !important;
}
`
)
.join('\n');
style = html`
<style>
${collapsedSiblingStyles}
</style>
`;
}
const textAlignStyle = styleMap({
textAlign: this.model.props.textAlign$?.value,
});
const childrenId = `heading-children-${this.model.id}`;
const children = html`<div
id=${childrenId}
class="affine-block-children-container"
style=${styleMap({
paddingLeft: `${BLOCK_CHILDREN_CONTAINER_PADDING_LEFT}px`,
display: collapsed ? 'none' : undefined,
})}
>
${this.renderChildren(this.model)}
</div>`;
return html`
${style}
<style>
.affine-paragraph-block-container[data-has-collapsed-siblings='false']
affine-paragraph-heading-icon
.heading-icon {
transform: translateX(-48px);
}
</style>
<div
class=${classMap({
'affine-paragraph-block-container': true,
'highlight-comment': this.isCommentHighlighted,
})}
style="${textAlignStyle}"
data-has-collapsed-siblings="${collapsedSiblings.length > 0}"
>
<div
class=${classMap({
'affine-paragraph-rich-text-wrapper': true,
[type$.value]: true,
[TOGGLE_BUTTON_PARENT_CLASS]: true,
})}
>
${
this.model.props.type$.value.startsWith('h')
? html`
<affine-paragraph-heading-icon
.model=${this.model}
></affine-paragraph-heading-icon>
`
: nothing
}
${
this.model.props.type$.value.startsWith('h') &&
collapsedSiblings.length > 0
? html`
<blocksuite-toggle-button
.collapsed=${collapsed}
.controls=${childrenId}
.updateCollapsed=${(value: boolean) => {
if (this.store.readonly) {
this._setReadonlyCollapsed(value);
} else {
this.store.captureSync();
this.store.updateBlock(this.model, {
collapsed: value,
});
}
if (this.hasCitationSiblings) {
this.citationService.trackEvent('Expand', {
control: 'Source Button',
type: value ? 'Hide' : 'Show',
});
}
}}
></blocksuite-toggle-button>
`
: nothing
}
<rich-text
.yText=${this.model.props.text.yText}
.inlineEventSource=${this.topContenteditableElement ?? nothing}
.undoManager=${this.store.history.undoManager}
.attributesSchema=${this.attributesSchema}
.attributeRenderer=${this.attributeRenderer}
.markdownMatches=${this.inlineManager?.markdownMatches}
.embedChecker=${this.embedChecker}
.readonly=${this.store.readonly}
.inlineRangeProvider=${this._inlineRangeProvider}
.enableClipboard=${false}
.enableUndoRedo=${false}
.verticalScrollContainerGetter=${() =>
getViewportElement(this.host)}
></rich-text>
${
this.inEdgelessText
? nothing
: html`
<div
contenteditable="false"
class=${classMap({
'affine-paragraph-placeholder': true,
visible: this._displayPlaceholder.value,
})}
>
${this._placeholder}
</div>
`
}
</div>
${children} ${widgets}
</div>
`;
}
@query('rich-text')
private accessor _richTextElement: RichText | null = null;
override accessor blockContainerStyles = {
margin: 'var(--affine-paragraph-margin, 10px 0)',
};
}

View File

@@ -0,0 +1,232 @@
import { textKeymap } from '@blocksuite/affine-inline-preset';
import {
CalloutBlockModel,
ParagraphBlockModel,
ParagraphBlockSchema,
} from '@blocksuite/affine-model';
import {
focusTextModel,
getInlineEditorByModel,
} from '@blocksuite/affine-rich-text';
import {
calculateCollapsedSiblings,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { IS_MAC } from '@blocksuite/global/env';
import { KeymapExtension, TextSelection } from '@blocksuite/std';
import { addParagraphCommand } from './commands/add-paragraph.js';
import {
canDedentParagraphCommand,
dedentParagraphCommand,
} from './commands/dedent-paragraph.js';
import {
canIndentParagraphCommand,
indentParagraphCommand,
} from './commands/indent-paragraph.js';
import { splitParagraphCommand } from './commands/split-paragraph.js';
import { forwardDelete } from './utils/forward-delete.js';
import { mergeWithPrev } from './utils/merge-with-prev.js';
export const ParagraphKeymapExtension = KeymapExtension(
std => {
return {
Backspace: ctx => {
const text = std.selection.find(TextSelection);
if (!text) return;
const isCollapsed = text.isCollapsed();
const isStart = isCollapsed && text.from.index === 0;
if (!isStart) return;
const { store } = std;
const model = store.getBlock(text.from.blockId)?.model;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const event = ctx.get('defaultState').event;
event.preventDefault();
// When deleting at line start of a paragraph block,
// firstly switch it to normal text, then delete this empty block.
if (model.props.type !== 'text') {
// Try to switch to normal text
store.captureSync();
store.updateBlock(model, { type: 'text' });
return true;
}
const merged = mergeWithPrev(std.host, model);
if (merged) {
return true;
}
std.command
.chain()
.pipe(canDedentParagraphCommand)
.pipe(dedentParagraphCommand)
.run();
return true;
},
'Mod-Enter': ctx => {
const { store } = std;
const text = std.selection.find(TextSelection);
if (!text) return;
const model = store.getBlock(text.from.blockId)?.model;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const inlineEditor = getInlineEditorByModel(std, text.from.blockId);
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange || !inlineEditor) return;
const raw = ctx.get('keyboardState').raw;
raw.preventDefault();
if (model.props.type === 'quote') {
store.captureSync();
inlineEditor.insertText(inlineRange, '\n');
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
return true;
}
std.command.chain().pipe(addParagraphCommand).run();
return true;
},
Enter: ctx => {
const raw = ctx.get('keyboardState').raw;
if (raw.isComposing) return;
const { store } = std;
const text = std.selection.find(TextSelection);
if (!text) return;
const model = store.getBlock(text.from.blockId)?.model;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const inlineEditor = getInlineEditorByModel(std, text.from.blockId);
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange || !inlineEditor) return;
const isEnd = model.props.text.length === inlineRange.index;
if (model.props.type === 'quote') {
const textStr = model.props.text.toString();
/**
* If quote block ends with two blank lines, split the block
* ---
* before:
* > \n
* > \n|
*
* after:
* > \n
* |
* ---
*/
const endWithTwoBlankLines =
textStr === '\n' || textStr.endsWith('\n');
if (isEnd && endWithTwoBlankLines) {
raw.preventDefault();
store.captureSync();
model.props.text.delete(inlineRange.index - 1, 1);
std.command.chain().pipe(addParagraphCommand).run();
return true;
}
return true;
}
raw.preventDefault();
if (model.props.type.startsWith('h') && model.props.collapsed) {
const parent = store.getParent(model);
if (!parent) return true;
const index = parent.children.indexOf(model);
if (index === -1) return true;
const collapsedSiblings = calculateCollapsedSiblings(model);
const rightText = model.props.text.split(inlineRange.index);
const newId = store.addBlock(
model.flavour,
{ type: model.props.type, text: rightText },
parent,
index + collapsedSiblings.length + 1
);
focusTextModel(std, newId);
return true;
}
if (isEnd) {
std.command.chain().pipe(addParagraphCommand).run();
return true;
}
std.command.chain().pipe(splitParagraphCommand).run();
return true;
},
Delete: ctx => {
const deleted = forwardDelete(std);
if (!deleted) {
return;
}
const event = ctx.get('keyboardState').raw;
event.preventDefault();
return true;
},
'Control-d': ctx => {
if (!IS_MAC) return;
const deleted = forwardDelete(std);
if (!deleted) {
return;
}
const event = ctx.get('keyboardState').raw;
event.preventDefault();
return true;
},
Tab: ctx => {
const [success] = std.command
.chain()
.pipe(canIndentParagraphCommand)
.pipe(indentParagraphCommand)
.run();
if (!success) {
return;
}
ctx.get('keyboardState').raw.preventDefault();
return true;
},
'Shift-Tab': ctx => {
const [success] = std.command
.chain()
.pipe(canDedentParagraphCommand)
.pipe(dedentParagraphCommand)
.run();
if (!success) {
return;
}
ctx.get('keyboardState').raw.preventDefault();
return true;
},
};
},
{
flavour: ParagraphBlockSchema.model.flavour,
}
);
export const ParagraphTextKeymapExtension = KeymapExtension(textKeymap, {
flavour: ParagraphBlockSchema.model.flavour,
});

View File

@@ -0,0 +1,17 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { ParagraphBlockSchemaExtension } from '@blocksuite/affine-model';
import { ParagraphBlockAdapterExtensions } from './adapters/extension';
export class ParagraphStoreExtension extends StoreExtensionProvider {
override name = 'affine-paragraph-block';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(ParagraphBlockSchemaExtension);
context.register(ParagraphBlockAdapterExtensions);
}
}

View File

@@ -0,0 +1,158 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const paragraphBlockStyles = css`
affine-paragraph {
box-sizing: border-box;
display: block;
font-size: var(--affine-font-base);
}
.affine-paragraph-block-container {
position: relative;
border-radius: 4px;
}
.affine-paragraph-rich-text-wrapper {
position: relative;
}
.affine-paragraph-block-container.highlight-comment {
background-color: ${unsafeCSSVarV2('block/comment/highlightActive')};
outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')};
}
affine-paragraph code {
font-size: calc(var(--affine-font-base) - 3px);
padding: 0px 4px 2px;
}
.h1 {
font-size: var(--affine-font-h-1);
font-weight: 700;
letter-spacing: -0.02em;
line-height: calc(1em + 8px);
margin-top: 18px;
margin-bottom: 10px;
}
.h1 code {
font-size: calc(var(--affine-font-base) + 10px);
padding: 0px 4px;
}
.h2 {
font-size: var(--affine-font-h-2);
font-weight: 600;
letter-spacing: -0.02em;
line-height: calc(1em + 10px);
margin-top: 14px;
margin-bottom: 10px;
}
.h2 code {
font-size: calc(var(--affine-font-base) + 8px);
padding: 0px 4px;
}
.h3 {
font-size: var(--affine-font-h-3);
font-weight: 600;
letter-spacing: -0.02em;
line-height: calc(1em + 8px);
margin-top: 12px;
margin-bottom: 10px;
}
.h3 code {
font-size: calc(var(--affine-font-base) + 6px);
padding: 0px 4px;
}
.h4 {
font-size: var(--affine-font-h-4);
font-weight: 600;
letter-spacing: -0.015em;
line-height: calc(1em + 8px);
margin-top: 12px;
margin-bottom: 10px;
}
.h4 code {
font-size: calc(var(--affine-font-base) + 4px);
padding: 0px 4px;
}
.h5 {
font-size: var(--affine-font-h-5);
font-weight: 600;
letter-spacing: -0.015em;
line-height: calc(1em + 8px);
margin-top: 12px;
margin-bottom: 10px;
}
.h5 code {
font-size: calc(var(--affine-font-base) + 2px);
padding: 0px 4px;
}
.h6 {
font-size: var(--affine-font-h-6);
font-weight: 600;
letter-spacing: -0.015em;
line-height: calc(1em + 8px);
margin-top: 12px;
margin-bottom: 10px;
}
.h6 code {
font-size: var(--affine-font-base);
padding: 0px 4px 2px;
}
.quote {
line-height: 26px;
padding-left: 17px;
margin-top: var(--affine-paragraph-space);
padding-top: 10px;
padding-bottom: 10px;
position: relative;
}
.quote::after {
content: '';
width: 2px;
height: calc(100% - 20px);
margin-top: 10px;
margin-bottom: 10px;
position: absolute;
left: 0;
top: 0;
background: var(--affine-quote-color);
border-radius: 18px;
}
.affine-paragraph-placeholder {
position: absolute;
display: none;
max-width: 100%;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
left: 0;
bottom: 0;
pointer-events: none;
color: var(--affine-black-30);
fill: var(--affine-black-30);
}
@media print {
.affine-paragraph-placeholder {
display: none !important;
}
}
.affine-paragraph-placeholder.visible {
display: block;
}
@media print {
.affine-paragraph-placeholder.visible {
display: none;
}
}
`;

View File

@@ -0,0 +1,106 @@
import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutHandlerExtension,
BlockLayoutHandlersIdentifier,
getSentenceRects,
segmentSentences,
} from '@blocksuite/affine-gfx-turbo-renderer';
import type { Container } from '@blocksuite/global/di';
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
import { clientToModelCoord, type ViewportRecord } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { ParagraphLayout } from './paragraph-painter.worker';
export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension<ParagraphLayout> {
readonly blockType = 'affine:paragraph';
static override setup(di: Container) {
di.addImpl(
BlockLayoutHandlersIdentifier('paragraph'),
ParagraphLayoutHandlerExtension
);
}
override queryLayout(
model: BlockModel,
host: EditorHost,
viewportRecord: ViewportRecord
): ParagraphLayout | null {
const component = host.std.view.getBlock(
model.id
) as GfxBlockComponent | null;
if (!component) return null;
const paragraphSelector =
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]';
const paragraphNode = component.querySelector(paragraphSelector);
if (!paragraphNode) return null;
const { zoom, viewScale } = viewportRecord;
const paragraph: ParagraphLayout = {
type: 'affine:paragraph',
sentences: [],
blockId: model.id,
rect: { x: 0, y: 0, w: 0, h: 0 },
};
const computedStyle = window.getComputedStyle(paragraphNode);
const fontSizeStr = computedStyle.fontSize;
const fontSize = parseInt(fontSizeStr);
const sentences = segmentSentences(paragraphNode.textContent || '');
const sentenceLayouts = sentences.map(sentence => {
const sentenceRects = getSentenceRects(paragraphNode, sentence);
const rects = sentenceRects.map(({ text, rect }) => {
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
rect.x,
rect.y,
]);
return {
text,
rect: {
x: modelX,
y: modelY,
w: rect.w / zoom / viewScale,
h: rect.h / zoom / viewScale,
},
};
});
return {
text: sentence,
rects,
fontSize,
};
});
paragraph.sentences.push(...sentenceLayouts);
return paragraph;
}
calculateBound(layout: ParagraphLayout) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
layout.sentences.forEach(sentence => {
sentence.rects.forEach(r => {
minX = Math.min(minX, r.rect.x);
minY = Math.min(minY, r.rect.y);
maxX = Math.max(maxX, r.rect.x + r.rect.w);
maxY = Math.max(maxY, r.rect.y + r.rect.h);
});
});
const rect: Rect = {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY,
};
return {
rect,
subRects: layout.sentences.flatMap(s => s.rects.map(r => r.rect)),
};
}
}

View File

@@ -0,0 +1,115 @@
import type {
BlockLayout,
BlockLayoutPainter,
TextRect,
WorkerToHostMessage,
} from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutPainterExtension,
getBaseline,
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
interface SentenceLayout {
text: string;
rects: TextRect[];
fontSize: number;
}
export interface ParagraphLayout extends BlockLayout {
type: 'affine:paragraph';
sentences: SentenceLayout[];
}
const debugSentenceBorder = false;
function isParagraphLayout(layout: BlockLayout): layout is ParagraphLayout {
return layout.type === 'affine:paragraph';
}
class ParagraphLayoutPainter implements BlockLayoutPainter {
private static readonly supportFontFace =
typeof FontFace !== 'undefined' &&
typeof self !== 'undefined' &&
'fonts' in self;
static readonly font = ParagraphLayoutPainter.supportFontFace
? new FontFace(
'Inter',
`url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ8UA3.woff2)`
)
: null;
static fontLoaded = !ParagraphLayoutPainter.supportFontFace;
static {
if (ParagraphLayoutPainter.supportFontFace && ParagraphLayoutPainter.font) {
// @ts-expect-error worker fonts API
self.fonts.add(ParagraphLayoutPainter.font);
ParagraphLayoutPainter.font
.load()
.then(() => {
ParagraphLayoutPainter.fontLoaded = true;
})
.catch(error => {
console.error('Failed to load Inter font:', error);
});
}
}
paint(
ctx: OffscreenCanvasRenderingContext2D,
layout: BlockLayout,
layoutBaseX: number,
layoutBaseY: number
): void {
if (!ParagraphLayoutPainter.fontLoaded) {
const message: WorkerToHostMessage = {
type: 'paintError',
error: 'Font not loaded',
blockType: 'affine:paragraph',
};
self.postMessage(message);
return;
}
if (!isParagraphLayout(layout)) {
console.warn(
'Expected paragraph layout but received different format:',
layout
);
return;
}
const renderedPositions = new Set<string>();
layout.sentences.forEach(sentence => {
const fontSize = sentence.fontSize;
const baselineY = getBaseline(fontSize);
if (fontSize !== 15) return; // TODO: fine-tune for heading font sizes
ctx.font = `${fontSize}px Inter`;
ctx.strokeStyle = 'yellow';
sentence.rects.forEach(textRect => {
const x = textRect.rect.x - layoutBaseX;
const y = textRect.rect.y - layoutBaseY;
const posKey = `${x},${y}`;
// Only render if we haven't rendered at this position before
if (renderedPositions.has(posKey)) return;
if (debugSentenceBorder) {
ctx.strokeRect(x, y, textRect.rect.w, textRect.rect.h);
}
ctx.fillStyle = 'black';
ctx.fillText(textRect.text, x, y + baselineY);
renderedPositions.add(posKey);
});
});
}
}
export const ParagraphLayoutPainterExtension = BlockLayoutPainterExtension(
'affine:paragraph',
ParagraphLayoutPainter
);

View File

@@ -0,0 +1,94 @@
import {
AttachmentBlockModel,
BookmarkBlockModel,
CalloutBlockModel,
CodeBlockModel,
DatabaseBlockModel,
DividerBlockModel,
ImageBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { EMBED_BLOCK_MODEL_LIST } from '@blocksuite/affine-shared/consts';
import {
getNextContentBlock,
matchModels,
} from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
type BlockStdScope,
TextSelection,
} from '@blocksuite/std';
export function forwardDelete(std: BlockStdScope) {
const { store, host } = std;
const text = std.selection.find(TextSelection);
if (!text) return;
const isCollapsed = text.isCollapsed();
const model = store.getBlock(text.from.blockId)?.model;
if (
!model ||
!matchModels(model, [ParagraphBlockModel]) ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const isEnd = isCollapsed && text.from.index === model.props.text.length;
if (!isEnd) return;
const parent = store.getParent(model);
if (!parent) return;
const nextSibling = store.getNext(model);
if (
matchModels(nextSibling, [
AttachmentBlockModel,
BookmarkBlockModel,
DatabaseBlockModel,
CodeBlockModel,
ImageBlockModel,
DividerBlockModel,
...EMBED_BLOCK_MODEL_LIST,
] as const)
) {
std.selection.setGroup('note', [
std.selection.create(BlockSelection, { blockId: nextSibling.id }),
]);
return true;
}
if (matchModels(nextSibling, [ParagraphBlockModel, ListBlockModel])) {
model.props.text.join(nextSibling.props.text);
if (nextSibling.children) {
const parent = store.getParent(nextSibling);
if (!parent) return false;
store.moveBlocks(nextSibling.children, parent, model, false);
}
store.deleteBlock(nextSibling);
return true;
}
const nextBlock = getNextContentBlock(host, model);
if (nextBlock?.text) {
model.props.text.join(nextBlock.text);
if (nextBlock.children) {
const parent = store.getParent(nextBlock);
if (!parent) return false;
store.moveBlocks(
nextBlock.children,
parent,
store.getParent(model),
false
);
}
store.deleteBlock(nextBlock);
return true;
}
if (nextBlock) {
std.selection.setGroup('note', [
std.selection.create(BlockSelection, { blockId: nextBlock.id }),
]);
}
return true;
}

View File

@@ -0,0 +1,214 @@
import {
AttachmentBlockModel,
BookmarkBlockModel,
CalloutBlockModel,
CodeBlockModel,
DatabaseBlockModel,
DividerBlockModel,
EdgelessTextBlockModel,
ImageBlockModel,
ListBlockModel,
NoteBlockModel,
ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import {
asyncSetInlineRange,
focusTextModel,
} from '@blocksuite/affine-rich-text';
import { EMBED_BLOCK_MODEL_LIST } from '@blocksuite/affine-shared/consts';
import type { ExtendedModel } from '@blocksuite/affine-shared/types';
import {
focusTitle,
getDocTitleInlineEditor,
getPrevContentBlock,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { IS_ANDROID, IS_MOBILE } from '@blocksuite/global/env';
import { BlockSelection, type EditorHost } from '@blocksuite/std';
import type { BlockModel, Text } from '@blocksuite/store';
/**
* Merge the paragraph with prev block
*
* Before press backspace
* - line1
* - line2
* - |aaa
* - line3
*
* After press backspace
* - line1
* - line2|aaa
* - line3
*/
export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) {
const doc = model.store;
const parent = doc.getParent(model);
if (!parent) return false;
const prevBlock = getPrevContentBlock(editorHost, model);
if (!prevBlock) {
return handleNoPreviousSibling(editorHost, model);
}
const modelIndex = parent.children.indexOf(model);
const prevSibling = doc.getPrev(model);
if (matchModels(prevSibling, [CalloutBlockModel])) {
editorHost.selection.setGroup('note', [
editorHost.selection.create(BlockSelection, {
blockId: prevSibling.id,
}),
]);
return true;
}
if (matchModels(prevBlock, [ParagraphBlockModel, ListBlockModel])) {
if (
(modelIndex === -1 || modelIndex === parent.children.length - 1) &&
parent.role === 'content'
)
return false;
const lengthBeforeJoin = prevBlock.props.text?.length ?? 0;
prevBlock.props.text.join(model.text as Text);
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
asyncSetInlineRange(editorHost.std, prevBlock, {
index: lengthBeforeJoin,
length: 0,
}).catch(console.error);
// due to some IME like Microsoft Swift IME on Android will reset range after join text,
// for example:
//
// $ZERO_WIDTH_FOR_EMPTY_LINE <--- p1
// |aaa <--- p2
//
// after pressing backspace, during beforeinput event, the native range is (p1, 1) -> (p2, 0)
// and after browser and IME handle the event, the native range is (p1, 1) -> (p1, 1)
//
// a|aa <--- p1
//
// so we need to set range again after join text.
if (IS_ANDROID) {
setTimeout(() => {
asyncSetInlineRange(editorHost.std, prevBlock, {
index: lengthBeforeJoin,
length: 0,
}).catch(console.error);
});
}
return true;
}
if (
matchModels(prevBlock, [
AttachmentBlockModel,
BookmarkBlockModel,
CodeBlockModel,
ImageBlockModel,
DividerBlockModel,
...EMBED_BLOCK_MODEL_LIST,
])
) {
// due to create a block selection will clear text selection, which lead
// the virtual keyboard to be auto closed on mobile. This behavior breaks
// the user experience.
if (!IS_MOBILE) {
const selection = editorHost.selection.create(BlockSelection, {
blockId: prevBlock.id,
});
editorHost.selection.setGroup('note', [selection]);
} else {
doc.deleteBlock(prevBlock);
}
if (model.text?.length === 0) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
}
return true;
}
if (matchModels(parent, [DatabaseBlockModel])) {
doc.deleteBlock(model);
focusTextModel(editorHost.std, prevBlock.id, prevBlock.text?.yText.length);
return true;
}
return false;
}
function handleNoPreviousSibling(editorHost: EditorHost, model: ExtendedModel) {
const doc = model.store;
const text = model.text;
const parent = doc.getParent(model);
if (!parent) return false;
const focusFirstBlockStart = () => {
const firstBlock = parent.firstChild();
if (firstBlock) {
focusTextModel(editorHost.std, firstBlock.id, 0);
}
};
if (matchModels(parent, [NoteBlockModel])) {
const hasTitleEditor = getDocTitleInlineEditor(editorHost);
const rootModel = model.store.root as RootBlockModel;
const title = rootModel.props.title;
const shouldHandleTitle = parent.isPageBlock() && hasTitleEditor;
doc.captureSync();
if (shouldHandleTitle) {
let textLength = 0;
if (text) {
textLength = text.length;
title.join(text);
}
if (model.children.length > 0 || doc.getNext(model)) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
}
// no other blocks, preserve a empty line
else {
text?.clear();
}
focusTitle(editorHost, title.length - textLength);
return true;
}
// Preserve at least one block to be able to focus on container click
if (
text?.length === 0 &&
(model.children.length > 0 || doc.getNext(model))
) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
focusFirstBlockStart();
return true;
}
}
if (
matchModels(parent, [EdgelessTextBlockModel]) &&
text?.length === 0 &&
(model.children.length > 0 || doc.getNext(model))
) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
focusFirstBlockStart();
return true;
}
return false;
}

View File

@@ -0,0 +1,66 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import { literal } from 'lit/static-html.js';
import { z } from 'zod';
import { effects } from './effects';
import { ParagraphMarkdownExtension } from './markdown.js';
import { ParagraphBlockConfigExtension } from './paragraph-block-config.js';
import {
ParagraphKeymapExtension,
ParagraphTextKeymapExtension,
} from './paragraph-keymap.js';
const placeholders = {
text: "Type '/' for commands",
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
quote: '',
};
const optionsSchema = z.object({
getPlaceholder: z.optional(
z.function().args(z.instanceof(ParagraphBlockModel)).returns(z.string())
),
});
export class ParagraphViewExtension extends ViewExtensionProvider<
z.infer<typeof optionsSchema>
> {
override name = 'affine-paragraph-block';
override schema = optionsSchema;
override effect(): void {
super.effect();
effects();
}
override setup(
context: ViewExtensionContext,
options?: z.infer<typeof optionsSchema>
) {
super.setup(context, options);
const getPlaceholder =
options?.getPlaceholder ?? (model => placeholders[model.props.type]);
context.register([
FlavourExtension('affine:paragraph'),
BlockViewExtension('affine:paragraph', literal`affine-paragraph`),
ParagraphTextKeymapExtension,
ParagraphKeymapExtension,
ParagraphBlockConfigExtension({
getPlaceholder,
}),
ParagraphMarkdownExtension,
]);
}
}

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../gfx/turbo-renderer" },
{ "path": "../../inlines/preset" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}