refactor(editor): extract note block (#9310)

This commit is contained in:
Saul-Mirone
2024-12-26 01:30:43 +00:00
parent 40b90ef51b
commit 2ffd0e561c
50 changed files with 467 additions and 394 deletions
@@ -0,0 +1,220 @@
import {
asyncSetInlineRange,
focusTextModel,
onModelTextUpdated,
} from '@blocksuite/affine-components/rich-text';
import {
matchFlavours,
mergeToCodeModel,
transformModel,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
type UpdateBlockConfig = {
flavour: BlockSuite.Flavour;
props?: Record<string, unknown>;
};
export const updateBlockType: Command<
'selectedBlocks',
'updatedBlocks',
UpdateBlockConfig
> = (ctx, next) => {
const { std, flavour, props } = ctx;
const host = std.host;
const doc = std.doc;
const getSelectedBlocks = () => {
let { selectedBlocks } = ctx;
if (selectedBlocks == null) {
const [result, ctx] = std.command
.chain()
.tryAll(chain => [chain.getTextSelection(), chain.getBlockSelections()])
.getSelectedBlocks({ types: ['text', 'block'] })
.run();
if (result) {
selectedBlocks = ctx.selectedBlocks;
}
}
return selectedBlocks;
};
const selectedBlocks = getSelectedBlocks();
if (!selectedBlocks || selectedBlocks.length === 0) return false;
const blockModels = selectedBlocks.map(ele => ele.model);
const hasSameDoc = selectedBlocks.every(block => block.doc === doc);
if (!hasSameDoc) {
// doc check
console.error(
'Not all models have the same doc instance, the result for update text type may not be correct',
selectedBlocks
);
}
const mergeToCode: Command<never, 'updatedBlocks'> = (_, next) => {
if (flavour !== 'affine:code') return;
const id = mergeToCodeModel(blockModels);
if (!id) return;
const model = doc.getBlockById(id);
if (!model) return;
asyncSetInlineRange(host, model, {
index: model.text?.length ?? 0,
length: 0,
}).catch(console.error);
return next({ updatedBlocks: [model] });
};
const appendDivider: Command<never, 'updatedBlocks'> = (_, next) => {
if (flavour !== 'affine:divider') {
return false;
}
const model = blockModels.at(-1);
if (!model) {
return next({ updatedBlocks: [] });
}
const parent = doc.getParent(model);
if (!parent) {
return next({ updatedBlocks: [] });
}
const index = parent.children.indexOf(model);
const nextSibling = doc.getNext(model);
let nextSiblingId = nextSibling?.id as string;
const id = doc.addBlock('affine:divider', {}, parent, index + 1);
if (!nextSibling) {
nextSiblingId = doc.addBlock('affine:paragraph', {}, parent);
}
focusTextModel(host.std, nextSiblingId);
const newModel = doc.getBlockById(id);
if (!newModel) {
return next({ updatedBlocks: [] });
}
return next({ updatedBlocks: [newModel] });
};
const focusText: Command<'updatedBlocks'> = (ctx, next) => {
const { updatedBlocks } = ctx;
if (!updatedBlocks || updatedBlocks.length === 0) {
return false;
}
const firstNewModel = updatedBlocks[0];
const lastNewModel = updatedBlocks[updatedBlocks.length - 1];
const allTextUpdated = updatedBlocks.map(model =>
onModelTextUpdated(host, model)
);
const selectionManager = host.selection;
const textSelection = selectionManager.find('text');
if (!textSelection) {
return false;
}
const newTextSelection = selectionManager.create('text', {
from: {
blockId: firstNewModel.id,
index: textSelection.from.index,
length: textSelection.from.length,
},
to: textSelection.to
? {
blockId: lastNewModel.id,
index: textSelection.to.index,
length: textSelection.to.length,
}
: null,
});
Promise.all(allTextUpdated)
.then(() => {
selectionManager.setGroup('note', [newTextSelection]);
})
.catch(console.error);
return next();
};
const focusBlock: Command<'updatedBlocks'> = (ctx, next) => {
const { updatedBlocks } = ctx;
if (!updatedBlocks || updatedBlocks.length === 0) {
return false;
}
const selectionManager = host.selection;
const blockSelections = selectionManager.filter('block');
if (blockSelections.length === 0) {
return false;
}
requestAnimationFrame(() => {
const selections = updatedBlocks.map(model => {
return selectionManager.create('block', {
blockId: model.id,
});
});
selectionManager.setGroup('note', selections);
});
return next();
};
const [result, resultCtx] = std.command
.chain()
.inline((_, next) => {
doc.captureSync();
return next();
})
// update block type
.try<'updatedBlocks'>(chain => [
chain.inline<'updatedBlocks'>(mergeToCode),
chain.inline<'updatedBlocks'>(appendDivider),
chain.inline<'updatedBlocks'>((_, next) => {
const newModels: BlockModel[] = [];
blockModels.forEach(model => {
if (
!matchFlavours(model, [
'affine:paragraph',
'affine:list',
'affine:code',
])
) {
return;
}
if (model.flavour === flavour) {
doc.updateBlock(model, props ?? {});
newModels.push(model);
return;
}
const newId = transformModel(model, flavour, props);
if (!newId) {
return;
}
const newModel = doc.getBlockById(newId);
if (newModel) {
newModels.push(newModel);
}
});
return next({ updatedBlocks: newModels });
}),
])
// focus
.try(chain => [
chain.inline((_, next) => {
if (['affine:code', 'affine:divider'].includes(flavour)) {
return next();
}
return false;
}),
chain.inline(focusText),
chain.inline(focusBlock),
chain.inline((_, next) => next()),
])
.run();
if (!result) {
return false;
}
return next({ updatedBlocks: resultCtx.updatedBlocks });
};
@@ -0,0 +1,39 @@
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
export const dedentBlockToRoot: Command<
never,
never,
{
blockId?: string;
stopCapture?: boolean;
}
> = (ctx, next) => {
let { blockId } = ctx;
const { std, stopCapture = true } = ctx;
const { doc } = std;
if (!blockId) {
const sel = std.selection.getGroup('note').at(0);
blockId = sel?.blockId;
}
if (!blockId) return;
const model = std.doc.getBlock(blockId)?.model;
if (!model) return;
let parent = doc.getParent(model);
let changed = false;
while (parent && !matchFlavours(parent, ['affine:note'])) {
if (!changed) {
if (stopCapture) doc.captureSync();
changed = true;
}
std.command.exec('dedentBlock', { blockId: model.id, stopCapture: true });
parent = doc.getParent(model);
}
if (!changed) {
return;
}
return next();
};
@@ -0,0 +1,70 @@
import {
calculateCollapsedSiblings,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
/**
* @example
* before unindent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*
* after unindent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*/
export const dedentBlock: Command<
never,
never,
{
blockId?: string;
stopCapture?: boolean;
}
> = (ctx, next) => {
let { blockId } = ctx;
const { std, stopCapture = true } = ctx;
const { doc } = std;
if (!blockId) {
const sel = std.selection.getGroup('note').at(0);
blockId = sel?.blockId;
}
if (!blockId) return;
const model = std.doc.getBlock(blockId)?.model;
if (!model) return;
const parent = doc.getParent(model);
const grandParent = parent && doc.getParent(parent);
if (doc.readonly || !parent || parent.role !== 'content' || !grandParent) {
// Top most, can not unindent, do nothing
return;
}
if (stopCapture) doc.captureSync();
if (
matchFlavours(model, ['affine:paragraph']) &&
model.type.startsWith('h') &&
model.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
doc.moveBlocks([model, ...collapsedSiblings], grandParent, parent, false);
return next();
}
try {
const nextSiblings = doc.getNexts(model);
doc.moveBlocks(nextSiblings, model);
doc.moveBlocks([model], grandParent, parent, false);
} catch {
return;
}
return next();
};
@@ -0,0 +1,44 @@
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
export const dedentBlocksToRoot: Command<
never,
never,
{
blockIds?: string[];
stopCapture?: boolean;
}
> = (ctx, next) => {
let { blockIds } = ctx;
const { std, stopCapture = true } = ctx;
const { doc } = std;
if (!blockIds || !blockIds.length) {
const text = std.selection.find('text');
if (text) {
// If the text selection is not at the beginning of the block, use default behavior
if (text.from.index !== 0) return;
blockIds = [text.from.blockId, text.to?.blockId].filter(
(x): x is string => !!x
);
} else {
blockIds = std.selection.getGroup('note').map(sel => sel.blockId);
}
}
if (!blockIds || !blockIds.length || doc.readonly) return;
if (stopCapture) doc.captureSync();
for (let i = blockIds.length - 1; i >= 0; i--) {
const model = blockIds[i];
const parent = doc.getParent(model);
if (parent && !matchFlavours(parent, ['affine:note'])) {
std.command.exec('dedentBlockToRoot', {
blockId: model,
stopCapture: false,
});
}
}
return next();
};
@@ -0,0 +1,88 @@
import {
calculateCollapsedSiblings,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
export const dedentBlocks: Command<
never,
never,
{
blockIds?: string[];
stopCapture?: boolean;
}
> = (ctx, next) => {
let { blockIds } = ctx;
const { std, stopCapture = true } = ctx;
const { doc, selection, range, host } = std;
const { schema } = doc;
if (!blockIds || !blockIds.length) {
const nativeRange = range.value;
if (nativeRange) {
const topBlocks = range.getSelectedBlockComponentsByRange(nativeRange, {
match: el => el.model.role === 'content',
mode: 'highest',
});
if (topBlocks.length > 0) {
blockIds = topBlocks.map(block => block.blockId);
}
} else {
blockIds = std.selection.getGroup('note').map(sel => sel.blockId);
}
}
if (!blockIds || !blockIds.length || doc.readonly) return;
// Find the first model that can be unindented
let firstDedentIndex = -1;
for (let i = 0; i < blockIds.length; i++) {
const model = doc.getBlock(blockIds[i])?.model;
if (!model) continue;
const parent = doc.getParent(blockIds[i]);
if (!parent) continue;
const grandParent = doc.getParent(parent);
if (!grandParent) continue;
if (schema.isValid(model.flavour, grandParent.flavour)) {
firstDedentIndex = i;
break;
}
}
if (firstDedentIndex === -1) return;
if (stopCapture) doc.captureSync();
const collapsedIds: string[] = [];
blockIds.slice(firstDedentIndex).forEach(id => {
const model = doc.getBlock(id)?.model;
if (!model) return;
if (
matchFlavours(model, ['affine:paragraph']) &&
model.type.startsWith('h') &&
model.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
collapsedIds.push(...collapsedSiblings.map(sibling => sibling.id));
}
});
// Models waiting to be dedented
const dedentIds = blockIds
.slice(firstDedentIndex)
.filter(id => !collapsedIds.includes(id));
dedentIds.reverse().forEach(id => {
std.command.exec('dedentBlock', { blockId: id, stopCapture: false });
});
const textSelection = selection.find('text');
if (textSelection) {
host.updateComplete
.then(() => {
range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
}
return next();
};
@@ -0,0 +1,21 @@
import type { Command } from '@blocksuite/block-std';
export const focusBlockEnd: Command<'focusBlock'> = (ctx, next) => {
const { focusBlock, std } = ctx;
if (!focusBlock || !focusBlock.model.text) return;
const { selection } = std;
selection.setGroup('note', [
selection.create('text', {
from: {
blockId: focusBlock.blockId,
index: focusBlock.model.text.length,
length: 0,
},
to: null,
}),
]);
return next();
};
@@ -0,0 +1,17 @@
import type { Command } from '@blocksuite/block-std';
export const focusBlockStart: Command<'focusBlock'> = (ctx, next) => {
const { focusBlock, std } = ctx;
if (!focusBlock || !focusBlock.model.text) return;
const { selection } = std;
selection.setGroup('note', [
selection.create('text', {
from: { blockId: focusBlock.blockId, index: 0, length: 0 },
to: null,
}),
]);
return next();
};
@@ -0,0 +1,78 @@
import type { ListBlockModel } from '@blocksuite/affine-model';
import {
calculateCollapsedSiblings,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
/**
* @example
* before indent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*
* after indent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*/
export const indentBlock: Command<
never,
never,
{
blockId?: string;
stopCapture?: boolean;
}
> = (ctx, next) => {
let { blockId } = ctx;
const { std, stopCapture = true } = ctx;
const { doc } = std;
const { schema } = doc;
if (!blockId) {
const sel = std.selection.getGroup('note').at(0);
blockId = sel?.blockId;
}
if (!blockId) return;
const model = std.doc.getBlock(blockId)?.model;
if (!model) return;
const previousSibling = doc.getPrev(model);
if (
doc.readonly ||
!previousSibling ||
!schema.isValid(model.flavour, previousSibling.flavour)
) {
// can not indent, do nothing
return;
}
if (stopCapture) doc.captureSync();
if (
matchFlavours(model, ['affine:paragraph']) &&
model.type.startsWith('h') &&
model.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
doc.moveBlocks([model, ...collapsedSiblings], previousSibling);
} else {
doc.moveBlocks([model], previousSibling);
}
// update collapsed state of affine list
if (
matchFlavours(previousSibling, ['affine:list']) &&
previousSibling.collapsed
) {
doc.updateBlock(previousSibling, {
collapsed: false,
} as Partial<ListBlockModel>);
}
return next();
};
@@ -0,0 +1,130 @@
import {
calculateCollapsedSiblings,
getNearestHeadingBefore,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/block-std';
export const indentBlocks: Command<
never,
never,
{
blockIds?: string[];
stopCapture?: boolean;
}
> = (ctx, next) => {
let { blockIds } = ctx;
const { std, stopCapture = true } = ctx;
const { doc, selection, range, host } = std;
const { schema } = doc;
if (!blockIds || !blockIds.length) {
const nativeRange = range.value;
if (nativeRange) {
const topBlocks = range.getSelectedBlockComponentsByRange(nativeRange, {
match: el => el.model.role === 'content',
mode: 'highest',
});
if (topBlocks.length > 0) {
blockIds = topBlocks.map(block => block.blockId);
}
} else {
blockIds = std.selection.getGroup('note').map(sel => sel.blockId);
}
}
if (!blockIds || !blockIds.length || doc.readonly) return;
// Find the first model that can be indented
let firstIndentIndex = -1;
for (let i = 0; i < blockIds.length; i++) {
const previousSibling = doc.getPrev(blockIds[i]);
const model = doc.getBlock(blockIds[i])?.model;
if (
model &&
previousSibling &&
schema.isValid(model.flavour, previousSibling.flavour)
) {
firstIndentIndex = i;
break;
}
}
// No model can be indented
if (firstIndentIndex === -1) return;
if (stopCapture) doc.captureSync();
const collapsedIds: string[] = [];
blockIds.slice(firstIndentIndex).forEach(id => {
const model = doc.getBlock(id)?.model;
if (!model) return;
if (
matchFlavours(model, ['affine:paragraph']) &&
model.type.startsWith('h') &&
model.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
collapsedIds.push(...collapsedSiblings.map(sibling => sibling.id));
}
});
// Models waiting to be indented
const indentIds = blockIds
.slice(firstIndentIndex)
.filter(id => !collapsedIds.includes(id));
const firstModel = doc.getBlock(indentIds[0])?.model;
if (!firstModel) return;
{
// > # 123
// > # 456
// > # 789
//
// we need to update 123 collapsed state to false when indent 456 and 789
const nearestHeading = getNearestHeadingBefore(firstModel);
if (
nearestHeading &&
matchFlavours(nearestHeading, ['affine:paragraph']) &&
nearestHeading.collapsed
) {
doc.updateBlock(nearestHeading, {
collapsed: false,
});
}
}
indentIds.forEach(id => {
std.command.exec('indentBlock', { blockId: id, stopCapture: false });
});
{
// 123
// > # 456
// 789
// 012
//
// we need to update 456 collapsed state to false when indent 789 and 012
const nearestHeading = getNearestHeadingBefore(firstModel);
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,40 @@
import {
getBlockIndexCommand,
getBlockSelectionsCommand,
getNextBlockCommand,
getPrevBlockCommand,
getSelectedBlocksCommand,
} from '@blocksuite/affine-shared/commands';
import type { BlockCommands } from '@blocksuite/block-std';
import { updateBlockType } from './block-type.js';
import { dedentBlock } from './dedent-block.js';
import { dedentBlockToRoot } from './dedent-block-to-root.js';
import { dedentBlocks } from './dedent-blocks.js';
import { dedentBlocksToRoot } from './dedent-blocks-to-root.js';
import { focusBlockEnd } from './focus-block-end.js';
import { focusBlockStart } from './focus-block-start.js';
import { indentBlock } from './indent-block.js';
import { indentBlocks } from './indent-blocks.js';
import { selectBlock } from './select-block.js';
import { selectBlocksBetween } from './select-blocks-between.js';
export const commands: BlockCommands = {
// block
getBlockIndex: getBlockIndexCommand,
getPrevBlock: getPrevBlockCommand,
getNextBlock: getNextBlockCommand,
getSelectedBlocks: getSelectedBlocksCommand,
getBlockSelections: getBlockSelectionsCommand,
selectBlock,
selectBlocksBetween,
focusBlockStart,
focusBlockEnd,
updateBlockType,
indentBlock,
dedentBlock,
indentBlocks,
dedentBlocks,
dedentBlockToRoot,
dedentBlocksToRoot,
};
@@ -0,0 +1,16 @@
import type { Command } from '@blocksuite/block-std';
export const selectBlock: Command<'focusBlock'> = (ctx, next) => {
const { focusBlock, std } = ctx;
if (!focusBlock) {
return;
}
const { selection } = std;
selection.setGroup('note', [
selection.create('block', { blockId: focusBlock.blockId }),
]);
return next();
};
@@ -0,0 +1,49 @@
import type { Command } from '@blocksuite/block-std';
export const selectBlocksBetween: Command<
'focusBlock' | 'anchorBlock',
never,
{ tail: boolean }
> = (ctx, next) => {
const { focusBlock, anchorBlock, tail } = ctx;
if (!focusBlock || !anchorBlock) {
return;
}
const selection = ctx.std.selection;
// In same block
if (anchorBlock.blockId === focusBlock.blockId) {
const blockId = focusBlock.blockId;
selection.setGroup('note', [selection.create('block', { blockId })]);
return next();
}
// In different blocks
const selections = [...selection.value];
if (selections.every(sel => sel.blockId !== focusBlock.blockId)) {
if (tail) {
selections.push(
selection.create('block', { blockId: focusBlock.blockId })
);
} else {
selections.unshift(
selection.create('block', { blockId: focusBlock.blockId })
);
}
}
let start = false;
const sel = selections.filter(sel => {
if (
sel.blockId === anchorBlock.blockId ||
sel.blockId === focusBlock.blockId
) {
start = !start;
return true;
}
return start;
});
selection.setGroup('note', sel);
return next();
};