mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 22:37:04 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
13
blocksuite/affine/blocks/paragraph/src/adapters/extension.ts
Normal file
13
blocksuite/affine/blocks/paragraph/src/adapters/extension.ts
Normal 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,
|
||||
];
|
||||
359
blocksuite/affine/blocks/paragraph/src/adapters/html.ts
Normal file
359
blocksuite/affine/blocks/paragraph/src/adapters/html.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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)
|
||||
) {
|
||||
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/blocks/paragraph/src/adapters/index.ts
Normal file
4
blocksuite/affine/blocks/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/blocks/paragraph/src/adapters/markdown.ts
Normal file
206
blocksuite/affine/blocks/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/store';
|
||||
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/blocks/paragraph/src/adapters/notion-html.ts
Normal file
239
blocksuite/affine/blocks/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);
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
11
blocksuite/affine/blocks/paragraph/src/commands/index.ts
Normal file
11
blocksuite/affine/blocks/paragraph/src/commands/index.ts
Normal 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';
|
||||
@@ -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 });
|
||||
};
|
||||
13
blocksuite/affine/blocks/paragraph/src/effects.ts
Normal file
13
blocksuite/affine/blocks/paragraph/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
92
blocksuite/affine/blocks/paragraph/src/heading-icon.ts
Normal file
92
blocksuite/affine/blocks/paragraph/src/heading-icon.ts
Normal 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(-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.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;
|
||||
}
|
||||
}
|
||||
7
blocksuite/affine/blocks/paragraph/src/index.ts
Normal file
7
blocksuite/affine/blocks/paragraph/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './adapters/index.js';
|
||||
export * from './commands';
|
||||
export * from './paragraph-block.js';
|
||||
export * from './paragraph-block-config.js';
|
||||
export * from './paragraph-spec.js';
|
||||
export * from './turbo/paragraph-layout-handler';
|
||||
export * from './turbo/paragraph-painter.worker';
|
||||
@@ -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');
|
||||
337
blocksuite/affine/blocks/paragraph/src/paragraph-block.ts
Normal file
337
blocksuite/affine/blocks/paragraph/src/paragraph-block.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
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 { 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, 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 { 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 _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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.doc.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(() => {
|
||||
const collapsed = this.model.props.collapsed$.value;
|
||||
this._readonlyCollapsed = collapsed;
|
||||
|
||||
// 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.doc.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 { type$ } = this.model.props;
|
||||
const collapsed = this.doc.readonly
|
||||
? this._readonlyCollapsed
|
||||
: this.model.props.collapsed;
|
||||
const collapsedSiblings = this.collapsedSiblings;
|
||||
|
||||
let style = html``;
|
||||
if (this.model.props.type$.value.startsWith('h') && collapsed) {
|
||||
style = html`
|
||||
<style>
|
||||
${collapsedSiblings.map(sibling =>
|
||||
unsafeHTML(`
|
||||
[data-block-id="${sibling.id}"] {
|
||||
display: none !important;
|
||||
}
|
||||
`)
|
||||
)}
|
||||
</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}
|
||||
<style>
|
||||
.affine-paragraph-block-container[data-has-collapsed-siblings='false']
|
||||
affine-paragraph-heading-icon
|
||||
.heading-icon {
|
||||
transform: translateX(-48px);
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="affine-paragraph-block-container"
|
||||
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}
|
||||
.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.props.text.yText}
|
||||
.inlineEventSource=${this.topContenteditableElement ?? nothing}
|
||||
.undoManager=${this.doc.history}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.markdownMatches=${this.inlineManager?.markdownMatches}
|
||||
.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._placeholder}
|
||||
</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)',
|
||||
};
|
||||
}
|
||||
249
blocksuite/affine/blocks/paragraph/src/paragraph-keymap.ts
Normal file
249
blocksuite/affine/blocks/paragraph/src/paragraph-keymap.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { textKeymap } from '@blocksuite/affine-inline-preset';
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
ParagraphBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
focusTextModel,
|
||||
getInlineEditorByModel,
|
||||
markdownInput,
|
||||
} 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 { 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;
|
||||
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 (markdownInput(std, model.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
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()
|
||||
.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,
|
||||
});
|
||||
34
blocksuite/affine/blocks/paragraph/src/paragraph-spec.ts
Normal file
34
blocksuite/affine/blocks/paragraph/src/paragraph-spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { ParagraphBlockAdapterExtensions } from './adapters/extension.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: '',
|
||||
};
|
||||
|
||||
export const ParagraphBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:paragraph'),
|
||||
BlockViewExtension('affine:paragraph', literal`affine-paragraph`),
|
||||
ParagraphTextKeymapExtension,
|
||||
ParagraphKeymapExtension,
|
||||
ParagraphBlockAdapterExtensions,
|
||||
ParagraphBlockConfigExtension({
|
||||
getPlaceholder: model => {
|
||||
return placeholders[model.props.type];
|
||||
},
|
||||
}),
|
||||
].flat();
|
||||
152
blocksuite/affine/blocks/paragraph/src/styles.ts
Normal file
152
blocksuite/affine/blocks/paragraph/src/styles.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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 { GfxBlockComponent } from '@blocksuite/std';
|
||||
import { clientToModelCoord } from '@blocksuite/std/gfx';
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
queryLayout(component: GfxBlockComponent): ParagraphLayout | null {
|
||||
const paragraphSelector =
|
||||
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]';
|
||||
const paragraphNodes = component.querySelectorAll(paragraphSelector);
|
||||
|
||||
if (paragraphNodes.length === 0) return null;
|
||||
|
||||
const viewportRecord = component.gfx.viewport.deserializeRecord(
|
||||
component.dataset.viewportState
|
||||
);
|
||||
|
||||
if (!viewportRecord) return null;
|
||||
|
||||
const { zoom, viewScale } = viewportRecord;
|
||||
const paragraph: ParagraphLayout = {
|
||||
type: 'affine:paragraph',
|
||||
sentences: [],
|
||||
};
|
||||
|
||||
paragraphNodes.forEach(paragraphNode => {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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)) return; // cast to ParagraphLayout
|
||||
|
||||
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
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
162
blocksuite/affine/blocks/paragraph/src/utils/merge-with-prev.ts
Normal file
162
blocksuite/affine/blocks/paragraph/src/utils/merge-with-prev.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
AttachmentBlockModel,
|
||||
BookmarkBlockModel,
|
||||
CalloutBlockModel,
|
||||
CodeBlockModel,
|
||||
DatabaseBlockModel,
|
||||
DividerBlockModel,
|
||||
EdgelessTextBlockModel,
|
||||
ImageBlockModel,
|
||||
ListBlockModel,
|
||||
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 { 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.doc;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return false;
|
||||
|
||||
if (matchModels(parent, [EdgelessTextBlockModel])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
matchModels(prevBlock, [
|
||||
AttachmentBlockModel,
|
||||
BookmarkBlockModel,
|
||||
CodeBlockModel,
|
||||
ImageBlockModel,
|
||||
DividerBlockModel,
|
||||
...EMBED_BLOCK_MODEL_LIST,
|
||||
])
|
||||
) {
|
||||
const selection = editorHost.selection.create(BlockSelection, {
|
||||
blockId: prevBlock.id,
|
||||
});
|
||||
editorHost.selection.setGroup('note', [selection]);
|
||||
|
||||
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.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 (
|
||||
matchModels(parent, [EdgelessTextBlockModel]) ||
|
||||
model.children.length > 0
|
||||
) {
|
||||
doc.deleteBlock(model, {
|
||||
bringChildrenTo: parent,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootModel = model.doc.root as RootBlockModel;
|
||||
const title = rootModel.props.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