chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,203 @@
import { ListBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
TextUtils,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/inline';
import { nanoid } from '@blocksuite/store';
import type { Element } from 'hast';
export const listBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: ListBlockSchema.model.flavour,
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'li',
fromMatch: o => o.node.flavour === ListBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const parentList = o.parent?.node as Element;
let listType = 'bulleted';
if (parentList.tagName === 'ol') {
listType = 'numbered';
} else if (Array.isArray(parentList.properties?.className)) {
if (parentList.properties.className.includes('to-do-list')) {
listType = 'todo';
} else if (parentList.properties.className.includes('toggle')) {
listType = 'toggle';
} else if (parentList.properties.className.includes('bulleted-list')) {
listType = 'bulleted';
}
}
const listNumber =
typeof parentList.properties.start === 'number'
? parentList.properties.start + parentList.children.indexOf(o.node)
: null;
const firstElementChild = HastUtils.getElementChildren(o.node)[0];
o.node = HastUtils.flatNodes(
o.node,
tagName => tagName === 'div' || tagName === 'p'
) as Element;
const { walkerContext, deltaConverter } = context;
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:list',
props: {
type: listType,
text: {
'$blocksuite:internal:text$': true,
delta:
listType !== 'toggle'
? deltaConverter.astToDelta(
HastUtils.getInlineOnlyElementAST(o.node)
)
: deltaConverter.astToDelta(
HastUtils.querySelector(o.node, 'summary') ?? o.node
),
},
checked:
listType === 'todo'
? firstElementChild &&
Array.isArray(firstElementChild.properties?.className) &&
firstElementChild.properties.className.includes('checkbox-on')
: false,
collapsed:
listType === 'toggle'
? firstElementChild &&
firstElementChild.tagName === 'details' &&
firstElementChild.properties.open === undefined
: false,
order: listNumber,
},
children: [],
},
'children'
);
},
leave: (_, context) => {
const { walkerContext } = context;
walkerContext.closeNode();
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
const { deltaConverter, walkerContext } = context;
const currentTNode = walkerContext.currentNode();
const liChildren = deltaConverter.deltaToAST(text.delta);
if (o.node.props.type === 'todo') {
liChildren.unshift({
type: 'element',
tagName: 'input',
properties: {
type: 'checkbox',
checked: o.node.props.checked as boolean,
},
children: [
{
type: 'element',
tagName: 'label',
properties: {
style: 'margin-right: 3px;',
},
children: [],
},
],
});
}
// check if the list is of the same type
if (
walkerContext.getNodeContext('affine:list:parent') === o.parent &&
currentTNode.type === 'element' &&
currentTNode.tagName ===
(o.node.props.type === 'numbered' ? 'ol' : 'ul') &&
!(
Array.isArray(currentTNode.properties.className) &&
currentTNode.properties.className.includes('todo-list')
) ===
TextUtils.isNullish(
o.node.props.type === 'todo'
? (o.node.props.checked as boolean)
: undefined
)
) {
// if true, add the list item to the list
} else {
// if false, create a new list
walkerContext.openNode(
{
type: 'element',
tagName: o.node.props.type === 'numbered' ? 'ol' : 'ul',
properties: {
style:
o.node.props.type === 'todo'
? 'list-style-type: none; padding-inline-start: 18px;'
: null,
className: [o.node.props.type + '-list'],
},
children: [],
},
'children'
);
walkerContext.setNodeContext('affine:list:parent', o.parent);
}
walkerContext.openNode(
{
type: 'element',
tagName: 'li',
properties: {
className: ['affine-list-block-container'],
},
children: liChildren,
},
'children'
);
},
leave: (o, context) => {
const { walkerContext } = context;
const currentTNode = walkerContext.currentNode() as Element;
const previousTNode = walkerContext.previousNode() as Element;
if (
walkerContext.getPreviousNodeContext('affine:list:parent') ===
o.parent &&
currentTNode.tagName === 'li' &&
previousTNode.tagName ===
(o.node.props.type === 'numbered' ? 'ol' : 'ul') &&
!(
Array.isArray(previousTNode.properties.className) &&
previousTNode.properties.className.includes('todo-list')
) ===
TextUtils.isNullish(
o.node.props.type === 'todo'
? (o.node.props.checked as boolean)
: undefined
)
) {
walkerContext.closeNode();
if (
o.next?.flavour !== 'affine:list' ||
o.next.props.type !== o.node.props.type
) {
// If the next node is not a list or different type of list, close the list
walkerContext.closeNode();
}
} else {
walkerContext.closeNode().closeNode();
}
},
},
};
export const ListBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
listBlockHtmlAdapterMatcher
);
@@ -0,0 +1,4 @@
export * from './html.js';
export * from './markdown.js';
export * from './notion-html.js';
export * from './plain-text.js';
@@ -0,0 +1,156 @@
import { ListBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
type MarkdownAST,
TextUtils,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/inline';
import { nanoid } from '@blocksuite/store';
import type { List } from 'mdast';
const LIST_MDAST_TYPE = new Set(['list', 'listItem']);
const isListMDASTType = (node: MarkdownAST) => LIST_MDAST_TYPE.has(node.type);
export const listBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: ListBlockSchema.model.flavour,
toMatch: o => isListMDASTType(o.node),
fromMatch: o => o.node.flavour === ListBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
if (o.node.type === 'listItem') {
const parentList = o.parent?.node as List;
const listNumber = parentList.start
? parentList.start + parentList.children.indexOf(o.node)
: null;
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:list',
props: {
type:
o.node.checked !== null
? 'todo'
: parentList.ordered
? 'numbered'
: 'bulleted',
text: {
'$blocksuite:internal:text$': true,
delta:
o.node.children[0] && o.node.children[0].type === 'paragraph'
? deltaConverter.astToDelta(o.node.children[0])
: [],
},
checked: o.node.checked ?? false,
collapsed: false,
order: listNumber,
},
children: [],
},
'children'
);
if (o.node.children[0] && o.node.children[0].type === 'paragraph') {
walkerContext.skipChildren(1);
}
}
},
leave: (o, context) => {
const { walkerContext } = context;
if (o.node.type === 'listItem') {
walkerContext.closeNode();
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
const text = (o.node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
const currentTNode = walkerContext.currentNode();
// check if the list is of the same type
if (
walkerContext.getNodeContext('affine:list:parent') === o.parent &&
currentTNode.type === 'list' &&
currentTNode.ordered === (o.node.props.type === 'numbered') &&
TextUtils.isNullish(currentTNode.children[0].checked) ===
TextUtils.isNullish(
o.node.props.type === 'todo'
? (o.node.props.checked as boolean)
: undefined
)
) {
// if true, add the list item to the list
} else {
// if false, create a new list
walkerContext
.openNode(
{
type: 'list',
ordered: o.node.props.type === 'numbered',
spread: false,
children: [],
},
'children'
)
.setNodeContext('affine:list:parent', o.parent);
}
walkerContext
.openNode(
{
type: 'listItem',
checked:
o.node.props.type === 'todo'
? (o.node.props.checked as boolean)
: undefined,
spread: false,
children: [],
},
'children'
)
.openNode(
{
type: 'paragraph',
children: deltaConverter.deltaToAST(text.delta),
},
'children'
)
.closeNode();
},
leave: (o, context) => {
const { walkerContext } = context;
const currentTNode = walkerContext.currentNode();
const previousTNode = walkerContext.previousNode();
if (
walkerContext.getPreviousNodeContext('affine:list:parent') ===
o.parent &&
currentTNode.type === 'listItem' &&
previousTNode?.type === 'list' &&
previousTNode.ordered === (o.node.props.type === 'numbered') &&
TextUtils.isNullish(currentTNode.checked) ===
TextUtils.isNullish(
o.node.props.type === 'todo'
? (o.node.props.checked as boolean)
: undefined
)
) {
walkerContext.closeNode();
if (
o.next?.flavour !== 'affine:list' ||
o.next.props.type !== o.node.props.type
) {
// If the next node is not a list or different type of list, close the list
walkerContext.closeNode();
}
} else {
walkerContext.closeNode().closeNode();
}
},
},
};
export const ListBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
listBlockMarkdownAdapterMatcher
);
@@ -0,0 +1,116 @@
import { ListBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/inline';
import { nanoid } from '@blocksuite/store';
const listBlockMatchTags = new Set(['ul', 'ol', 'li']);
export const listBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: ListBlockSchema.model.flavour,
toMatch: o =>
HastUtils.isElement(o.node) && listBlockMatchTags.has(o.node.tagName),
fromMatch: () => false,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext, pageMap, deltaConverter } = context;
switch (o.node.tagName) {
case 'ul':
case 'ol': {
walkerContext.setNodeContext('hast:list:type', 'bulleted');
if (o.node.tagName === 'ol') {
walkerContext.setNodeContext('hast:list:type', 'numbered');
} else if (Array.isArray(o.node.properties?.className)) {
if (o.node.properties.className.includes('to-do-list')) {
walkerContext.setNodeContext('hast:list:type', 'todo');
} else if (o.node.properties.className.includes('toggle')) {
walkerContext.setNodeContext('hast:list:type', 'toggle');
} else if (
o.node.properties.className.includes('bulleted-list')
) {
walkerContext.setNodeContext('hast:list:type', 'bulleted');
}
}
break;
}
case 'li': {
const firstElementChild = HastUtils.getElementChildren(o.node)[0];
const notionListType =
walkerContext.getNodeContext('hast:list:type');
const listType =
notionListType === 'toggle' ? 'bulleted' : notionListType;
let delta: DeltaInsert[] = [];
if (notionListType === 'toggle') {
delta = deltaConverter.astToDelta(
HastUtils.querySelector(o.node, 'summary') ?? o.node,
{ pageMap }
);
} else if (notionListType === 'todo') {
delta = deltaConverter.astToDelta(o.node, { pageMap });
} else {
delta = deltaConverter.astToDelta(
HastUtils.getInlineOnlyElementAST(o.node),
{
pageMap,
}
);
}
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:list',
props: {
type: listType,
text: {
'$blocksuite:internal:text$': true,
delta,
},
checked:
notionListType === 'todo'
? firstElementChild &&
Array.isArray(
firstElementChild.properties?.className
) &&
firstElementChild.properties.className.includes(
'checkbox-on'
)
: false,
collapsed:
notionListType === 'toggle'
? firstElementChild &&
firstElementChild.tagName === 'details' &&
firstElementChild.properties.open === undefined
: false,
},
children: [],
},
'children'
);
break;
}
}
},
leave: (o, context) => {
const { walkerContext } = context;
if (!HastUtils.isElement(o.node)) {
return;
}
if (o.node.tagName === 'li') {
walkerContext.closeNode();
}
},
},
fromBlockSnapshot: {},
};
export const ListBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(listBlockNotionHtmlAdapterMatcher);
@@ -0,0 +1,27 @@
import { ListBlockSchema } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/inline';
export const listBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
flavour: ListBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === ListBlockSchema.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 ListBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(listBlockPlainTextAdapterMatcher);
@@ -0,0 +1,27 @@
import { toNumberedList } from '@blocksuite/affine-shared/utils';
import type { Command, EditorHost } from '@blocksuite/block-std';
export const convertToNumberedListCommand: Command<
never,
'listConvertedId',
{
id: string;
order: number; // This parameter may not correspond to the final order.
stopCapturing?: boolean;
}
> = (ctx, next) => {
const { std, id, order, stopCapturing = true } = ctx;
const host = std.host as EditorHost;
const doc = host.doc;
const model = doc.getBlock(id)?.model;
if (!model || !model.text) return;
if (stopCapturing) host.doc.captureSync();
const listConvertedId = toNumberedList(std, model, order);
if (!listConvertedId) return;
return next({ listConvertedId });
};
@@ -0,0 +1,161 @@
import type { IndentContext } from '@blocksuite/affine-shared/types';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
import { correctNumberedListsOrderToPrev } from './utils.js';
export const canDedentListCommand: Command<
never,
'indentContext',
Partial<Omit<IndentContext, 'flavour' | 'type'>>
> = (ctx, next) => {
let { blockId, inlineIndex } = ctx;
const { std } = ctx;
const { selection, doc } = std;
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;
}
/**
* initial state:
* - aaa
* - bbb
* - ccc <- unindent
* - ddd
* - eee
* - fff
*
* final state:
* - aaa
* - bbb
* - ccc
* - ddd
* - eee
* - fff
*/
/**
* ccc
*/
const model = doc.getBlock(blockId)?.model;
if (!model || !matchFlavours(model, ['affine:list'])) {
return;
}
/**
* bbb
*/
const parent = doc.getParent(model);
if (!parent) {
return;
}
if (doc.readonly || parent.role !== 'content') {
// Top most list cannot be unindent
return;
}
/**
* aaa
*/
const grandParent = doc.getParent(parent);
if (!grandParent) {
return;
}
/**
* ccc index
*/
const modelIndex = parent.children.indexOf(model);
if (modelIndex === -1) {
return;
}
return next({
indentContext: {
blockId,
inlineIndex,
type: 'dedent',
flavour: 'affine:list',
},
});
};
export const dedentListCommand: Command<'indentContext'> = (ctx, next) => {
const { indentContext: dedentContext, std } = ctx;
const { doc, selection, range, host } = std;
if (
!dedentContext ||
dedentContext.type !== 'dedent' ||
dedentContext.flavour !== 'affine:list'
) {
console.warn(
'you need to use `canDedentList` command before running `dedentList` 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();
/**
* step 1:
* - aaa
* - bbb
* - ccc
* - ddd
* - eee <- make eee as ccc's child
* - fff
*/
const nextSiblings = doc.getNexts(model); // [eee]
doc.moveBlocks(nextSiblings, model);
/**
* eee
*/
const nextSibling = nextSiblings.at(0);
if (nextSibling) correctNumberedListsOrderToPrev(doc, nextSibling);
/**
* step 2:
* - aaa
* - bbb
* - ccc <- make ccc as aaa's child
* - ddd
* - eee
* - fff
*/
doc.moveBlocks([model], grandParent, parent, false);
correctNumberedListsOrderToPrev(doc, model);
const textSelection = selection.find('text');
if (textSelection) {
host.updateComplete
.then(() => {
range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
}
return next();
};
@@ -0,0 +1,146 @@
import type { IndentContext } from '@blocksuite/affine-shared/types';
import {
getNearestHeadingBefore,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
import { correctNumberedListsOrderToPrev } from './utils.js';
export const canIndentListCommand: Command<
never,
'indentContext',
Partial<Omit<IndentContext, 'type' | 'flavour'>>
> = (ctx, next) => {
let { blockId, inlineIndex } = ctx;
const { std } = ctx;
const { selection, doc } = std;
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;
}
/**
* initial state:
* - aaa
* - bbb
* - ccc <- indent
* - ddd
* - eee
*
* final state:
* - aaa
* - bbb
* - ccc
* - ddd
* - eee
*/
/**
* ccc
*/
const model = doc.getBlock(blockId)?.model;
if (!model || !matchFlavours(model, ['affine:list'])) {
return;
}
const schema = std.doc.schema;
/**
* aaa
*/
const previousSibling = doc.getPrev(model);
if (
doc.readonly ||
!previousSibling ||
!schema.isValid(model.flavour, previousSibling.flavour)
) {
// cannot indent, do nothing
return;
}
/**
* eee
*/
// const nextSibling = doc.getNext(model);
return next({
indentContext: {
blockId,
inlineIndex,
type: 'indent',
flavour: 'affine:list',
},
});
};
export const indentListCommand: Command<'indentContext', never> = (
ctx,
next
) => {
const { indentContext, std } = ctx;
if (
!indentContext ||
indentContext.type !== 'indent' ||
indentContext.flavour !== 'affine:list'
) {
console.warn(
'you need to use `canIndentList` command before running `indentList` command'
);
return;
}
const { blockId } = indentContext;
const { doc, selection, host, range } = std;
const model = doc.getBlock(blockId)?.model;
if (!model) return;
const previousSibling = doc.getPrev(model);
if (!previousSibling) return;
const nextSibling = doc.getNext(model);
doc.captureSync();
doc.moveBlocks([model], previousSibling);
correctNumberedListsOrderToPrev(doc, model);
if (nextSibling) correctNumberedListsOrderToPrev(doc, nextSibling);
// 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,
});
}
const textSelection = selection.find('text');
if (textSelection) {
host.updateComplete
.then(() => {
range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
}
return next();
};
@@ -0,0 +1,17 @@
import type { BlockCommands } from '@blocksuite/block-std';
import { convertToNumberedListCommand } from './convert-to-numbered-list.js';
import { canDedentListCommand, dedentListCommand } from './dedent-list.js';
import { canIndentListCommand, indentListCommand } from './indent-list.js';
import { listToParagraphCommand } from './list-to-paragraph.js';
import { splitListCommand } from './split-list.js';
export const commands: BlockCommands = {
convertToNumberedList: convertToNumberedListCommand,
listToParagraph: listToParagraphCommand,
splitList: splitListCommand,
canIndentList: canIndentListCommand,
indentList: indentListCommand,
canDedentList: canDedentListCommand,
dedentList: dedentListCommand,
};
@@ -0,0 +1,42 @@
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
export const listToParagraphCommand: Command<
never,
'listConvertedId',
{
id: string;
stopCapturing?: boolean;
}
> = (ctx, next) => {
const { id, stopCapturing = true } = ctx;
const std = ctx.std;
const doc = std.doc;
const model = doc.getBlock(id)?.model;
if (!model || !matchFlavours(model, ['affine:list'])) return false;
const parent = doc.getParent(model);
if (!parent) return false;
const index = parent.children.indexOf(model);
const blockProps = {
type: 'text' as const,
text: model.text?.clone(),
children: model.children,
};
if (stopCapturing) std.doc.captureSync();
doc.deleteBlock(model, {
deleteChildren: false,
});
const listConvertedId = doc.addBlock(
'affine:paragraph',
blockProps,
parent,
index
);
focusTextModel(std, listConvertedId);
return next({ listConvertedId });
};
@@ -0,0 +1,206 @@
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
import {
getNextContinuousNumberedLists,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { Command, EditorHost } from '@blocksuite/block-std';
import { correctNumberedListsOrderToPrev } from './utils.js';
export const splitListCommand: Command<
never,
never,
{
blockId: string;
inlineIndex: number;
}
> = (ctx, next) => {
const { blockId, inlineIndex, std } = ctx;
const host = std.host as EditorHost;
const doc = host.doc;
const model = doc.getBlock(blockId)?.model;
if (!model || !matchFlavours(model, ['affine:list'])) {
console.error(`block ${blockId} is not a list block`);
return;
}
const parent = doc.getParent(model);
if (!parent) {
console.error(`block ${blockId} has no parent`);
return;
}
const modelIndex = parent.children.indexOf(model);
if (modelIndex === -1) {
console.error(`block ${blockId} is not a child of its parent`);
return;
}
doc.captureSync();
if (model.text.length === 0) {
/**
* case 1: target is top most, convert the list into a paragraph
*
* before:
* - aaa
* - | <- split here
* - bbb
*
* after:
* - aaa
* |
* - bbb
*/
if (parent.role === 'hub') {
const id = doc.addBlock('affine:paragraph', {}, parent, modelIndex);
const paragraph = doc.getBlock(id);
if (!paragraph) return;
doc.deleteBlock(model, {
bringChildrenTo: paragraph.model,
});
// reset next continuous numbered list's order
const nextContinuousNumberedLists = getNextContinuousNumberedLists(
doc,
paragraph.model
);
let base = 1;
nextContinuousNumberedLists.forEach(list => {
doc.transact(() => {
list.order = base;
});
base += 1;
});
host.updateComplete
.then(() => {
focusTextModel(std, id);
})
.catch(console.error);
next();
return;
}
/**
* case 2: not top most, unindent the list
*
* before:
* - aaa
* - bbb
* - | <- split here
* - ccc
*
* after:
* - aaa
* - bbb
* - |
* - ccc
*/
if (parent.role === 'content') {
host.command
.chain()
.canDedentList({
blockId,
inlineIndex: 0,
})
.dedentList()
.run();
next();
return;
}
return;
}
let newListId: string | null = null;
if (model.children.length > 0 && !model.collapsed) {
/**
* case 3: list has children (list not collapsed)
*
* before:
* - aa|a <- split here
* - bbb
*
* after:
* - aa
* - |a
* - bbb
*/
const afterText = model.text.split(inlineIndex);
newListId = doc.addBlock(
'affine:list',
{
type: model.type,
text: afterText,
order: model.type === 'numbered' ? 1 : null,
},
model,
0
);
if (model.type === 'numbered') {
const nextContinuousNumberedLists = getNextContinuousNumberedLists(
doc,
newListId
);
let base = 2;
nextContinuousNumberedLists.forEach(list => {
doc.transact(() => {
list.order = base;
});
base += 1;
});
}
} else {
/**
* case 4: list has children (list collapsed)
*
* before:
* - aa|a <- split here
* - bbb
*
* after:
* - aa
* - bbb
* - |a
*
*
* case 5: list does not have children
*
* before:
* - aa|a <- split here
* - bbb
*
* after:
* - aa
* - |a
* - bbb
*/
const afterText = model.text.split(inlineIndex);
newListId = doc.addBlock(
'affine:list',
{
type: model.type,
text: afterText,
order: null,
},
parent,
modelIndex + 1
);
correctNumberedListsOrderToPrev(doc, newListId);
}
if (newListId) {
host.updateComplete
.then(() => {
focusTextModel(std, newListId);
})
.catch(console.error);
next();
return;
}
};
@@ -0,0 +1,70 @@
import type { ListBlockModel } from '@blocksuite/affine-model';
import {
getNextContinuousNumberedLists,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockModel, Doc } from '@blocksuite/store';
/**
* correct target is a numbered list, which is divided into two steps:
* 1. check if there is a numbered list before the target list. If so, adjust the order of the target list
* to the order of the previous list plus 1, otherwise set the order to 1
* 2. find continuous lists starting from the target list and keep their order continuous
*/
export function correctNumberedListsOrderToPrev(
doc: Doc,
modelOrId: BlockModel | string,
transact = true
) {
const model =
typeof modelOrId === 'string' ? doc.getBlock(modelOrId)?.model : modelOrId;
if (!model) return;
if (
!matchFlavours(model, ['affine:list']) ||
model.type$.value !== 'numbered'
) {
return;
}
const fn = () => {
// step 1
const previousSibling = doc.getPrev(model);
if (
previousSibling &&
matchFlavours(previousSibling, ['affine:list']) &&
previousSibling.type === 'numbered'
) {
if (!previousSibling.order) previousSibling.order = 1;
model.order = previousSibling.order + 1;
} else {
model.order = 1;
}
// step 2
let base = model.order + 1;
const continuousNumberedLists = getNextContinuousNumberedLists(doc, model);
continuousNumberedLists.forEach(list => {
list.order = base;
base++;
});
};
if (transact) {
doc.transact(fn);
} else {
fn();
}
}
export function correctListOrder(doc: Doc, model: ListBlockModel) {
// old numbered list has no order
if (model.type === 'numbered' && !Number.isInteger(model.order)) {
correctNumberedListsOrderToPrev(doc, model, false);
}
// if list is not numbered, order should be null
if (model.type !== 'numbered') {
model.order = null;
}
}
@@ -0,0 +1,46 @@
import type { IndentContext } from '@blocksuite/affine-shared/types';
import type { convertToNumberedListCommand } from './commands/convert-to-numbered-list.js';
import type {
canDedentListCommand,
dedentListCommand,
} from './commands/dedent-list.js';
import type {
canIndentListCommand,
indentListCommand,
} from './commands/indent-list.js';
import type { listToParagraphCommand } from './commands/list-to-paragraph.js';
import type { splitListCommand } from './commands/split-list.js';
import { ListBlockComponent } from './list-block.js';
import type { ListBlockService } from './list-service.js';
export function effects() {
customElements.define('affine-list', ListBlockComponent);
}
declare global {
namespace BlockSuite {
interface BlockServices {
'affine:list': ListBlockService;
}
interface CommandContext {
listConvertedId?: string;
indentContext?: IndentContext;
}
interface Commands {
convertToNumberedList: typeof convertToNumberedListCommand;
canDedentList: typeof canDedentListCommand;
canIndentList: typeof canIndentListCommand;
dedentList: typeof dedentListCommand;
indentList: typeof indentListCommand;
listToParagraph: typeof listToParagraphCommand;
splitList: typeof splitListCommand;
}
}
interface HTMLElementTagNameMap {
'affine-list': ListBlockComponent;
}
}
@@ -0,0 +1,4 @@
export * from './adapters/index.js';
export * from './list-block.js';
export * from './list-service.js';
export * from './list-spec.js';
@@ -0,0 +1,217 @@
import '@blocksuite/affine-shared/commands';
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { playCheckAnimation } from '@blocksuite/affine-components/icons';
import {
DefaultInlineManagerExtension,
type RichText,
} from '@blocksuite/affine-components/rich-text';
import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button';
import type { ListBlockModel } 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 { getViewportElement } from '@blocksuite/affine-shared/utils';
import type { BaseSelection, BlockComponent } from '@blocksuite/block-std';
import { getInlineRangeProvider } from '@blocksuite/block-std';
import type { InlineRangeProvider } from '@blocksuite/inline';
import { effect } 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 { correctNumberedListsOrderToPrev } from './commands/utils.js';
import type { ListBlockService } from './list-service.js';
import { listBlockStyles } from './styles.js';
import { getListIcon } from './utils/get-list-icon.js';
export class ListBlockComponent extends CaptionedBlockComponent<
ListBlockModel,
ListBlockService
> {
static override styles = listBlockStyles;
private _inlineRangeProvider: InlineRangeProvider | null = null;
private _onClickIcon = (e: MouseEvent) => {
e.stopPropagation();
if (this.model.type === 'toggle') {
if (this.doc.readonly) {
this._readonlyCollapsed = !this._readonlyCollapsed;
} else {
this.doc.captureSync();
this.doc.updateBlock(this.model, {
collapsed: !this.model.collapsed,
});
}
return;
} else if (this.model.type === 'todo') {
if (this.doc.readonly) return;
this.doc.captureSync();
const checkedPropObj = { checked: !this.model.checked };
this.doc.updateBlock(this.model, checkedPropObj);
if (this.model.checked) {
const checkEl = this.querySelector('.affine-list-block__todo-prefix');
if (checkEl) {
playCheckAnimation(checkEl).catch(console.error);
}
}
return;
}
this._select();
};
get attributeRenderer() {
return this.inlineManager.getRenderer();
}
get attributesSchema() {
return this.inlineManager.getSchema();
}
get embedChecker() {
return this.inlineManager.embedChecker;
}
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;
}
private _select() {
const selection = this.host.selection;
selection.update(selList => {
return selList
.filter<BaseSelection>(sel => !sel.is('text') && !sel.is('block'))
.concat(selection.create('block', { blockId: this.blockId }));
});
}
override connectedCallback() {
super.connectedCallback();
this._inlineRangeProvider = getInlineRangeProvider(this);
this.disposables.add(
effect(() => {
const collapsed = this.model.collapsed$.value;
this._readonlyCollapsed = collapsed;
})
);
this.disposables.add(
effect(() => {
const type = this.model.type$.value;
const order = this.model.order$.value;
// old numbered list has no order
if (type === 'numbered' && !Number.isInteger(order)) {
correctNumberedListsOrderToPrev(this.doc, this.model, false);
}
// if list is not numbered, order should be null
if (type !== 'numbered' && order !== null) {
this.model.order = null;
}
})
);
}
override async getUpdateComplete() {
const result = await super.getUpdateComplete();
await this._richTextElement?.updateComplete;
return result;
}
override renderBlock(): TemplateResult<1> {
const { model, _onClickIcon } = this;
const collapsed = this.doc.readonly
? this._readonlyCollapsed
: model.collapsed;
const listIcon = getListIcon(model, !collapsed, _onClickIcon);
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`
<div class=${'affine-list-block-container'}>
<div
class=${classMap({
'affine-list-rich-text-wrapper': true,
'affine-list--checked':
this.model.type === 'todo' && this.model.checked,
[TOGGLE_BUTTON_PARENT_CLASS]: true,
})}
>
${this.model.children.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}
${listIcon}
<rich-text
.yText=${this.model.text.yText}
.inlineEventSource=${this.topContenteditableElement ?? nothing}
.undoManager=${this.doc.history}
.attributeRenderer=${this.attributeRenderer}
.attributesSchema=${this.attributesSchema}
.markdownShortcutHandler=${this.markdownShortcutHandler}
.embedChecker=${this.embedChecker}
.readonly=${this.doc.readonly}
.inlineRangeProvider=${this._inlineRangeProvider}
.enableClipboard=${false}
.enableUndoRedo=${false}
.verticalScrollContainerGetter=${() =>
getViewportElement(this.host)}
></rich-text>
</div>
${children}
</div>
`;
}
@state()
private accessor _readonlyCollapsed = false;
@query('rich-text')
private accessor _richTextElement: RichText | null = null;
override accessor blockContainerStyles = {
margin: 'var(--affine-list-margin, 10px 0)',
};
}
@@ -0,0 +1,125 @@
import {
markdownInput,
textKeymap,
} from '@blocksuite/affine-components/rich-text';
import { ListBlockSchema } from '@blocksuite/affine-model';
import { KeymapExtension } from '@blocksuite/block-std';
import { IS_MAC } from '@blocksuite/global/env';
import { forwardDelete } from './utils/forward-delete.js';
export const ListKeymapExtension = KeymapExtension(
std => {
return {
Enter: ctx => {
const text = std.selection.find('text');
if (!text) return false;
ctx.get('keyboardState').raw.preventDefault();
std.command.exec('splitList', {
blockId: text.from.blockId,
inlineIndex: text.from.index,
});
return true;
},
'Mod-Enter': ctx => {
const text = std.selection.find('text');
if (!text) return false;
ctx.get('keyboardState').raw.preventDefault();
std.command.exec('splitList', {
blockId: text.from.blockId,
inlineIndex: text.from.index,
});
return true;
},
Tab: ctx => {
const { selectedModels } = std.command.exec('getSelectedModels', {
types: ['text'],
});
if (selectedModels?.length !== 1) {
return false;
}
const text = std.selection.find('text');
if (!text) return false;
ctx.get('keyboardState').raw.preventDefault();
std.command
.chain()
.canIndentList({
blockId: text.from.blockId,
inlineIndex: text.from.index,
})
.indentList()
.run();
return true;
},
'Shift-Tab': ctx => {
const { selectedModels } = std.command.exec('getSelectedModels', {
types: ['text'],
});
if (selectedModels?.length !== 1) {
return;
}
const text = std.selection.find('text');
if (!text) return false;
ctx.get('keyboardState').raw.preventDefault();
std.command
.chain()
.canDedentList({
blockId: text.from.blockId,
inlineIndex: text.from.index,
})
.dedentList()
.run();
return true;
},
Backspace: ctx => {
const text = std.selection.find('text');
if (!text) return false;
const isCollapsed = text.isCollapsed();
const isStart = isCollapsed && text.from.index === 0;
if (!isStart) return false;
ctx.get('keyboardState').raw.preventDefault();
std.command.exec('listToParagraph', { id: text.from.blockId });
return true;
},
'Control-d': ctx => {
if (!IS_MAC) return;
const deleted = forwardDelete(std);
if (!deleted) return;
ctx.get('keyboardState').raw.preventDefault();
return true;
},
Delete: ctx => {
const deleted = forwardDelete(std);
if (!deleted) return;
ctx.get('keyboardState').raw.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;
},
};
},
{
flavour: ListBlockSchema.model.flavour,
}
);
export const ListTextKeymapExtension = KeymapExtension(textKeymap, {
flavour: ListBlockSchema.model.flavour,
});
@@ -0,0 +1,33 @@
import { ListBlockSchema } from '@blocksuite/affine-model';
import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { BlockService } from '@blocksuite/block-std';
import { correctNumberedListsOrderToPrev } from './commands/utils.js';
export class ListBlockService extends BlockService {
static override readonly flavour = ListBlockSchema.model.flavour;
}
export const ListDragHandleOption = DragHandleConfigExtension({
flavour: ListBlockSchema.model.flavour,
onDragEnd: ({ draggingElements, editorHost }) => {
draggingElements.forEach((el: BlockComponent) => {
const model = el.model;
const doc = el.doc;
if (matchFlavours(model, ['affine:list']) && model.type === 'numbered') {
const next = el.doc.getNext(model);
editorHost.updateComplete
.then(() => {
correctNumberedListsOrderToPrev(doc, model);
if (next) {
correctNumberedListsOrderToPrev(doc, next);
}
})
.catch(console.error);
}
});
return false;
},
});
@@ -0,0 +1,21 @@
import {
BlockViewExtension,
CommandExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { commands } from './commands/index.js';
import { ListKeymapExtension, ListTextKeymapExtension } from './list-keymap.js';
import { ListBlockService, ListDragHandleOption } from './list-service.js';
export const ListBlockSpec: ExtensionType[] = [
FlavourExtension('affine:list'),
ListBlockService,
CommandExtension(commands),
BlockViewExtension('affine:list', literal`affine-list`),
ListKeymapExtension,
ListTextKeymapExtension,
ListDragHandleOption,
];
@@ -0,0 +1,64 @@
import { css } from 'lit';
export const listPrefix = css`
.affine-list-block__prefix {
display: flex;
color: var(--affine-blue-700);
font-size: var(--affine-font-sm);
user-select: none;
position: relative;
}
.affine-list-block__numbered {
min-width: 22px;
height: 24px;
margin-left: 2px;
}
.affine-list-block__todo-prefix {
display: flex;
align-items: center;
cursor: pointer;
width: 24px;
height: 24px;
color: var(--affine-icon-color);
}
.affine-list-block__todo-prefix.readonly {
cursor: default;
}
.affine-list-block__todo-prefix > svg {
width: 20px;
height: 20px;
}
`;
export const listBlockStyles = css`
affine-list {
display: block;
font-size: var(--affine-font-base);
}
.affine-list-block-container {
box-sizing: border-box;
border-radius: 4px;
position: relative;
}
.affine-list-block-container .affine-list-block-container {
margin-top: 0;
}
.affine-list-rich-text-wrapper {
position: relative;
display: flex;
}
.affine-list-rich-text-wrapper rich-text {
flex: 1;
}
.affine-list--checked {
color: var(--affine-text-secondary-color);
}
${listPrefix}
`;
@@ -0,0 +1,74 @@
import {
getNextContentBlock,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { Text } from '@blocksuite/store';
// When deleting at line end of a list block,
// check current block's children and siblings
/**
* Example:
- Line1 <-(cursor here)
- Line2
- Line3
- Line4
- Line5
- Line6
- Line7
- Line8
- Line9
*/
export function forwardDelete(std: BlockStdScope): true | undefined {
const text = std.selection.find('text');
if (!text) return;
const isCollapsed = text.isCollapsed();
const doc = std.doc;
const model = doc.getBlock(text.from.blockId)?.model;
if (!model || !matchFlavours(model, ['affine:list'])) return;
const isEnd = isCollapsed && text.from.index === model.text.length;
if (!isEnd) return;
// Has children in list
const firstChild = model.firstChild();
if (firstChild) {
model.text.join(firstChild.text as Text);
const grandChildren = firstChild.children;
if (grandChildren) {
doc.moveBlocks(grandChildren, model);
doc.deleteBlock(firstChild);
return true;
}
doc.deleteBlock(firstChild);
return true;
}
const parent = doc.getParent(model);
// Has text sibling
const nextSibling = doc.getNext(model);
const nextText = nextSibling?.text;
if (nextSibling && nextText) {
model.text.join(nextText);
if (nextSibling.children) {
if (!parent) return;
doc.moveBlocks(nextSibling.children, parent, model, false);
}
doc.deleteBlock(nextSibling);
return true;
}
// Has next text block in other note block
const nextBlock = getNextContentBlock(std.host, model);
const nextBlockText = nextBlock?.text;
if (nextBlock && nextBlockText) {
model.text.join(nextBlock.text as Text);
if (nextBlock.children) {
const nextBlockParent = doc.getParent(nextBlock);
if (!nextBlockParent) return;
doc.moveBlocks(nextBlock.children, nextBlockParent, parent, false);
}
doc.deleteBlock(nextBlock);
}
return true;
}
@@ -0,0 +1,66 @@
import {
BulletIcons,
checkboxChecked,
checkboxUnchecked,
toggleDown,
toggleRight,
} from '@blocksuite/affine-components/icons';
import type { ListBlockModel } from '@blocksuite/affine-model';
import { html } from 'lit';
import { getNumberPrefix } from './get-number-prefix.js';
const getListDeep = (model: ListBlockModel): number => {
let deep = 0;
let parent = model.doc.getParent(model);
while (parent?.flavour === model.flavour) {
deep++;
parent = model.doc.getParent(parent);
}
return deep;
};
export function getListIcon(
model: ListBlockModel,
showChildren: boolean,
onClick: (e: MouseEvent) => void
) {
const deep = getListDeep(model);
switch (model.type) {
case 'bulleted':
return html`<div
contenteditable="false"
class="affine-list-block__prefix"
@click=${onClick}
>
${BulletIcons[deep % BulletIcons.length]}
</div>`;
case 'numbered':
return html`<div
contenteditable="false"
class="affine-list-block__prefix affine-list-block__numbered"
@click=${onClick}
>
${model.order ? getNumberPrefix(model.order, deep) : '1.'}
</div>`;
case 'todo':
return html`<div
contenteditable="false"
class=${`affine-list-block__prefix affine-list-block__todo-prefix ${model.doc.readonly ? 'readonly' : ''}`}
@click=${onClick}
>
${model.checked ? checkboxChecked() : checkboxUnchecked()}
</div>`;
case 'toggle':
return html`<div
contenteditable="false"
class="affine-list-block__prefix"
@click=${onClick}
>
${showChildren ? toggleDown : toggleRight}
</div>`;
default:
console.error('Unknown list type', model.type, model);
return null;
}
}
@@ -0,0 +1,52 @@
function number2letter(n: number) {
const ordA = 'a'.charCodeAt(0);
const ordZ = 'z'.charCodeAt(0);
const len = ordZ - ordA + 1;
let s = '';
while (n >= 0) {
s = String.fromCharCode((n % len) + ordA) + s;
n = Math.floor(n / len) - 1;
}
return s;
}
// Derive from https://gist.github.com/imilu/00f32c61e50b7ca296f91e9d96d8e976
export function number2roman(num: number) {
const lookup: Record<string, number> = {
M: 1000,
CM: 900,
D: 500,
CD: 400,
C: 100,
XC: 90,
L: 50,
XL: 40,
X: 10,
IX: 9,
V: 5,
IV: 4,
I: 1,
};
let romanStr = '';
for (const i in lookup) {
while (num >= lookup[i]) {
romanStr += i;
num -= lookup[i];
}
}
return romanStr;
}
function getPrefix(depth: number, index: number) {
const map = [
() => index,
() => number2letter(index - 1),
() => number2roman(index),
];
return map[depth % map.length]();
}
export function getNumberPrefix(index: number, depth: number) {
const prefix = getPrefix(depth, index);
return `${prefix}.`;
}