mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
344
blocksuite/affine/block-paragraph/src/adapters/html.ts
Normal file
344
blocksuite/affine/block-paragraph/src/adapters/html.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { ParagraphBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
const paragraphBlockMatchTags = new Set([
|
||||
'p',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'body',
|
||||
'div',
|
||||
'span',
|
||||
'footer',
|
||||
]);
|
||||
|
||||
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' &&
|
||||
!['li', 'p'].includes(o.parent.node.tagName) &&
|
||||
HastUtils.isParagraphLike(o.node)
|
||||
) {
|
||||
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();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'p': {
|
||||
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),
|
||||
},
|
||||
},
|
||||
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': {
|
||||
// eslint-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 (
|
||||
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
|
||||
);
|
||||
4
blocksuite/affine/block-paragraph/src/adapters/index.ts
Normal file
4
blocksuite/affine/block-paragraph/src/adapters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
export * from './plain-text.js';
|
||||
206
blocksuite/affine/block-paragraph/src/adapters/markdown.ts
Normal file
206
blocksuite/affine/block-paragraph/src/adapters/markdown.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { ParagraphBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Heading } from 'mdast';
|
||||
|
||||
const PARAGRAPH_MDAST_TYPE = new Set([
|
||||
'paragraph',
|
||||
'html',
|
||||
'heading',
|
||||
'blockquote',
|
||||
]);
|
||||
|
||||
const isParagraphMDASTType = (node: MarkdownAST) =>
|
||||
PARAGRAPH_MDAST_TYPE.has(node.type);
|
||||
|
||||
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
toMatch: o => isParagraphMDASTType(o.node),
|
||||
fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
switch (o.node.type) {
|
||||
case 'html': {
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [
|
||||
{
|
||||
insert: o.node.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
break;
|
||||
}
|
||||
case 'paragraph': {
|
||||
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': {
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: `h${o.node.depth}`,
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
break;
|
||||
}
|
||||
case 'blockquote': {
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'quote',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
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': {
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'blockquote',
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: deltaConverter.deltaToAST(text.delta),
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
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);
|
||||
239
blocksuite/affine/block-paragraph/src/adapters/notion-html.ts
Normal file
239
blocksuite/affine/block-paragraph/src/adapters/notion-html.ts
Normal 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': {
|
||||
// eslint-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);
|
||||
28
blocksuite/affine/block-paragraph/src/adapters/plain-text.ts
Normal file
28
blocksuite/affine/block-paragraph/src/adapters/plain-text.ts
Normal 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/inline';
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
/**
|
||||
* Add a paragraph next to the current block.
|
||||
*/
|
||||
export const addParagraphCommand: Command<
|
||||
never,
|
||||
'paragraphConvertedId',
|
||||
{
|
||||
blockId?: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { std } = ctx;
|
||||
const { doc, selection } = std;
|
||||
doc.captureSync();
|
||||
|
||||
let blockId = ctx.blockId;
|
||||
if (!blockId) {
|
||||
const text = selection.find('text');
|
||||
blockId = text?.blockId;
|
||||
}
|
||||
if (!blockId) return;
|
||||
|
||||
const model = doc.getBlock(blockId)?.model;
|
||||
if (!model) return;
|
||||
|
||||
let id: string;
|
||||
if (model.children.length > 0) {
|
||||
// before:
|
||||
// aaa|
|
||||
// bbb
|
||||
//
|
||||
// after:
|
||||
// aaa
|
||||
// |
|
||||
// bbb
|
||||
id = doc.addBlock('affine:paragraph', {}, model, 0);
|
||||
} else {
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index < 0) return;
|
||||
// before:
|
||||
// aaa|
|
||||
//
|
||||
// after:
|
||||
// aaa
|
||||
// |
|
||||
id = doc.addBlock('affine:paragraph', {}, parent, index + 1);
|
||||
}
|
||||
|
||||
focusTextModel(std, id);
|
||||
return next({ paragraphConvertedId: id });
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
|
||||
import { getLastNoteBlock } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
/**
|
||||
* Append a paragraph block at the end of the whole page.
|
||||
*/
|
||||
export const appendParagraphCommand: Command<
|
||||
never,
|
||||
never,
|
||||
{ text?: string }
|
||||
> = (ctx, next) => {
|
||||
const { std, text = '' } = ctx;
|
||||
const { doc } = std;
|
||||
if (!doc.root) return;
|
||||
|
||||
const note = getLastNoteBlock(doc);
|
||||
let noteId = note?.id;
|
||||
if (!noteId) {
|
||||
noteId = doc.addBlock('affine:note', {}, doc.root.id);
|
||||
}
|
||||
const id = doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{ text: new doc.Text(text) },
|
||||
noteId
|
||||
);
|
||||
|
||||
focusTextModel(std, id, text.length);
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { IndentContext } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
calculateCollapsedSiblings,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
export const canDedentParagraphCommand: Command<
|
||||
never,
|
||||
'indentContext',
|
||||
Partial<Omit<IndentContext, 'flavour' | 'type'>>
|
||||
> = (ctx, next) => {
|
||||
let { blockId, inlineIndex } = ctx;
|
||||
const { std } = ctx;
|
||||
const { selection, doc } = std;
|
||||
const text = selection.find('text');
|
||||
|
||||
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 = doc.getBlock(blockId)?.model;
|
||||
if (!model || !matchFlavours(model, ['affine:paragraph'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = doc.getParent(model);
|
||||
if (doc.readonly || !parent || parent.role !== 'content') {
|
||||
// Top most, can not unindent, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
const grandParent = doc.getParent(parent);
|
||||
if (!grandParent) return;
|
||||
|
||||
return next({
|
||||
indentContext: {
|
||||
blockId,
|
||||
inlineIndex,
|
||||
type: 'dedent',
|
||||
flavour: 'affine:paragraph',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const dedentParagraphCommand: Command<'indentContext'> = (ctx, next) => {
|
||||
const { indentContext: dedentContext, std } = ctx;
|
||||
const { doc, 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 = doc.getBlock(blockId)?.model;
|
||||
if (!model) return;
|
||||
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
const grandParent = doc.getParent(parent);
|
||||
if (!grandParent) return;
|
||||
|
||||
doc.captureSync();
|
||||
|
||||
if (
|
||||
matchFlavours(model, ['affine:paragraph']) &&
|
||||
model.type.startsWith('h') &&
|
||||
model.collapsed
|
||||
) {
|
||||
const collapsedSiblings = calculateCollapsedSiblings(model);
|
||||
doc.moveBlocks([model, ...collapsedSiblings], grandParent, parent, false);
|
||||
} else {
|
||||
const nextSiblings = doc.getNexts(model);
|
||||
doc.moveBlocks(nextSiblings, model);
|
||||
doc.moveBlocks([model], grandParent, parent, false);
|
||||
}
|
||||
|
||||
const textSelection = selection.find('text');
|
||||
if (textSelection) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
range.syncTextSelectionToRange(textSelection);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ListBlockModel } from '@blocksuite/affine-model';
|
||||
import type { IndentContext } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
calculateCollapsedSiblings,
|
||||
getNearestHeadingBefore,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
export const canIndentParagraphCommand: Command<
|
||||
never,
|
||||
'indentContext',
|
||||
Partial<Omit<IndentContext, 'flavour' | 'type'>>
|
||||
> = (cxt, next) => {
|
||||
let { blockId, inlineIndex } = cxt;
|
||||
const { std } = cxt;
|
||||
const { selection, doc } = std;
|
||||
const { schema } = doc;
|
||||
|
||||
if (!blockId) {
|
||||
const text = selection.find('text');
|
||||
/**
|
||||
* 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.doc.getBlock(blockId)?.model;
|
||||
if (!model || !matchFlavours(model, ['affine:paragraph'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSibling = doc.getPrev(model);
|
||||
if (
|
||||
doc.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'> = (ctx, next) => {
|
||||
const { indentContext, std } = ctx;
|
||||
const { doc, 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 = doc.getBlock(blockId)?.model;
|
||||
if (!model) return;
|
||||
|
||||
const previousSibling = doc.getPrev(model);
|
||||
if (!previousSibling) return;
|
||||
|
||||
doc.captureSync();
|
||||
|
||||
{
|
||||
// > # 123
|
||||
// > # 456
|
||||
//
|
||||
// we need to update 123 collapsed state to false when indent 456
|
||||
const nearestHeading = getNearestHeadingBefore(model);
|
||||
if (
|
||||
nearestHeading &&
|
||||
matchFlavours(nearestHeading, ['affine:paragraph']) &&
|
||||
nearestHeading.collapsed
|
||||
) {
|
||||
doc.updateBlock(nearestHeading, {
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
matchFlavours(model, ['affine:paragraph']) &&
|
||||
model.type.startsWith('h') &&
|
||||
model.collapsed
|
||||
) {
|
||||
const collapsedSiblings = calculateCollapsedSiblings(model);
|
||||
doc.moveBlocks([model, ...collapsedSiblings], previousSibling);
|
||||
} else {
|
||||
doc.moveBlocks([model], previousSibling);
|
||||
}
|
||||
|
||||
{
|
||||
// 123
|
||||
// > # 456
|
||||
// 789
|
||||
//
|
||||
// we need to update 456 collapsed state to false when indent 789
|
||||
const nearestHeading = getNearestHeadingBefore(model);
|
||||
if (
|
||||
nearestHeading &&
|
||||
matchFlavours(nearestHeading, ['affine:paragraph']) &&
|
||||
nearestHeading.collapsed
|
||||
) {
|
||||
doc.updateBlock(nearestHeading, {
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// update collapsed state of affine list
|
||||
if (
|
||||
matchFlavours(previousSibling, ['affine:list']) &&
|
||||
previousSibling.collapsed
|
||||
) {
|
||||
doc.updateBlock(previousSibling, {
|
||||
collapsed: false,
|
||||
} as Partial<ListBlockModel>);
|
||||
}
|
||||
|
||||
const textSelection = selection.find('text');
|
||||
if (textSelection) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
range.syncTextSelectionToRange(textSelection);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
23
blocksuite/affine/block-paragraph/src/commands/index.ts
Normal file
23
blocksuite/affine/block-paragraph/src/commands/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { BlockCommands } from '@blocksuite/block-std';
|
||||
|
||||
import { addParagraphCommand } from './add-paragraph.js';
|
||||
import { appendParagraphCommand } from './append-paragraph.js';
|
||||
import {
|
||||
canDedentParagraphCommand,
|
||||
dedentParagraphCommand,
|
||||
} from './dedent-paragraph.js';
|
||||
import {
|
||||
canIndentParagraphCommand,
|
||||
indentParagraphCommand,
|
||||
} from './indent-paragraph.js';
|
||||
import { splitParagraphCommand } from './split-paragraph.js';
|
||||
|
||||
export const commands: BlockCommands = {
|
||||
appendParagraph: appendParagraphCommand,
|
||||
splitParagraph: splitParagraphCommand,
|
||||
addParagraph: addParagraphCommand,
|
||||
canIndentParagraph: canIndentParagraphCommand,
|
||||
canDedentParagraph: canDedentParagraphCommand,
|
||||
indentParagraph: indentParagraphCommand,
|
||||
dedentParagraph: dedentParagraphCommand,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
focusTextModel,
|
||||
getInlineEditorByModel,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import { matchFlavours } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
export const splitParagraphCommand: Command<
|
||||
never,
|
||||
'paragraphConvertedId',
|
||||
{
|
||||
blockId?: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { std } = ctx;
|
||||
const { doc, host, selection } = std;
|
||||
let blockId = ctx.blockId;
|
||||
if (!blockId) {
|
||||
const text = selection.find('text');
|
||||
blockId = text?.blockId;
|
||||
}
|
||||
if (!blockId) return;
|
||||
|
||||
const model = doc.getBlock(blockId)?.model;
|
||||
if (!model || !matchFlavours(model, ['affine:paragraph'])) return;
|
||||
|
||||
const inlineEditor = getInlineEditorByModel(host, 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.text.yText.length < splitIndex + splitLength) return;
|
||||
|
||||
if (model.children.length > 0 && splitIndex > 0) {
|
||||
doc.captureSync();
|
||||
const right = model.text.split(splitIndex, splitLength);
|
||||
const id = doc.addBlock(
|
||||
model.flavour as BlockSuite.Flavour,
|
||||
{
|
||||
text: right,
|
||||
type: model.type,
|
||||
},
|
||||
model,
|
||||
0
|
||||
);
|
||||
focusTextModel(std, id);
|
||||
return next({ paragraphConvertedId: id });
|
||||
}
|
||||
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index < 0) return;
|
||||
doc.captureSync();
|
||||
const right = model.text.split(splitIndex, splitLength);
|
||||
const id = doc.addBlock(
|
||||
model.flavour,
|
||||
{
|
||||
text: right,
|
||||
type: model.type,
|
||||
},
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
const newModel = doc.getBlock(id)?.model;
|
||||
if (newModel) {
|
||||
doc.moveBlocks(model.children, newModel);
|
||||
} else {
|
||||
console.error('Failed to find the new model split from the paragraph');
|
||||
}
|
||||
focusTextModel(std, id);
|
||||
return next({ paragraphConvertedId: id });
|
||||
};
|
||||
45
blocksuite/affine/block-paragraph/src/effects.ts
Normal file
45
blocksuite/affine/block-paragraph/src/effects.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { IndentContext } from '@blocksuite/affine-shared/types';
|
||||
|
||||
import type { addParagraphCommand } from './commands/add-paragraph.js';
|
||||
import type { appendParagraphCommand } from './commands/append-paragraph.js';
|
||||
import type {
|
||||
canDedentParagraphCommand,
|
||||
dedentParagraphCommand,
|
||||
} from './commands/dedent-paragraph.js';
|
||||
import type {
|
||||
canIndentParagraphCommand,
|
||||
indentParagraphCommand,
|
||||
} from './commands/indent-paragraph.js';
|
||||
import type { splitParagraphCommand } from './commands/split-paragraph.js';
|
||||
import { effects as ParagraphHeadingIconEffects } from './heading-icon.js';
|
||||
import { ParagraphBlockComponent } from './paragraph-block.js';
|
||||
import type { ParagraphBlockService } from './paragraph-service.js';
|
||||
|
||||
export function effects() {
|
||||
ParagraphHeadingIconEffects();
|
||||
customElements.define('affine-paragraph', ParagraphBlockComponent);
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface BlockServices {
|
||||
'affine:paragraph': ParagraphBlockService;
|
||||
}
|
||||
interface Commands {
|
||||
addParagraph: typeof addParagraphCommand;
|
||||
appendParagraph: typeof appendParagraphCommand;
|
||||
canIndentParagraph: typeof canIndentParagraphCommand;
|
||||
canDedentParagraph: typeof canDedentParagraphCommand;
|
||||
dedentParagraph: typeof dedentParagraphCommand;
|
||||
indentParagraph: typeof indentParagraphCommand;
|
||||
splitParagraph: typeof splitParagraphCommand;
|
||||
}
|
||||
interface CommandContext {
|
||||
paragraphConvertedId?: string;
|
||||
indentContext?: IndentContext;
|
||||
}
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-paragraph': ParagraphBlockComponent;
|
||||
}
|
||||
}
|
||||
88
blocksuite/affine/block-paragraph/src/heading-icon.ts
Normal file
88
blocksuite/affine/block-paragraph/src/heading-icon.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
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 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(-64px);
|
||||
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.type;
|
||||
if (!type.startsWith('h')) return nothing;
|
||||
|
||||
const i = parseInt(type.slice(1));
|
||||
|
||||
return html`<div class="heading-icon">${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;
|
||||
}
|
||||
}
|
||||
4
blocksuite/affine/block-paragraph/src/index.ts
Normal file
4
blocksuite/affine/block-paragraph/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './adapters/index.js';
|
||||
export * from './paragraph-block.js';
|
||||
export * from './paragraph-service.js';
|
||||
export * from './paragraph-spec.js';
|
||||
323
blocksuite/affine/block-paragraph/src/paragraph-block.ts
Normal file
323
blocksuite/affine/block-paragraph/src/paragraph-block.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
DefaultInlineManagerExtension,
|
||||
type RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button';
|
||||
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BLOCK_CHILDREN_CONTAINER_PADDING_LEFT,
|
||||
NOTE_SELECTOR,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
calculateCollapsedSiblings,
|
||||
getNearestHeadingBefore,
|
||||
getViewportElement,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent } from '@blocksuite/block-std';
|
||||
import { getInlineRangeProvider } from '@blocksuite/block-std';
|
||||
import type { InlineRangeProvider } from '@blocksuite/inline';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, nothing, type TemplateResult } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
|
||||
import type { ParagraphBlockService } from './paragraph-service.js';
|
||||
import { paragraphBlockStyles } from './styles.js';
|
||||
|
||||
export class ParagraphBlockComponent extends CaptionedBlockComponent<
|
||||
ParagraphBlockModel,
|
||||
ParagraphBlockService
|
||||
> {
|
||||
static override styles = paragraphBlockStyles;
|
||||
|
||||
private _composing = signal(false);
|
||||
|
||||
private _displayPlaceholder = signal(false);
|
||||
|
||||
private _inlineRangeProvider: InlineRangeProvider | null = null;
|
||||
|
||||
private _isInDatabase = () => {
|
||||
let parent = this.parentElement;
|
||||
while (parent && parent !== document.body) {
|
||||
if (parent.tagName.toLowerCase() === 'affine-database') {
|
||||
return true;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
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 markdownShortcutHandler() {
|
||||
return this.inlineManager.markdownShortcutHandler;
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(NOTE_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.doc.readonly) {
|
||||
this._displayPlaceholder.value = false;
|
||||
return;
|
||||
}
|
||||
const textSelection = this.host.selection.find('text');
|
||||
const isCollapsed = textSelection?.isCollapsed() ?? false;
|
||||
if (!this.selected || !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.type$.value;
|
||||
if (!type.startsWith('h') && this.model.collapsed) {
|
||||
this.model.collapsed = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const collapsed = this.model.collapsed$.value;
|
||||
this._readonlyCollapsed = collapsed;
|
||||
|
||||
// reset text selection when selected block is collapsed
|
||||
if (this.model.type.startsWith('h') && collapsed) {
|
||||
const collapsedSiblings = this.collapsedSiblings;
|
||||
const textSelection = this.host.selection.find('text');
|
||||
const blockSelections = this.host.selection.filter('block');
|
||||
|
||||
if (
|
||||
textSelection &&
|
||||
collapsedSiblings.some(
|
||||
sibling => sibling.id === textSelection.blockId
|
||||
)
|
||||
) {
|
||||
this.host.selection.clear(['text']);
|
||||
}
|
||||
|
||||
if (
|
||||
blockSelections.some(selection =>
|
||||
collapsedSiblings.some(
|
||||
sibling => sibling.id === selection.blockId
|
||||
)
|
||||
)
|
||||
) {
|
||||
this.host.selection.clear(['block']);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// > # 123
|
||||
// # 456
|
||||
//
|
||||
// we need to update collapsed state of 123 when 456 converted to text
|
||||
let beforeType = this.model.type;
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const type = this.model.type$.value;
|
||||
if (beforeType !== type && !type.startsWith('h')) {
|
||||
const nearestHeading = getNearestHeadingBefore(this.model);
|
||||
if (
|
||||
nearestHeading &&
|
||||
nearestHeading.type.startsWith('h') &&
|
||||
nearestHeading.collapsed &&
|
||||
!this.doc.readonly
|
||||
) {
|
||||
nearestHeading.collapsed = false;
|
||||
}
|
||||
}
|
||||
beforeType = type;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override async getUpdateComplete() {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this._richTextElement?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override renderBlock(): TemplateResult<1> {
|
||||
const { type$ } = this.model;
|
||||
const collapsed = this.doc.readonly
|
||||
? this._readonlyCollapsed
|
||||
: this.model.collapsed;
|
||||
const collapsedSiblings = this.collapsedSiblings;
|
||||
|
||||
let style = html``;
|
||||
if (this.model.type.startsWith('h') && collapsed) {
|
||||
style = html`
|
||||
<style>
|
||||
${collapsedSiblings.map(sibling =>
|
||||
unsafeHTML(`
|
||||
[data-block-id="${sibling.id}"] {
|
||||
display: none;
|
||||
}
|
||||
`)
|
||||
)}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
const children = html`<div
|
||||
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}
|
||||
<div class="affine-paragraph-block-container">
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-paragraph-rich-text-wrapper': true,
|
||||
[type$.value]: true,
|
||||
[TOGGLE_BUTTON_PARENT_CLASS]: true,
|
||||
})}
|
||||
>
|
||||
${this.model.type.startsWith('h') && collapsedSiblings.length > 0
|
||||
? html`
|
||||
<affine-paragraph-heading-icon
|
||||
.model=${this.model}
|
||||
></affine-paragraph-heading-icon>
|
||||
<blocksuite-toggle-button
|
||||
.collapsed=${collapsed}
|
||||
.updateCollapsed=${(value: boolean) => {
|
||||
if (this.doc.readonly) {
|
||||
this._readonlyCollapsed = value;
|
||||
} else {
|
||||
this.doc.captureSync();
|
||||
this.doc.updateBlock(this.model, {
|
||||
collapsed: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
></blocksuite-toggle-button>
|
||||
`
|
||||
: nothing}
|
||||
<rich-text
|
||||
.yText=${this.model.text.yText}
|
||||
.inlineEventSource=${this.topContenteditableElement ?? nothing}
|
||||
.undoManager=${this.doc.history}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.markdownShortcutHandler=${this.markdownShortcutHandler}
|
||||
.embedChecker=${this.embedChecker}
|
||||
.readonly=${this.doc.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.service.placeholderGenerator(this.model)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${children}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _readonlyCollapsed = false;
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
|
||||
override accessor blockContainerStyles = {
|
||||
margin: 'var(--affine-paragraph-margin, 10px 0)',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
ParagraphBlockModel,
|
||||
ParagraphBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
calculateCollapsedSiblings,
|
||||
captureEventTarget,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
|
||||
export const ParagraphDragHandleOption = DragHandleConfigExtension({
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
onDragStart: ({ state, startDragging, anchorBlockId, editorHost }) => {
|
||||
if (!anchorBlockId) return false;
|
||||
|
||||
const element = captureEventTarget(state.raw.target);
|
||||
const dragByHandle = !!element?.closest('affine-drag-handle-widget');
|
||||
if (!dragByHandle) return false;
|
||||
|
||||
const block = editorHost.doc.getBlock(anchorBlockId);
|
||||
if (!block) return false;
|
||||
const model = block.model;
|
||||
if (
|
||||
matchFlavours(model, ['affine:paragraph']) &&
|
||||
model.type.startsWith('h') &&
|
||||
model.collapsed
|
||||
) {
|
||||
const collapsedSiblings = calculateCollapsedSiblings(model).flatMap(
|
||||
sibling => editorHost.view.getBlock(sibling.id) ?? []
|
||||
);
|
||||
const modelElement = editorHost.view.getBlock(anchorBlockId);
|
||||
if (!modelElement) return false;
|
||||
startDragging([modelElement, ...collapsedSiblings], state);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
onDragEnd: ({ draggingElements }) => {
|
||||
draggingElements
|
||||
.filter(el => matchFlavours(el.model, ['affine:paragraph']))
|
||||
.forEach(el => {
|
||||
const model = el.model;
|
||||
if (!(model instanceof ParagraphBlockModel)) return;
|
||||
model.collapsed = false;
|
||||
});
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
223
blocksuite/affine/block-paragraph/src/paragraph-keymap.ts
Normal file
223
blocksuite/affine/block-paragraph/src/paragraph-keymap.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
focusTextModel,
|
||||
getInlineEditorByModel,
|
||||
markdownInput,
|
||||
textKeymap,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import { ParagraphBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
calculateCollapsedSiblings,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { KeymapExtension } from '@blocksuite/block-std';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
|
||||
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('text');
|
||||
if (!text) return;
|
||||
const isCollapsed = text.isCollapsed();
|
||||
const isStart = isCollapsed && text.from.index === 0;
|
||||
if (!isStart) return;
|
||||
|
||||
const { doc } = std;
|
||||
const model = doc.getBlock(text.from.blockId)?.model;
|
||||
if (!model || !matchFlavours(model, ['affine:paragraph'])) return;
|
||||
|
||||
// const { model, doc } = this;
|
||||
const event = ctx.get('keyboardState').raw;
|
||||
event.preventDefault();
|
||||
|
||||
// When deleting at line start of a paragraph block,
|
||||
// firstly switch it to normal text, then delete this empty block.
|
||||
if (model.type !== 'text') {
|
||||
// Try to switch to normal text
|
||||
doc.captureSync();
|
||||
doc.updateBlock(model, { type: 'text' });
|
||||
return true;
|
||||
}
|
||||
|
||||
const merged = mergeWithPrev(std.host, model);
|
||||
if (merged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std.command.chain().canDedentParagraph().dedentParagraph().run();
|
||||
return true;
|
||||
},
|
||||
'Mod-Enter': ctx => {
|
||||
const { doc } = std;
|
||||
const text = std.selection.find('text');
|
||||
if (!text) return;
|
||||
const model = doc.getBlock(text.from.blockId)?.model;
|
||||
if (!model || !matchFlavours(model, ['affine:paragraph'])) return;
|
||||
const inlineEditor = getInlineEditorByModel(
|
||||
std.host,
|
||||
text.from.blockId
|
||||
);
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange || !inlineEditor) return;
|
||||
const raw = ctx.get('keyboardState').raw;
|
||||
raw.preventDefault();
|
||||
if (model.type === 'quote') {
|
||||
doc.captureSync();
|
||||
inlineEditor.insertText(inlineRange, '\n');
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
std.command.exec('addParagraph');
|
||||
return true;
|
||||
},
|
||||
Enter: ctx => {
|
||||
const { doc } = std;
|
||||
const text = std.selection.find('text');
|
||||
if (!text) return;
|
||||
const model = doc.getBlock(text.from.blockId)?.model;
|
||||
if (!model || !matchFlavours(model, ['affine:paragraph'])) return;
|
||||
const inlineEditor = getInlineEditorByModel(
|
||||
std.host,
|
||||
text.from.blockId
|
||||
);
|
||||
const range = inlineEditor?.getInlineRange();
|
||||
if (!range || !inlineEditor) return;
|
||||
|
||||
const raw = ctx.get('keyboardState').raw;
|
||||
const isEnd = model.text.length === range.index;
|
||||
|
||||
if (model.type === 'quote') {
|
||||
const textStr = model.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();
|
||||
doc.captureSync();
|
||||
model.text.delete(range.index - 1, 1);
|
||||
std.command.exec('addParagraph');
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
raw.preventDefault();
|
||||
|
||||
if (markdownInput(std, model.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (model.type.startsWith('h') && model.collapsed) {
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return true;
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index === -1) return true;
|
||||
const collapsedSiblings = calculateCollapsedSiblings(model);
|
||||
|
||||
const rightText = model.text.split(range.index);
|
||||
const newId = doc.addBlock(
|
||||
model.flavour,
|
||||
{ type: model.type, text: rightText },
|
||||
parent,
|
||||
index + collapsedSiblings.length + 1
|
||||
);
|
||||
|
||||
focusTextModel(std, newId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isEnd) {
|
||||
std.command.exec('addParagraph');
|
||||
return true;
|
||||
}
|
||||
|
||||
std.command.exec('splitParagraph');
|
||||
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;
|
||||
},
|
||||
Space: ctx => {
|
||||
if (!markdownInput(std)) {
|
||||
return;
|
||||
}
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
'Shift-Space': ctx => {
|
||||
if (!markdownInput(std)) {
|
||||
return;
|
||||
}
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
Tab: ctx => {
|
||||
const [success] = std.command
|
||||
.chain()
|
||||
.canIndentParagraph()
|
||||
.indentParagraph()
|
||||
.run();
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
'Shift-Tab': ctx => {
|
||||
const [success] = std.command
|
||||
.chain()
|
||||
.canDedentParagraph()
|
||||
.dedentParagraph()
|
||||
.run();
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
}
|
||||
);
|
||||
|
||||
export const ParagraphTextKeymapExtension = KeymapExtension(textKeymap, {
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
});
|
||||
26
blocksuite/affine/block-paragraph/src/paragraph-service.ts
Normal file
26
blocksuite/affine/block-paragraph/src/paragraph-service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
type ParagraphBlockModel,
|
||||
ParagraphBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { BlockService } from '@blocksuite/block-std';
|
||||
|
||||
export class ParagraphBlockService extends BlockService {
|
||||
static override readonly flavour = ParagraphBlockSchema.model.flavour;
|
||||
|
||||
placeholderGenerator: (model: ParagraphBlockModel) => string = model => {
|
||||
if (model.type === 'text') {
|
||||
return "Type '/' for commands";
|
||||
}
|
||||
|
||||
const placeholders = {
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
h4: 'Heading 4',
|
||||
h5: 'Heading 5',
|
||||
h6: 'Heading 6',
|
||||
quote: '',
|
||||
};
|
||||
return placeholders[model.type];
|
||||
};
|
||||
}
|
||||
25
blocksuite/affine/block-paragraph/src/paragraph-spec.ts
Normal file
25
blocksuite/affine/block-paragraph/src/paragraph-spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
BlockViewExtension,
|
||||
CommandExtension,
|
||||
type ExtensionType,
|
||||
FlavourExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { commands } from './commands/index.js';
|
||||
import { ParagraphDragHandleOption } from './paragraph-drag-extension.js';
|
||||
import {
|
||||
ParagraphKeymapExtension,
|
||||
ParagraphTextKeymapExtension,
|
||||
} from './paragraph-keymap.js';
|
||||
import { ParagraphBlockService } from './paragraph-service.js';
|
||||
|
||||
export const ParagraphBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:paragraph'),
|
||||
ParagraphBlockService,
|
||||
CommandExtension(commands),
|
||||
BlockViewExtension('affine:paragraph', literal`affine-paragraph`),
|
||||
ParagraphTextKeymapExtension,
|
||||
ParagraphKeymapExtension,
|
||||
ParagraphDragHandleOption,
|
||||
];
|
||||
148
blocksuite/affine/block-paragraph/src/styles.ts
Normal file
148
blocksuite/affine/block-paragraph/src/styles.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { EMBED_BLOCK_FLAVOUR_LIST } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
getNextContentBlock,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
|
||||
export function forwardDelete(std: BlockStdScope) {
|
||||
const { doc, host } = std;
|
||||
const text = std.selection.find('text');
|
||||
if (!text) return;
|
||||
const isCollapsed = text.isCollapsed();
|
||||
const model = doc.getBlock(text.from.blockId)?.model;
|
||||
if (!model || !matchFlavours(model, ['affine:paragraph'])) return;
|
||||
const isEnd = isCollapsed && text.from.index === model.text.length;
|
||||
if (!isEnd) return;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
const nextSibling = doc.getNext(model);
|
||||
const ignoreForwardDeleteFlavourList: BlockSuite.Flavour[] = [
|
||||
'affine:attachment',
|
||||
'affine:bookmark',
|
||||
// @ts-expect-error TODO: should be fixed after database model is migrated to affine-models
|
||||
'affine:database',
|
||||
'affine:code',
|
||||
'affine:image',
|
||||
'affine:divider',
|
||||
...EMBED_BLOCK_FLAVOUR_LIST,
|
||||
];
|
||||
|
||||
if (matchFlavours(nextSibling, ignoreForwardDeleteFlavourList)) {
|
||||
std.selection.setGroup('note', [
|
||||
std.selection.create('block', { blockId: nextSibling.id }),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nextSibling?.text) {
|
||||
model.text.join(nextSibling.text);
|
||||
if (nextSibling.children) {
|
||||
const parent = doc.getParent(nextSibling);
|
||||
if (!parent) return false;
|
||||
doc.moveBlocks(nextSibling.children, parent, model, false);
|
||||
}
|
||||
|
||||
doc.deleteBlock(nextSibling);
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextBlock = getNextContentBlock(host, model);
|
||||
if (nextBlock?.text) {
|
||||
model.text.join(nextBlock.text);
|
||||
if (nextBlock.children) {
|
||||
const parent = doc.getParent(nextBlock);
|
||||
if (!parent) return false;
|
||||
doc.moveBlocks(nextBlock.children, parent, doc.getParent(model), false);
|
||||
}
|
||||
doc.deleteBlock(nextBlock);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nextBlock) {
|
||||
std.selection.setGroup('note', [
|
||||
std.selection.create('block', { blockId: nextBlock.id }),
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
141
blocksuite/affine/block-paragraph/src/utils/merge-with-prev.ts
Normal file
141
blocksuite/affine/block-paragraph/src/utils/merge-with-prev.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
asyncSetInlineRange,
|
||||
focusTextModel,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { EMBED_BLOCK_FLAVOUR_LIST } from '@blocksuite/affine-shared/consts';
|
||||
import type { ExtendedModel } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
focusTitle,
|
||||
getDocTitleInlineEditor,
|
||||
getPrevContentBlock,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-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.doc;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return false;
|
||||
|
||||
if (matchFlavours(parent, ['affine:edgeless-text'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prevBlock = getPrevContentBlock(editorHost, model);
|
||||
if (!prevBlock) {
|
||||
return handleNoPreviousSibling(editorHost, model);
|
||||
}
|
||||
|
||||
if (matchFlavours(prevBlock, ['affine:paragraph', 'affine:list'])) {
|
||||
const modelIndex = parent.children.indexOf(model);
|
||||
if (
|
||||
(modelIndex === -1 || modelIndex === parent.children.length - 1) &&
|
||||
parent.role === 'content'
|
||||
)
|
||||
return false;
|
||||
|
||||
const lengthBeforeJoin = prevBlock.text?.length ?? 0;
|
||||
prevBlock.text.join(model.text as Text);
|
||||
doc.deleteBlock(model, {
|
||||
bringChildrenTo: parent,
|
||||
});
|
||||
asyncSetInlineRange(editorHost, prevBlock, {
|
||||
index: lengthBeforeJoin,
|
||||
length: 0,
|
||||
}).catch(console.error);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
matchFlavours(prevBlock, [
|
||||
'affine:attachment',
|
||||
'affine:bookmark',
|
||||
'affine:code',
|
||||
'affine:image',
|
||||
'affine:divider',
|
||||
...EMBED_BLOCK_FLAVOUR_LIST,
|
||||
])
|
||||
) {
|
||||
const selection = editorHost.selection.create('block', {
|
||||
blockId: prevBlock.id,
|
||||
});
|
||||
editorHost.selection.setGroup('note', [selection]);
|
||||
|
||||
if (model.text?.length === 0) {
|
||||
doc.deleteBlock(model, {
|
||||
bringChildrenTo: parent,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-expect-error TODO: should be fixed after database model is migrated to affine-models
|
||||
if (matchFlavours(parent, ['affine:database'])) {
|
||||
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.doc;
|
||||
const text = model.text;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return false;
|
||||
const titleEditor = getDocTitleInlineEditor(editorHost);
|
||||
// Probably no title, e.g. in edgeless mode
|
||||
if (!titleEditor) {
|
||||
if (
|
||||
matchFlavours(parent, ['affine:edgeless-text']) ||
|
||||
model.children.length > 0
|
||||
) {
|
||||
doc.deleteBlock(model, {
|
||||
bringChildrenTo: parent,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootModel = model.doc.root as RootBlockModel;
|
||||
const title = rootModel.title;
|
||||
|
||||
doc.captureSync();
|
||||
let textLength = 0;
|
||||
if (text) {
|
||||
textLength = text.length;
|
||||
title.join(text);
|
||||
}
|
||||
|
||||
// Preserve at least one block to be able to focus on container click
|
||||
if (doc.getNext(model) || model.children.length > 0) {
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return false;
|
||||
doc.deleteBlock(model, {
|
||||
bringChildrenTo: parent,
|
||||
});
|
||||
} else {
|
||||
text?.clear();
|
||||
}
|
||||
focusTitle(editorHost, title.length - textLength);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user