mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +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/list/src/adapters/extension.ts
Normal file
13
blocksuite/affine/blocks/list/src/adapters/extension.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { ListBlockHtmlAdapterExtension } from './html.js';
|
||||
import { ListBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { ListBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { ListBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const ListBlockAdapterExtensions: ExtensionType[] = [
|
||||
ListBlockHtmlAdapterExtension,
|
||||
ListBlockMarkdownAdapterExtension,
|
||||
ListBlockPlainTextAdapterExtension,
|
||||
ListBlockNotionHtmlAdapterExtension,
|
||||
];
|
||||
203
blocksuite/affine/blocks/list/src/adapters/html.ts
Normal file
203
blocksuite/affine/blocks/list/src/adapters/html.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { ListBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
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')
|
||||
) ===
|
||||
AdapterTextUtils.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')
|
||||
) ===
|
||||
AdapterTextUtils.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
|
||||
);
|
||||
4
blocksuite/affine/blocks/list/src/adapters/index.ts
Normal file
4
blocksuite/affine/blocks/list/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';
|
||||
156
blocksuite/affine/blocks/list/src/adapters/markdown.ts
Normal file
156
blocksuite/affine/blocks/list/src/adapters/markdown.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { ListBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
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') &&
|
||||
AdapterTextUtils.isNullish(currentTNode.children[0].checked) ===
|
||||
AdapterTextUtils.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') &&
|
||||
AdapterTextUtils.isNullish(currentTNode.checked) ===
|
||||
AdapterTextUtils.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
|
||||
);
|
||||
116
blocksuite/affine/blocks/list/src/adapters/notion-html.ts
Normal file
116
blocksuite/affine/blocks/list/src/adapters/notion-html.ts
Normal file
@@ -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/store';
|
||||
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);
|
||||
27
blocksuite/affine/blocks/list/src/adapters/plain-text.ts
Normal file
27
blocksuite/affine/blocks/list/src/adapters/plain-text.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ListBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
|
||||
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,28 @@
|
||||
import { toNumberedList } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command, EditorHost } from '@blocksuite/std';
|
||||
|
||||
export const convertToNumberedListCommand: Command<
|
||||
{
|
||||
id: string;
|
||||
order: number; // This parameter may not correspond to the final order.
|
||||
stopCapturing?: boolean;
|
||||
},
|
||||
{
|
||||
listConvertedId: string;
|
||||
}
|
||||
> = (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 });
|
||||
};
|
||||
165
blocksuite/affine/blocks/list/src/commands/dedent-list.ts
Normal file
165
blocksuite/affine/blocks/list/src/commands/dedent-list.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { ListBlockModel } from '@blocksuite/affine-model';
|
||||
import type { IndentContext } from '@blocksuite/affine-shared/types';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { type Command, TextSelection } from '@blocksuite/std';
|
||||
|
||||
import { correctNumberedListsOrderToPrev } from './utils.js';
|
||||
|
||||
export const canDedentListCommand: Command<
|
||||
Partial<Omit<IndentContext, 'flavour' | 'type'>>,
|
||||
{
|
||||
indentContext: IndentContext;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
let { blockId, inlineIndex } = ctx;
|
||||
const { std } = ctx;
|
||||
const { selection, store } = std;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* initial state:
|
||||
* - aaa
|
||||
* - bbb
|
||||
* - ccc <- unindent
|
||||
* - ddd
|
||||
* - eee
|
||||
* - fff
|
||||
*
|
||||
* final state:
|
||||
* - aaa
|
||||
* - bbb
|
||||
* - ccc
|
||||
* - ddd
|
||||
* - eee
|
||||
* - fff
|
||||
*/
|
||||
|
||||
/**
|
||||
* ccc
|
||||
*/
|
||||
const model = store.getBlock(blockId)?.model;
|
||||
if (!model || !matchModels(model, [ListBlockModel])) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* bbb
|
||||
*/
|
||||
const parent = store.getParent(model);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
if (store.readonly || parent.role !== 'content') {
|
||||
// Top most list cannot be unindent
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* aaa
|
||||
*/
|
||||
const grandParent = store.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: IndentContext;
|
||||
}> = (ctx, next) => {
|
||||
const { indentContext: dedentContext, std } = ctx;
|
||||
const { store, 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 = 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();
|
||||
|
||||
/**
|
||||
* step 1:
|
||||
* - aaa
|
||||
* - bbb
|
||||
* - ccc
|
||||
* - ddd
|
||||
* - eee <- make eee as ccc's child
|
||||
* - fff
|
||||
*/
|
||||
const nextSiblings = store.getNexts(model); // [eee]
|
||||
store.moveBlocks(nextSiblings, model);
|
||||
/**
|
||||
* eee
|
||||
*/
|
||||
const nextSibling = nextSiblings.at(0);
|
||||
if (nextSibling) correctNumberedListsOrderToPrev(store, nextSibling);
|
||||
|
||||
/**
|
||||
* step 2:
|
||||
* - aaa
|
||||
* - bbb
|
||||
* - ccc <- make ccc as aaa's child
|
||||
* - ddd
|
||||
* - eee
|
||||
* - fff
|
||||
*/
|
||||
store.moveBlocks([model], grandParent, parent, false);
|
||||
correctNumberedListsOrderToPrev(store, model);
|
||||
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (textSelection) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
range.syncTextSelectionToRange(textSelection);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
147
blocksuite/affine/blocks/list/src/commands/indent-list.ts
Normal file
147
blocksuite/affine/blocks/list/src/commands/indent-list.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { ListBlockModel, ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import type { IndentContext } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
getNearestHeadingBefore,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type Command, TextSelection } from '@blocksuite/std';
|
||||
|
||||
import { correctNumberedListsOrderToPrev } from './utils.js';
|
||||
|
||||
export const canIndentListCommand: Command<
|
||||
Partial<Omit<IndentContext, 'type' | 'flavour'>>,
|
||||
{
|
||||
indentContext: IndentContext;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
let { blockId, inlineIndex } = ctx;
|
||||
const { std } = ctx;
|
||||
const { selection, store } = std;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* initial state:
|
||||
* - aaa
|
||||
* - bbb
|
||||
* - ccc <- indent
|
||||
* - ddd
|
||||
* - eee
|
||||
*
|
||||
* final state:
|
||||
* - aaa
|
||||
* - bbb
|
||||
* - ccc
|
||||
* - ddd
|
||||
* - eee
|
||||
*/
|
||||
|
||||
/**
|
||||
* ccc
|
||||
*/
|
||||
const model = store.getBlock(blockId)?.model;
|
||||
if (!model || !matchModels(model, [ListBlockModel])) {
|
||||
return;
|
||||
}
|
||||
const schema = std.store.schema;
|
||||
/**
|
||||
* aaa
|
||||
*/
|
||||
const previousSibling = store.getPrev(model);
|
||||
if (
|
||||
store.readonly ||
|
||||
!previousSibling ||
|
||||
!schema.isValid(model.flavour, previousSibling.flavour)
|
||||
) {
|
||||
// cannot indent, do nothing
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* eee
|
||||
*/
|
||||
// const nextSibling = store.getNext(model);
|
||||
|
||||
return next({
|
||||
indentContext: {
|
||||
blockId,
|
||||
inlineIndex,
|
||||
type: 'indent',
|
||||
flavour: 'affine:list',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const indentListCommand: Command<{
|
||||
indentContext: IndentContext;
|
||||
}> = (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 { store, selection, host, range } = std;
|
||||
|
||||
const model = store.getBlock(blockId)?.model;
|
||||
if (!model) return;
|
||||
|
||||
const previousSibling = store.getPrev(model);
|
||||
if (!previousSibling) return;
|
||||
|
||||
const nextSibling = store.getNext(model);
|
||||
|
||||
store.captureSync();
|
||||
|
||||
store.moveBlocks([model], previousSibling);
|
||||
correctNumberedListsOrderToPrev(store, model);
|
||||
if (nextSibling) correctNumberedListsOrderToPrev(store, nextSibling);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (textSelection) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
range.syncTextSelectionToRange(textSelection);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
5
blocksuite/affine/blocks/list/src/commands/index.ts
Normal file
5
blocksuite/affine/blocks/list/src/commands/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { convertToNumberedListCommand } from './convert-to-numbered-list.js';
|
||||
export { canDedentListCommand, dedentListCommand } from './dedent-list.js';
|
||||
export { canIndentListCommand, indentListCommand } from './indent-list.js';
|
||||
export { listToParagraphCommand } from './list-to-paragraph.js';
|
||||
export { splitListCommand } from './split-list.js';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ListBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
|
||||
export const listToParagraphCommand: Command<
|
||||
{
|
||||
id: string;
|
||||
stopCapturing?: boolean;
|
||||
},
|
||||
{
|
||||
listConvertedId: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { id, stopCapturing = true } = ctx;
|
||||
const std = ctx.std;
|
||||
const doc = std.store;
|
||||
const model = doc.getBlock(id)?.model;
|
||||
|
||||
if (!model || !matchModels(model, [ListBlockModel])) 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.store.captureSync();
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
const listConvertedId = doc.addBlock(
|
||||
'affine:paragraph',
|
||||
blockProps,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
focusTextModel(std, listConvertedId);
|
||||
return next({ listConvertedId });
|
||||
};
|
||||
250
blocksuite/affine/blocks/list/src/commands/split-list.ts
Normal file
250
blocksuite/affine/blocks/list/src/commands/split-list.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { ListBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
getNextContinuousNumberedLists,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { Command, EditorHost } from '@blocksuite/std';
|
||||
|
||||
import { canDedentListCommand, dedentListCommand } from './dedent-list.js';
|
||||
import { correctNumberedListsOrderToPrev } from './utils.js';
|
||||
|
||||
export const splitListCommand: Command<{
|
||||
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 || !matchModels(model, [ListBlockModel])) {
|
||||
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.props.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.props.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()
|
||||
.pipe(canDedentListCommand, {
|
||||
blockId,
|
||||
inlineIndex: 0,
|
||||
})
|
||||
.pipe(dedentListCommand)
|
||||
.run();
|
||||
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let newListId: string | null = null;
|
||||
|
||||
if (model.children.length > 0 && !model.props.collapsed) {
|
||||
const afterText = model.props.text.split(inlineIndex);
|
||||
if (inlineIndex === 0) {
|
||||
/**
|
||||
* case 3: list has children (list not collapsed), split the list at the start of line
|
||||
*
|
||||
* before:
|
||||
* - |aaa <- split here
|
||||
* - bbb
|
||||
*
|
||||
* after:
|
||||
* -
|
||||
* - |aaa
|
||||
* - bbb
|
||||
*/
|
||||
newListId = doc.addBlock(
|
||||
'affine:list',
|
||||
{
|
||||
type: model.props.type,
|
||||
text: afterText,
|
||||
order:
|
||||
model.props.type === 'numbered' && model.props.order !== null
|
||||
? model.props.order + 1
|
||||
: null,
|
||||
},
|
||||
parent,
|
||||
modelIndex + 1
|
||||
);
|
||||
const newList = doc.getBlock(newListId)?.model;
|
||||
if (!newList) return;
|
||||
// move children to new list
|
||||
doc.moveBlocks(model.children, newList);
|
||||
|
||||
if (model.props.type === 'numbered' && model.props.order !== null) {
|
||||
const nextContinuousNumberedLists = getNextContinuousNumberedLists(
|
||||
doc,
|
||||
newListId
|
||||
);
|
||||
let base = model.props.order + 2;
|
||||
nextContinuousNumberedLists.forEach(list => {
|
||||
doc.transact(() => {
|
||||
list.props.order = base;
|
||||
});
|
||||
base += 1;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* case 4: list has children (list not collapsed), split the list not at the start of line
|
||||
*
|
||||
* before:
|
||||
* - aa|a <- split here
|
||||
* - bbb
|
||||
*
|
||||
* after:
|
||||
* - aa
|
||||
* - |a
|
||||
* - bbb
|
||||
*/
|
||||
newListId = doc.addBlock(
|
||||
'affine:list',
|
||||
{
|
||||
type: model.props.type,
|
||||
text: afterText,
|
||||
order: model.props.type === 'numbered' ? 1 : null,
|
||||
},
|
||||
model,
|
||||
0
|
||||
);
|
||||
|
||||
if (model.props.type === 'numbered') {
|
||||
const nextContinuousNumberedLists = getNextContinuousNumberedLists(
|
||||
doc,
|
||||
newListId
|
||||
);
|
||||
let base = 2;
|
||||
nextContinuousNumberedLists.forEach(list => {
|
||||
doc.transact(() => {
|
||||
list.props.order = base;
|
||||
});
|
||||
base += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* case 5: list has children (list collapsed)
|
||||
*
|
||||
* before:
|
||||
* - aa|a <- split here
|
||||
* - bbb
|
||||
*
|
||||
* after:
|
||||
* - aa
|
||||
* - bbb
|
||||
* - |a
|
||||
*
|
||||
*
|
||||
* case 6: list does not have children
|
||||
*
|
||||
* before:
|
||||
* - aa|a <- split here
|
||||
* - bbb
|
||||
*
|
||||
* after:
|
||||
* - aa
|
||||
* - |a
|
||||
* - bbb
|
||||
*/
|
||||
const afterText = model.props.text.split(inlineIndex);
|
||||
newListId = doc.addBlock(
|
||||
'affine:list',
|
||||
{
|
||||
type: model.props.type,
|
||||
text: afterText,
|
||||
order: null,
|
||||
},
|
||||
parent,
|
||||
modelIndex + 1
|
||||
);
|
||||
correctNumberedListsOrderToPrev(doc, newListId);
|
||||
}
|
||||
|
||||
if (newListId) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
focusTextModel(std, newListId);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
next();
|
||||
return;
|
||||
}
|
||||
};
|
||||
70
blocksuite/affine/blocks/list/src/commands/utils.ts
Normal file
70
blocksuite/affine/blocks/list/src/commands/utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ListBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
getNextContinuousNumberedLists,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockModel, Store } 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: Store,
|
||||
modelOrId: BlockModel | string,
|
||||
transact = true
|
||||
) {
|
||||
const model =
|
||||
typeof modelOrId === 'string' ? doc.getBlock(modelOrId)?.model : modelOrId;
|
||||
|
||||
if (!model) return;
|
||||
|
||||
if (
|
||||
!matchModels(model, [ListBlockModel]) ||
|
||||
model.props.type$.value !== 'numbered'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fn = () => {
|
||||
// step 1
|
||||
const previousSibling = doc.getPrev(model);
|
||||
if (
|
||||
previousSibling &&
|
||||
matchModels(previousSibling, [ListBlockModel]) &&
|
||||
previousSibling.props.type === 'numbered'
|
||||
) {
|
||||
if (!previousSibling.props.order) previousSibling.props.order = 1;
|
||||
model.props.order = previousSibling.props.order + 1;
|
||||
} else {
|
||||
model.props.order = 1;
|
||||
}
|
||||
|
||||
// step 2
|
||||
let base = model.props.order + 1;
|
||||
const continuousNumberedLists = getNextContinuousNumberedLists(doc, model);
|
||||
continuousNumberedLists.forEach(list => {
|
||||
list.props.order = base;
|
||||
base++;
|
||||
});
|
||||
};
|
||||
|
||||
if (transact) {
|
||||
doc.transact(fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
export function correctListOrder(doc: Store, model: ListBlockModel) {
|
||||
// old numbered list has no order
|
||||
if (model.props.type === 'numbered' && !Number.isInteger(model.props.order)) {
|
||||
correctNumberedListsOrderToPrev(doc, model, false);
|
||||
}
|
||||
// if list is not numbered, order should be null
|
||||
if (model.props.type !== 'numbered') {
|
||||
model.props.order = null;
|
||||
}
|
||||
}
|
||||
11
blocksuite/affine/blocks/list/src/effects.ts
Normal file
11
blocksuite/affine/blocks/list/src/effects.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ListBlockComponent } from './list-block.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-list', ListBlockComponent);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-list': ListBlockComponent;
|
||||
}
|
||||
}
|
||||
7
blocksuite/affine/blocks/list/src/index.ts
Normal file
7
blocksuite/affine/blocks/list/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './adapters/index.js';
|
||||
export * from './commands';
|
||||
export { correctNumberedListsOrderToPrev } from './commands/utils';
|
||||
export * from './list-block.js';
|
||||
export * from './list-spec.js';
|
||||
export * from './turbo/list-layout-handler';
|
||||
export * from './turbo/list-painter.worker';
|
||||
216
blocksuite/affine/blocks/list/src/list-block.ts
Normal file
216
blocksuite/affine/blocks/list/src/list-block.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import '@blocksuite/affine-shared/commands';
|
||||
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import { playCheckAnimation } from '@blocksuite/affine-components/icons';
|
||||
import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import type { ListBlockModel } 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 { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { BlockSelection, TextSelection } from '@blocksuite/std';
|
||||
import {
|
||||
getInlineRangeProvider,
|
||||
type InlineRangeProvider,
|
||||
} from '@blocksuite/std/inline';
|
||||
import type { BaseSelection } from '@blocksuite/store';
|
||||
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 { listBlockStyles } from './styles.js';
|
||||
import { getListIcon } from './utils/get-list-icon.js';
|
||||
|
||||
export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel> {
|
||||
static override styles = listBlockStyles;
|
||||
|
||||
private _inlineRangeProvider: InlineRangeProvider | null = null;
|
||||
|
||||
private readonly _onClickIcon = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (this.model.props.type === 'toggle') {
|
||||
if (this.doc.readonly) {
|
||||
this._readonlyCollapsed = !this._readonlyCollapsed;
|
||||
} else {
|
||||
this.doc.captureSync();
|
||||
this.doc.updateBlock(this.model, {
|
||||
collapsed: !this.model.props.collapsed,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (this.model.props.type === 'todo') {
|
||||
if (this.doc.readonly) return;
|
||||
|
||||
this.doc.captureSync();
|
||||
const checkedPropObj = { checked: !this.model.props.checked };
|
||||
this.doc.updateBlock(this.model, checkedPropObj);
|
||||
if (this.model.props.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);
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(
|
||||
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR
|
||||
);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
private _select() {
|
||||
const selection = this.host.selection;
|
||||
selection.update(selList => {
|
||||
return selList
|
||||
.filter<BaseSelection>(
|
||||
sel => !sel.is(TextSelection) && !sel.is(BlockSelection)
|
||||
)
|
||||
.concat(selection.create(BlockSelection, { blockId: this.blockId }));
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._inlineRangeProvider = getInlineRangeProvider(this);
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const collapsed = this.model.props.collapsed$.value;
|
||||
this._readonlyCollapsed = collapsed;
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const type = this.model.props.type$.value;
|
||||
const order = this.model.props.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.props.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.props.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.props.type === 'todo' && this.model.props.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.props.text.yText}
|
||||
.inlineEventSource=${this.topContenteditableElement ?? nothing}
|
||||
.undoManager=${this.doc.history}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.markdownMatches=${this.inlineManager?.markdownMatches}
|
||||
.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)',
|
||||
};
|
||||
}
|
||||
151
blocksuite/affine/blocks/list/src/list-keymap.ts
Normal file
151
blocksuite/affine/blocks/list/src/list-keymap.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { textKeymap } from '@blocksuite/affine-inline-preset';
|
||||
import { ListBlockSchema } from '@blocksuite/affine-model';
|
||||
import { markdownInput } from '@blocksuite/affine-rich-text';
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { KeymapExtension, TextSelection } from '@blocksuite/std';
|
||||
|
||||
import {
|
||||
canDedentListCommand,
|
||||
dedentListCommand,
|
||||
} from './commands/dedent-list.js';
|
||||
import {
|
||||
canIndentListCommand,
|
||||
indentListCommand,
|
||||
} from './commands/indent-list.js';
|
||||
import { listToParagraphCommand } from './commands/list-to-paragraph.js';
|
||||
import { splitListCommand } from './commands/split-list.js';
|
||||
import { forwardDelete } from './utils/forward-delete.js';
|
||||
|
||||
export const ListKeymapExtension = KeymapExtension(
|
||||
std => {
|
||||
return {
|
||||
Enter: ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return false;
|
||||
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(splitListCommand, {
|
||||
blockId: text.from.blockId,
|
||||
inlineIndex: text.from.index,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
'Mod-Enter': ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return false;
|
||||
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(splitListCommand, {
|
||||
blockId: text.from.blockId,
|
||||
inlineIndex: text.from.index,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
Tab: ctx => {
|
||||
const [_, { selectedModels }] = std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand, {
|
||||
types: ['text'],
|
||||
})
|
||||
.run();
|
||||
if (selectedModels?.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return false;
|
||||
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(canIndentListCommand, {
|
||||
blockId: text.from.blockId,
|
||||
inlineIndex: text.from.index,
|
||||
})
|
||||
.pipe(indentListCommand)
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
'Shift-Tab': ctx => {
|
||||
const [_, { selectedModels }] = std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand, {
|
||||
types: ['text'],
|
||||
})
|
||||
.run();
|
||||
if (selectedModels?.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return false;
|
||||
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(canDedentListCommand, {
|
||||
blockId: text.from.blockId,
|
||||
inlineIndex: text.from.index,
|
||||
})
|
||||
.pipe(dedentListCommand)
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
Backspace: ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return false;
|
||||
const isCollapsed = text.isCollapsed();
|
||||
const isStart = isCollapsed && text.from.index === 0;
|
||||
if (!isStart) return false;
|
||||
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(listToParagraphCommand, {
|
||||
id: text.from.blockId,
|
||||
})
|
||||
.run();
|
||||
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,
|
||||
});
|
||||
14
blocksuite/affine/blocks/list/src/list-spec.ts
Normal file
14
blocksuite/affine/blocks/list/src/list-spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { ListBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import { ListKeymapExtension, ListTextKeymapExtension } from './list-keymap.js';
|
||||
|
||||
export const ListBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:list'),
|
||||
BlockViewExtension('affine:list', literal`affine-list`),
|
||||
ListKeymapExtension,
|
||||
ListTextKeymapExtension,
|
||||
ListBlockAdapterExtensions,
|
||||
].flat();
|
||||
64
blocksuite/affine/blocks/list/src/styles.ts
Normal file
64
blocksuite/affine/blocks/list/src/styles.ts
Normal file
@@ -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}
|
||||
`;
|
||||
145
blocksuite/affine/blocks/list/src/turbo/list-layout-handler.ts
Normal file
145
blocksuite/affine/blocks/list/src/turbo/list-layout-handler.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 { ListLayout } from './list-painter.worker';
|
||||
|
||||
export class ListLayoutHandlerExtension extends BlockLayoutHandlerExtension<ListLayout> {
|
||||
readonly blockType = 'affine:list';
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(
|
||||
BlockLayoutHandlersIdentifier('list'),
|
||||
ListLayoutHandlerExtension
|
||||
);
|
||||
}
|
||||
|
||||
queryLayout(component: GfxBlockComponent): ListLayout | null {
|
||||
// Select all list items within this list block
|
||||
const listItemSelector =
|
||||
'.affine-list-block-container .affine-list-rich-text-wrapper [data-v-text="true"]';
|
||||
const listItemNodes = component.querySelectorAll(listItemSelector);
|
||||
|
||||
if (listItemNodes.length === 0) return null;
|
||||
|
||||
const viewportRecord = component.gfx.viewport.deserializeRecord(
|
||||
component.dataset.viewportState
|
||||
);
|
||||
|
||||
if (!viewportRecord) return null;
|
||||
|
||||
const { zoom, viewScale } = viewportRecord;
|
||||
const list: ListLayout = {
|
||||
type: 'affine:list',
|
||||
items: [],
|
||||
};
|
||||
|
||||
listItemNodes.forEach(listItemNode => {
|
||||
const listItemWrapper = listItemNode.closest(
|
||||
'.affine-list-rich-text-wrapper'
|
||||
);
|
||||
if (!listItemWrapper) return;
|
||||
|
||||
// Determine list item type based on class
|
||||
let itemType: 'bulleted' | 'numbered' | 'todo' | 'toggle' = 'bulleted';
|
||||
let checked = false;
|
||||
let collapsed = false;
|
||||
let prefix = '';
|
||||
|
||||
if (listItemWrapper.classList.contains('affine-list--checked')) {
|
||||
checked = true;
|
||||
}
|
||||
|
||||
const parentListBlock = listItemWrapper.closest(
|
||||
'.affine-list-block-container'
|
||||
)?.parentElement;
|
||||
if (parentListBlock) {
|
||||
if (parentListBlock.dataset.listType === 'numbered') {
|
||||
itemType = 'numbered';
|
||||
const orderVal = parentListBlock.dataset.listOrder;
|
||||
if (orderVal) {
|
||||
prefix = orderVal + '.';
|
||||
}
|
||||
} else if (parentListBlock.dataset.listType === 'todo') {
|
||||
itemType = 'todo';
|
||||
} else if (parentListBlock.dataset.listType === 'toggle') {
|
||||
itemType = 'toggle';
|
||||
collapsed = parentListBlock.dataset.collapsed === 'true';
|
||||
} else {
|
||||
itemType = 'bulleted';
|
||||
}
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(listItemNode);
|
||||
const fontSizeStr = computedStyle.fontSize;
|
||||
const fontSize = parseInt(fontSizeStr);
|
||||
|
||||
const sentences = segmentSentences(listItemNode.textContent || '');
|
||||
const sentenceLayouts = sentences.map(sentence => {
|
||||
const sentenceRects = getSentenceRects(listItemNode, sentence);
|
||||
return {
|
||||
text: sentence,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
fontSize,
|
||||
type: itemType,
|
||||
prefix,
|
||||
checked,
|
||||
collapsed,
|
||||
};
|
||||
});
|
||||
|
||||
list.items.push(...sentenceLayouts);
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
calculateBound(layout: ListLayout) {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
layout.items.forEach(item => {
|
||||
item.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.items.flatMap(s => s.rects.map(r => r.rect)),
|
||||
};
|
||||
}
|
||||
}
|
||||
114
blocksuite/affine/blocks/list/src/turbo/list-painter.worker.ts
Normal file
114
blocksuite/affine/blocks/list/src/turbo/list-painter.worker.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
BlockLayout,
|
||||
BlockLayoutPainter,
|
||||
TextRect,
|
||||
WorkerToHostMessage,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import {
|
||||
BlockLayoutPainterExtension,
|
||||
getBaseline,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
|
||||
|
||||
interface ListItemLayout {
|
||||
text: string;
|
||||
rects: TextRect[];
|
||||
fontSize: number;
|
||||
type: 'bulleted' | 'numbered' | 'todo' | 'toggle';
|
||||
prefix?: string;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface ListLayout extends BlockLayout {
|
||||
type: 'affine:list';
|
||||
items: ListItemLayout[];
|
||||
}
|
||||
|
||||
const debugListBorder = false;
|
||||
|
||||
function isListLayout(layout: BlockLayout): layout is ListLayout {
|
||||
return layout.type === 'affine:list';
|
||||
}
|
||||
|
||||
class ListLayoutPainter implements BlockLayoutPainter {
|
||||
private static readonly supportFontFace =
|
||||
typeof FontFace !== 'undefined' &&
|
||||
typeof self !== 'undefined' &&
|
||||
'fonts' in self;
|
||||
|
||||
static readonly font = ListLayoutPainter.supportFontFace
|
||||
? new FontFace(
|
||||
'Inter',
|
||||
`url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ8UA3.woff2)`
|
||||
)
|
||||
: null;
|
||||
|
||||
static fontLoaded = !ListLayoutPainter.supportFontFace;
|
||||
|
||||
static {
|
||||
if (ListLayoutPainter.supportFontFace && ListLayoutPainter.font) {
|
||||
// @ts-expect-error worker fonts API
|
||||
self.fonts.add(ListLayoutPainter.font);
|
||||
|
||||
ListLayoutPainter.font
|
||||
.load()
|
||||
.then(() => {
|
||||
ListLayoutPainter.fontLoaded = true;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load Inter font:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
paint(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
layout: BlockLayout,
|
||||
layoutBaseX: number,
|
||||
layoutBaseY: number
|
||||
): void {
|
||||
if (!ListLayoutPainter.fontLoaded) {
|
||||
const message: WorkerToHostMessage = {
|
||||
type: 'paintError',
|
||||
error: 'Font not loaded',
|
||||
blockType: 'affine:list',
|
||||
};
|
||||
self.postMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isListLayout(layout)) return;
|
||||
|
||||
const renderedPositions = new Set<string>();
|
||||
|
||||
layout.items.forEach(item => {
|
||||
const fontSize = item.fontSize;
|
||||
const baselineY = getBaseline(fontSize);
|
||||
|
||||
ctx.font = `${fontSize}px Inter`;
|
||||
ctx.strokeStyle = 'yellow';
|
||||
// Render the text content
|
||||
item.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 (debugListBorder) {
|
||||
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 ListLayoutPainterExtension = BlockLayoutPainterExtension(
|
||||
'affine:list',
|
||||
ListLayoutPainter
|
||||
);
|
||||
75
blocksuite/affine/blocks/list/src/utils/forward-delete.ts
Normal file
75
blocksuite/affine/blocks/list/src/utils/forward-delete.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ListBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
getNextContentBlock,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, TextSelection } from '@blocksuite/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(TextSelection);
|
||||
if (!text) return;
|
||||
const isCollapsed = text.isCollapsed();
|
||||
const doc = std.store;
|
||||
const model = doc.getBlock(text.from.blockId)?.model;
|
||||
if (!model || !matchModels(model, [ListBlockModel])) return;
|
||||
const isEnd = isCollapsed && text.from.index === model.props.text.length;
|
||||
if (!isEnd) return;
|
||||
// Has children in list
|
||||
const firstChild = model.firstChild();
|
||||
if (firstChild) {
|
||||
model.props.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.props.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.props.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;
|
||||
}
|
||||
78
blocksuite/affine/blocks/list/src/utils/get-list-icon.ts
Normal file
78
blocksuite/affine/blocks/list/src/utils/get-list-icon.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ListBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BulletedList01Icon,
|
||||
BulletedList02Icon,
|
||||
BulletedList03Icon,
|
||||
BulletedList04Icon,
|
||||
CheckBoxCheckSolidIcon,
|
||||
CheckBoxUnIcon,
|
||||
ToggleDownIcon,
|
||||
ToggleRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
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;
|
||||
};
|
||||
|
||||
const BulletIcons = [
|
||||
BulletedList01Icon({ width: '24px', height: '24px' }),
|
||||
BulletedList02Icon({ width: '24px', height: '24px' }),
|
||||
BulletedList03Icon({ width: '24px', height: '24px' }),
|
||||
BulletedList04Icon({ width: '24px', height: '24px' }),
|
||||
];
|
||||
|
||||
export function getListIcon(
|
||||
model: ListBlockModel,
|
||||
showChildren: boolean,
|
||||
onClick: (e: MouseEvent) => void
|
||||
) {
|
||||
const deep = getListDeep(model);
|
||||
switch (model.props.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.props.order ? getNumberPrefix(model.props.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.props.checked
|
||||
? CheckBoxCheckSolidIcon({ style: 'color: #1E96EB' })
|
||||
: CheckBoxUnIcon()}
|
||||
</div>`;
|
||||
case 'toggle':
|
||||
return html`<div
|
||||
contenteditable="false"
|
||||
class="affine-list-block__prefix"
|
||||
@click=${onClick}
|
||||
>
|
||||
${showChildren ? ToggleDownIcon() : ToggleRightIcon()}
|
||||
</div>`;
|
||||
default:
|
||||
console.error('Unknown list type', model.props.type, model);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
52
blocksuite/affine/blocks/list/src/utils/get-number-prefix.ts
Normal file
52
blocksuite/affine/blocks/list/src/utils/get-number-prefix.ts
Normal file
@@ -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}.`;
|
||||
}
|
||||
Reference in New Issue
Block a user