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

View File

@@ -0,0 +1,44 @@
import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
/**
* Create a html adapter matcher for note block.
*
* @param displayModeToSkip - The note with specific display mode to skip.
* For example, the note with display mode `EdgelessOnly` should not be converted to html when current editor mode is `Doc(Page)`.
* @returns The html adapter matcher.
*/
const createNoteBlockHtmlAdapterMatcher = (
displayModeToSkip: NoteDisplayMode
): BlockHtmlAdapterMatcher => ({
flavour: NoteBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const node = o.node;
if (node.props.displayMode === displayModeToSkip) {
context.walkerContext.skipAllChildren();
}
},
},
});
export const docNoteBlockHtmlAdapterMatcher = createNoteBlockHtmlAdapterMatcher(
NoteDisplayMode.EdgelessOnly
);
export const edgelessNoteBlockHtmlAdapterMatcher =
createNoteBlockHtmlAdapterMatcher(NoteDisplayMode.DocOnly);
export const DocNoteBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
docNoteBlockHtmlAdapterMatcher
);
export const EdgelessNoteBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
edgelessNoteBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,26 @@
import type { ExtensionType } from '@blocksuite/block-std';
import {
DocNoteBlockHtmlAdapterExtension,
EdgelessNoteBlockHtmlAdapterExtension,
} from './html.js';
import {
DocNoteBlockMarkdownAdapterExtension,
EdgelessNoteBlockMarkdownAdapterExtension,
} from './markdown.js';
import {
DocNoteBlockPlainTextAdapterExtension,
EdgelessNoteBlockPlainTextAdapterExtension,
} from './plain-text.js';
export const DocNoteBlockAdapterExtensions: ExtensionType[] = [
DocNoteBlockMarkdownAdapterExtension,
DocNoteBlockHtmlAdapterExtension,
DocNoteBlockPlainTextAdapterExtension,
];
export const EdgelessNoteBlockAdapterExtensions: ExtensionType[] = [
EdgelessNoteBlockMarkdownAdapterExtension,
EdgelessNoteBlockHtmlAdapterExtension,
EdgelessNoteBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,41 @@
import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
/**
* Create a markdown adapter matcher for note block.
*
* @param displayModeToSkip - The note with specific display mode to skip.
* For example, the note with display mode `EdgelessOnly` should not be converted to markdown when current editor mode is `Doc`.
* @returns The markdown adapter matcher.
*/
const createNoteBlockMarkdownAdapterMatcher = (
displayModeToSkip: NoteDisplayMode
): BlockMarkdownAdapterMatcher => ({
flavour: NoteBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const node = o.node;
if (node.props.displayMode === displayModeToSkip) {
context.walkerContext.skipAllChildren();
}
},
},
});
export const docNoteBlockMarkdownAdapterMatcher =
createNoteBlockMarkdownAdapterMatcher(NoteDisplayMode.EdgelessOnly);
export const edgelessNoteBlockMarkdownAdapterMatcher =
createNoteBlockMarkdownAdapterMatcher(NoteDisplayMode.DocOnly);
export const DocNoteBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(docNoteBlockMarkdownAdapterMatcher);
export const EdgelessNoteBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(edgelessNoteBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,41 @@
import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
/**
* Create a plain text adapter matcher for note block.
*
* @param displayModeToSkip - The note with specific display mode to skip.
* For example, the note with display mode `EdgelessOnly` should not be converted to plain text when current editor mode is `Doc(Page)`.
* @returns The plain text adapter matcher.
*/
const createNoteBlockPlainTextAdapterMatcher = (
displayModeToSkip: NoteDisplayMode
): BlockPlainTextAdapterMatcher => ({
flavour: NoteBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const node = o.node;
if (node.props.displayMode === displayModeToSkip) {
context.walkerContext.skipAllChildren();
}
},
},
});
export const docNoteBlockPlainTextAdapterMatcher =
createNoteBlockPlainTextAdapterMatcher(NoteDisplayMode.EdgelessOnly);
export const edgelessNoteBlockPlainTextAdapterMatcher =
createNoteBlockPlainTextAdapterMatcher(NoteDisplayMode.DocOnly);
export const DocNoteBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(docNoteBlockPlainTextAdapterMatcher);
export const EdgelessNoteBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(edgelessNoteBlockPlainTextAdapterMatcher);

View File

@@ -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 });
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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,
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -0,0 +1,52 @@
import type { BlockComponent } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import type { updateBlockType } from './commands/block-type';
import type { dedentBlock } from './commands/dedent-block';
import type { dedentBlockToRoot } from './commands/dedent-block-to-root';
import type { dedentBlocks } from './commands/dedent-blocks';
import type { dedentBlocksToRoot } from './commands/dedent-blocks-to-root';
import type { focusBlockEnd } from './commands/focus-block-end';
import type { focusBlockStart } from './commands/focus-block-start';
import type { indentBlock } from './commands/indent-block';
import type { indentBlocks } from './commands/indent-blocks';
import type { selectBlock } from './commands/select-block';
import type { selectBlocksBetween } from './commands/select-blocks-between';
import { NoteBlockComponent } from './note-block';
import {
EdgelessNoteBlockComponent,
EdgelessNoteMask,
} from './note-edgeless-block';
import type { NoteBlockService } from './note-service';
export function effects() {
customElements.define('affine-note', NoteBlockComponent);
customElements.define('edgeless-note-mask', EdgelessNoteMask);
customElements.define('affine-edgeless-note', EdgelessNoteBlockComponent);
}
declare global {
namespace BlockSuite {
interface Commands {
selectBlock: typeof selectBlock;
selectBlocksBetween: typeof selectBlocksBetween;
focusBlockStart: typeof focusBlockStart;
focusBlockEnd: typeof focusBlockEnd;
indentBlocks: typeof indentBlocks;
dedentBlock: typeof dedentBlock;
dedentBlocksToRoot: typeof dedentBlocksToRoot;
dedentBlocks: typeof dedentBlocks;
indentBlock: typeof indentBlock;
updateBlockType: typeof updateBlockType;
dedentBlockToRoot: typeof dedentBlockToRoot;
}
interface CommandContext {
focusBlock?: BlockComponent | null;
anchorBlock?: BlockComponent | null;
updatedBlocks?: BlockModel[];
}
interface BlockServices {
'affine:note': NoteBlockService;
}
}
}

View File

@@ -0,0 +1,6 @@
export * from './adapters';
export * from './commands';
export * from './note-block';
export * from './note-edgeless-block';
export * from './note-service';
export * from './note-spec';

View File

@@ -0,0 +1,125 @@
import type { BlockSelection, BlockStdScope } from '@blocksuite/block-std';
const getSelection = (std: BlockStdScope) => std.selection;
function getBlockSelectionBySide(std: BlockStdScope, tail: boolean) {
const selection = getSelection(std);
const selections = selection.filter('block');
const sel = selections.at(tail ? -1 : 0) as BlockSelection | undefined;
return sel ?? null;
}
function getTextSelection(std: BlockStdScope) {
const selection = getSelection(std);
return selection.find('text');
}
const pathToBlock = (std: BlockStdScope, blockId: string) =>
std.view.getBlock(blockId);
interface MoveBlockConfig {
name: string;
hotkey: string[];
action: (std: BlockStdScope) => void;
}
export const moveBlockConfigs: MoveBlockConfig[] = [
{
name: 'Move Up',
hotkey: ['Mod-Alt-ArrowUp', 'Mod-Shift-ArrowUp'],
action: std => {
const doc = std.doc;
const textSelection = getTextSelection(std);
if (textSelection) {
const currentModel = pathToBlock(
std,
textSelection.from.blockId
)?.model;
if (!currentModel) return;
const previousSiblingModel = doc.getPrev(currentModel);
if (!previousSiblingModel) return;
const parentModel = std.doc.getParent(previousSiblingModel);
if (!parentModel) return;
std.doc.moveBlocks(
[currentModel],
parentModel,
previousSiblingModel,
true
);
std.host.updateComplete
.then(() => {
std.range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
return true;
}
const blockSelection = getBlockSelectionBySide(std, true);
if (blockSelection) {
const currentModel = pathToBlock(std, blockSelection.blockId)?.model;
if (!currentModel) return;
const previousSiblingModel = doc.getPrev(currentModel);
if (!previousSiblingModel) return;
const parentModel = doc.getParent(previousSiblingModel);
if (!parentModel) return;
doc.moveBlocks(
[currentModel],
parentModel,
previousSiblingModel,
false
);
return true;
}
return;
},
},
{
name: 'Move Down',
hotkey: ['Mod-Alt-ArrowDown', 'Mod-Shift-ArrowDown'],
action: std => {
const doc = std.doc;
const textSelection = getTextSelection(std);
if (textSelection) {
const currentModel = pathToBlock(
std,
textSelection.from.blockId
)?.model;
if (!currentModel) return;
const nextSiblingModel = doc.getNext(currentModel);
if (!nextSiblingModel) return;
const parentModel = doc.getParent(nextSiblingModel);
if (!parentModel) return;
doc.moveBlocks([currentModel], parentModel, nextSiblingModel, false);
std.host.updateComplete
.then(() => {
std.range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
return true;
}
const blockSelection = getBlockSelectionBySide(std, true);
if (blockSelection) {
const currentModel = pathToBlock(std, blockSelection.blockId)?.model;
if (!currentModel) return;
const nextSiblingModel = doc.getNext(currentModel);
if (!nextSiblingModel) return;
const parentModel = doc.getParent(nextSiblingModel);
if (!parentModel) return;
doc.moveBlocks([currentModel], parentModel, nextSiblingModel, false);
return true;
}
return;
},
},
];

View File

@@ -0,0 +1,39 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import { BlockComponent } from '@blocksuite/block-std';
import { css, html } from 'lit';
import type { NoteBlockService } from './note-service.js';
export class NoteBlockComponent extends BlockComponent<
NoteBlockModel,
NoteBlockService
> {
static override styles = css`
.affine-note-block-container {
display: flow-root;
}
.affine-note-block-container.selected {
background-color: var(--affine-hover-color);
}
`;
override connectedCallback() {
super.connectedCallback();
}
override renderBlock() {
return html`
<div class="affine-note-block-container">
<div class="affine-block-children-container">
${this.renderChildren(this.model)}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-note': NoteBlockComponent;
}
}

View File

@@ -0,0 +1,524 @@
import { MoreIndicatorIcon } from '@blocksuite/affine-components/icons';
import type { NoteBlockModel } from '@blocksuite/affine-model';
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
StrokeStyle,
} from '@blocksuite/affine-model';
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
getClosestBlockComponentByPoint,
handleNativeRangeAtPoint,
matchFlavours,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import type {
BlockComponent,
BlockService,
EditorHost,
} from '@blocksuite/block-std';
import { ShadowlessElement, toGfxBlockComponent } from '@blocksuite/block-std';
import {
almostEqual,
Bound,
clamp,
Point,
WithDisposable,
} from '@blocksuite/global/utils';
import type { BlockModel, Slot } from '@blocksuite/store';
import { css, html, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { NoteBlockComponent } from './note-block.js';
export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) {
protected override firstUpdated() {
const maskDOM = this.renderRoot!.querySelector('.affine-note-mask');
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
if (!this.model.edgeless.collapse) {
const bound = Bound.deserialize(this.model.xywh);
const scale = this.model.edgeless.scale ?? 1;
const height = entry.contentRect.height * scale;
if (!height || almostEqual(bound.h, height)) {
return;
}
bound.h = height;
this.model.stash('xywh');
this.model.xywh = bound.serialize();
this.model.pop('xywh');
}
}
});
observer.observe(maskDOM!);
this._disposables.add(() => {
observer.disconnect();
});
}
override render() {
const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
return html`
<div
class="affine-note-mask"
style=${styleMap({
position: 'absolute',
top: `${-extra}px`,
left: `${-extra}px`,
bottom: `${-extra}px`,
right: `${-extra}px`,
zIndex: '1',
pointerEvents: this.display ? 'auto' : 'none',
borderRadius: `${
this.model.edgeless.style.borderRadius * this.zoom
}px`,
})}
></div>
`;
}
@property({ attribute: false })
accessor display!: boolean;
@property({ attribute: false })
accessor editing!: boolean;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor model!: NoteBlockModel;
@property({ attribute: false })
accessor zoom!: number;
}
const ACTIVE_NOTE_EXTRA_PADDING = 20;
export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
NoteBlockComponent
) {
static override styles = css`
.edgeless-note-collapse-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
z-index: 2;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
opacity: 0.2;
transition: opacity 0.3s;
}
.edgeless-note-collapse-button:hover {
opacity: 1;
}
.edgeless-note-collapse-button.flip {
transform: translateX(-50%) rotate(180deg);
}
.edgeless-note-collapse-button.hide {
display: none;
}
.edgeless-note-container:has(.affine-embed-synced-doc-container.editing)
> .note-background {
left: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important;
top: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important;
width: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important;
height: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important;
}
.edgeless-note-container:has(.affine-embed-synced-doc-container.editing)
> edgeless-note-mask {
display: none;
}
`;
private get _isShowCollapsedContent() {
return this.model.edgeless.collapse && (this._isResizing || this._isHover);
}
get _zoom() {
return this.gfx.viewport.zoom;
}
get rootService() {
return this.std.getService('affine:page') as BlockService & {
slots: Record<string, Slot>;
};
}
private _collapsedContent() {
if (!this._isShowCollapsedContent) {
return nothing;
}
const { xywh, edgeless } = this.model;
const { borderSize } = edgeless.style;
const extraPadding = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
const extraBorder = this._editing ? borderSize : 0;
const bound = Bound.deserialize(xywh);
const scale = edgeless.scale ?? 1;
const width = bound.w / scale + extraPadding * 2 + extraBorder;
const height = bound.h / scale;
const rect = this._notePageContent?.getBoundingClientRect();
if (!rect) return nothing;
const zoom = this.gfx.viewport.zoom;
this._noteFullHeight =
rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING;
if (height >= this._noteFullHeight) {
return nothing;
}
return html`
<div
style=${styleMap({
width: `${width}px`,
height: `${this._noteFullHeight - height}px`,
position: 'absolute',
left: `${-(extraPadding + extraBorder / 2)}px`,
top: `${height + extraPadding + extraBorder / 2}px`,
background: 'var(--affine-white)',
opacity: 0.5,
pointerEvents: 'none',
borderLeft: '2px var(--affine-blue) solid',
borderBottom: '2px var(--affine-blue) solid',
borderRight: '2px var(--affine-blue) solid',
borderRadius: '0 0 8px 8px',
})}
></div>
`;
}
private _handleClickAtBackground(e: MouseEvent) {
e.stopPropagation();
if (!this._editing) return;
const rect = this.getBoundingClientRect();
const offsetY = 16 * this._zoom;
const offsetX = 2 * this._zoom;
const x = clamp(e.x, rect.left + offsetX, rect.right - offsetX);
const y = clamp(e.y, rect.top + offsetY, rect.bottom - offsetY);
handleNativeRangeAtPoint(x, y);
if (this.doc.readonly) return;
this._tryAddParagraph(x, y);
}
private _hovered() {
if (
this.selection.value.some(
sel => sel.type === 'surface' && sel.blockId === this.model.id
)
) {
this._isHover = true;
}
}
private _leaved() {
if (this._isHover) {
this._isHover = false;
}
}
private _setCollapse(event: MouseEvent) {
event.stopImmediatePropagation();
const { collapse, collapsedHeight } = this.model.edgeless;
if (collapse) {
this.model.doc.updateBlock(this.model, () => {
this.model.edgeless.collapse = false;
});
} else if (collapsedHeight) {
const { xywh, edgeless } = this.model;
const bound = Bound.deserialize(xywh);
bound.h = collapsedHeight * (edgeless.scale ?? 1);
this.model.doc.updateBlock(this.model, () => {
this.model.edgeless.collapse = true;
this.model.xywh = bound.serialize();
});
}
this.selection.clear();
}
private _tryAddParagraph(x: number, y: number) {
const nearest = getClosestBlockComponentByPoint(
new Point(x, y)
) as BlockComponent | null;
if (!nearest) return;
const nearestBBox = nearest.getBoundingClientRect();
const yRel = y - nearestBBox.top;
const insertPos: 'before' | 'after' =
yRel < nearestBBox.height / 2 ? 'before' : 'after';
const nearestModel = nearest.model as BlockModel;
const nearestModelIdx = this.model.children.indexOf(nearestModel);
const children = this.model.children;
const siblingModel =
children[
clamp(
nearestModelIdx + (insertPos === 'before' ? -1 : 1),
0,
children.length
)
];
if (
(!nearestModel.text ||
!matchFlavours(nearestModel, ['affine:paragraph', 'affine:list'])) &&
(!siblingModel ||
!siblingModel.text ||
!matchFlavours(siblingModel, ['affine:paragraph', 'affine:list']))
) {
const [pId] = this.doc.addSiblingBlocks(
nearestModel,
[{ flavour: 'affine:paragraph' }],
insertPos
);
this.updateComplete
.then(() => {
this.std.selection.setGroup('note', [
this.std.selection.create('text', {
from: {
blockId: pId,
index: 0,
length: 0,
},
to: null,
}),
]);
})
.catch(console.error);
}
}
override connectedCallback(): void {
super.connectedCallback();
const selection = this.gfx.selection;
this._editing = selection.has(this.model.id) && selection.editing;
this._disposables.add(
selection.slots.updated.on(() => {
if (selection.has(this.model.id) && selection.editing) {
this._editing = true;
} else {
this._editing = false;
}
})
);
}
override firstUpdated() {
const { _disposables } = this;
const selection = this.gfx.selection;
_disposables.add(
this.rootService.slots.elementResizeStart.on(() => {
if (selection.selectedElements.includes(this.model)) {
this._isResizing = true;
}
})
);
_disposables.add(
this.rootService.slots.elementResizeEnd.on(() => {
this._isResizing = false;
})
);
const observer = new MutationObserver(() => {
const rect = this._notePageContent?.getBoundingClientRect();
if (!rect) return;
const zoom = this.gfx.viewport.zoom;
const scale = this.model.edgeless.scale ?? 1;
this._noteFullHeight =
rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING;
});
if (this._notePageContent) {
observer.observe(this, { childList: true, subtree: true });
_disposables.add(() => observer.disconnect());
}
}
override getRenderingRect() {
const { xywh, edgeless } = this.model;
const { collapse, scale = 1 } = edgeless;
const bound = Bound.deserialize(xywh);
const width = bound.w / scale;
const height = bound.h / scale;
return {
x: bound.x,
y: bound.y,
w: width,
h: collapse ? height : 'inherit',
zIndex: this.toZIndex(),
};
}
override renderGfxBlock() {
const { model } = this;
const { displayMode } = model;
if (!!displayMode && displayMode === NoteDisplayMode.DocOnly)
return nothing;
const { xywh, edgeless } = model;
const { borderRadius, borderSize, borderStyle, shadowType } =
edgeless.style;
const { collapse, collapsedHeight, scale = 1 } = edgeless;
const bound = Bound.deserialize(xywh);
const width = bound.w / scale;
const height = bound.h / scale;
const style = {
height: '100%',
padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`,
boxSizing: 'border-box',
borderRadius: borderRadius + 'px',
pointerEvents: 'all',
transformOrigin: '0 0',
transform: `scale(${scale})`,
fontWeight: '400',
lineHeight: 'var(--affine-line-height)',
};
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
const backgroundColor = this.std
.get(ThemeProvider)
.generateColorProperty(model.background, DEFAULT_NOTE_BACKGROUND_COLOR);
const backgroundStyle = {
position: 'absolute',
left: `${-extra}px`,
top: `${-extra}px`,
width: `${width + extra * 2}px`,
height: `calc(100% + ${extra * 2}px)`,
borderRadius: borderRadius + 'px',
transition: this._editing
? 'left 0.3s, top 0.3s, width 0.3s, height 0.3s'
: 'none',
backgroundColor,
border: `${borderSize}px ${
borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle
} var(--affine-black-10)`,
boxShadow: this._editing
? 'var(--affine-active-shadow)'
: !shadowType
? 'none'
: `var(${shadowType})`,
};
const isCollapsable =
collapse != null &&
collapsedHeight != null &&
collapsedHeight !== this._noteFullHeight;
const isCollapseArrowUp = collapse
? this._noteFullHeight < height
: !!collapsedHeight && collapsedHeight < height;
return html`
<div
class="edgeless-note-container"
style=${styleMap(style)}
data-model-height="${bound.h}"
@mouseleave=${this._leaved}
@mousemove=${this._hovered}
data-scale="${scale}"
>
<div
class="note-background"
style=${styleMap(backgroundStyle)}
@pointerdown=${stopPropagation}
@click=${this._handleClickAtBackground}
></div>
<div
class="edgeless-note-page-content"
style=${styleMap({
width: '100%',
height: '100%',
'overflow-y': this._isShowCollapsedContent ? 'initial' : 'clip',
})}
>
${this.renderPageContent()}
</div>
${isCollapsable
? html`<div
class="${classMap({
'edgeless-note-collapse-button': true,
flip: isCollapseArrowUp,
hide: this._isSelected,
})}"
style=${styleMap({
bottom: this._editing ? `${-extra}px` : '0',
})}
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@click=${this._setCollapse}
>
${MoreIndicatorIcon}
</div>`
: nothing}
${this._collapsedContent()}
<edgeless-note-mask
.model=${this.model}
.display=${!this._editing}
.host=${this.host}
.zoom=${this.gfx.viewport.zoom ?? 1}
.editing=${this._editing}
></edgeless-note-mask>
</div>
`;
}
@state()
private accessor _editing = false;
@state()
private accessor _isHover = false;
@state()
private accessor _isResizing = false;
@state()
private accessor _isSelected = false;
@state()
private accessor _noteFullHeight = 0;
@query('.edgeless-note-page-content .affine-note-block-container')
private accessor _notePageContent: HTMLElement | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-note': EdgelessNoteBlockComponent;
}
}

View File

@@ -0,0 +1,602 @@
import { textConversionConfigs } from '@blocksuite/affine-components/rich-text';
import { NoteBlockSchema } from '@blocksuite/affine-model';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import {
type BaseSelection,
type BlockComponent,
type BlockSelection,
BlockService,
type BlockStdScope,
type UIEventHandler,
type UIEventStateContext,
} from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import { moveBlockConfigs } from './move-block';
import { quickActionConfig } from './quick-action';
export class NoteBlockService extends BlockService {
static override readonly flavour = NoteBlockSchema.model.flavour;
private _anchorSel: BlockSelection | null = null;
private readonly _bindMoveBlockHotKey = () => {
return moveBlockConfigs.reduce(
(acc, config) => {
const keys = config.hotkey.reduce(
(acc, key) => {
return {
...acc,
[key]: ctx => {
ctx.get('defaultState').event.preventDefault();
return config.action(this.std);
},
};
},
{} as Record<string, UIEventHandler>
);
return {
...acc,
...keys,
};
},
{} as Record<string, UIEventHandler>
);
};
private readonly _bindQuickActionHotKey = () => {
return quickActionConfig
.filter(config => config.hotkey)
.reduce(
(acc, config) => {
return {
...acc,
[config.hotkey!]: ctx => {
if (!config.showWhen(this.std)) return;
ctx.get('defaultState').event.preventDefault();
config.action(this.std);
},
};
},
{} as Record<string, UIEventHandler>
);
};
private readonly _bindTextConversionHotKey = () => {
return textConversionConfigs
.filter(item => item.hotkey)
.reduce(
(acc, item) => {
const keymap = item.hotkey!.reduce(
(acc, key) => {
return {
...acc,
[key]: ctx => {
ctx.get('defaultState').event.preventDefault();
const [result] = this._std.command
.chain()
.updateBlockType({
flavour: item.flavour,
props: {
type: item.type,
},
})
.inline((ctx, next) => {
const newModels = ctx.updatedBlocks;
if (!newModels) {
return;
}
if (item.flavour !== 'affine:code') {
return;
}
const [codeModel] = newModels;
onModelElementUpdated(ctx.std, codeModel, codeElement => {
this._std.selection.setGroup('note', [
this._std.selection.create('text', {
from: {
blockId: codeElement.blockId,
index: 0,
length: codeModel.text?.length ?? 0,
},
to: null,
}),
]);
}).catch(console.error);
next();
})
.run();
return result;
},
};
},
{} as Record<string, UIEventHandler>
);
return {
...acc,
...keymap,
};
},
{} as Record<string, UIEventHandler>
);
};
private _focusBlock: BlockComponent | null = null;
private readonly _getClosestNoteByBlockId = (blockId: string) => {
const doc = this._std.doc;
let parent = doc.getBlock(blockId)?.model ?? null;
while (parent) {
if (matchFlavours(parent, [NoteBlockSchema.model.flavour])) {
return parent;
}
parent = doc.getParent(parent);
}
return null;
};
private readonly _onArrowDown = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event;
const [result] = this._std.command
.chain()
.inline((_, next) => {
this._reset();
return next();
})
.try(cmd => [
// text selection - select the next block
// 1. is paragraph, list, code block - follow the default behavior
// 2. is not - select the next block (use block selection instead of text selection)
cmd
.getTextSelection()
.inline<'currentSelectionPath'>((ctx, next) => {
const currentTextSelection = ctx.currentTextSelection;
if (!currentTextSelection) {
return;
}
return next({ currentSelectionPath: currentTextSelection.blockId });
})
.getNextBlock()
.inline((ctx, next) => {
const { nextBlock } = ctx;
if (!nextBlock) {
return;
}
if (
!matchFlavours(nextBlock.model, [
'affine:paragraph',
'affine:list',
'affine:code',
])
) {
this._std.command
.chain()
.with({
focusBlock: nextBlock,
})
.selectBlock()
.run();
}
return next({});
}),
// block selection - select the next block
// 1. is paragraph, list, code block - focus it
// 2. is not - select it using block selection
cmd
.getBlockSelections()
.inline<'currentSelectionPath'>((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
const blockSelection = currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
return next({ currentSelectionPath: blockSelection.blockId });
})
.getNextBlock()
.inline<'focusBlock'>((ctx, next) => {
const { nextBlock } = ctx;
if (!nextBlock) {
return;
}
event.preventDefault();
if (
matchFlavours(nextBlock.model, [
'affine:paragraph',
'affine:list',
'affine:code',
])
) {
this._std.command
.chain()
.focusBlockStart({ focusBlock: nextBlock })
.run();
return next();
}
this._std.command
.chain()
.with({ focusBlock: nextBlock })
.selectBlock()
.run();
return next();
}),
])
.run();
return result;
};
private readonly _onArrowUp = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event;
const [result] = this._std.command
.chain()
.inline((_, next) => {
this._reset();
return next();
})
.try(cmd => [
// text selection - select the previous block
// 1. is paragraph, list, code block - follow the default behavior
// 2. is not - select the previous block (use block selection instead of text selection)
cmd
.getTextSelection()
.inline<'currentSelectionPath'>((ctx, next) => {
const currentTextSelection = ctx.currentTextSelection;
if (!currentTextSelection) {
return;
}
return next({ currentSelectionPath: currentTextSelection.blockId });
})
.getPrevBlock()
.inline((ctx, next) => {
const { prevBlock } = ctx;
if (!prevBlock) {
return;
}
if (
!matchFlavours(prevBlock.model, [
'affine:paragraph',
'affine:list',
'affine:code',
])
) {
this._std.command
.chain()
.with({
focusBlock: prevBlock,
})
.selectBlock()
.run();
}
return next({});
}),
// block selection - select the previous block
// 1. is paragraph, list, code block - focus it
// 2. is not - select it using block selection
cmd
.getBlockSelections()
.inline<'currentSelectionPath'>((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
const blockSelection = currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
return next({ currentSelectionPath: blockSelection.blockId });
})
.getPrevBlock()
.inline<'focusBlock'>((ctx, next) => {
const { prevBlock } = ctx;
if (!prevBlock) {
return;
}
if (
matchFlavours(prevBlock.model, [
'affine:paragraph',
'affine:list',
'affine:code',
])
) {
event.preventDefault();
this._std.command
.chain()
.focusBlockEnd({ focusBlock: prevBlock })
.run();
return next();
}
this._std.command
.chain()
.with({ focusBlock: prevBlock })
.selectBlock()
.run();
return next();
}),
])
.run();
return result;
};
private readonly _onBlockShiftDown = (cmd: BlockSuite.CommandChain) => {
return cmd
.getBlockSelections()
.inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => {
const blockSelections = ctx.currentBlockSelections;
if (!blockSelections) {
return;
}
if (!this._anchorSel) {
this._anchorSel = blockSelections.at(-1) ?? null;
}
if (!this._anchorSel) {
return;
}
const anchorBlock = ctx.std.view.getBlock(this._anchorSel.blockId);
if (!anchorBlock) {
return;
}
return next({
anchorBlock,
currentSelectionPath:
this._focusBlock?.blockId ?? anchorBlock?.blockId,
});
})
.getNextBlock({})
.inline<'focusBlock'>((ctx, next) => {
const nextBlock = ctx.nextBlock;
if (!nextBlock) {
return;
}
this._focusBlock = nextBlock;
return next({
focusBlock: this._focusBlock,
});
})
.selectBlocksBetween({ tail: true });
};
private readonly _onBlockShiftUp = (cmd: BlockSuite.CommandChain) => {
return cmd
.getBlockSelections()
.inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => {
const blockSelections = ctx.currentBlockSelections;
if (!blockSelections) {
return;
}
if (!this._anchorSel) {
this._anchorSel = blockSelections.at(0) ?? null;
}
if (!this._anchorSel) {
return;
}
const anchorBlock = ctx.std.view.getBlock(this._anchorSel.blockId);
if (!anchorBlock) {
return;
}
return next({
anchorBlock,
currentSelectionPath:
this._focusBlock?.blockId ?? anchorBlock?.blockId,
});
})
.getPrevBlock({})
.inline((ctx, next) => {
const prevBlock = ctx.prevBlock;
if (!prevBlock) {
return;
}
this._focusBlock = prevBlock;
return next({
focusBlock: this._focusBlock,
});
})
.selectBlocksBetween({ tail: false });
};
private readonly _onEnter = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event;
const [result] = this._std.command
.chain()
.getBlockSelections()
.inline((ctx, next) => {
const blockSelection = ctx.currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
const { view, doc, selection } = ctx.std;
const element = view.getBlock(blockSelection.blockId);
if (!element) {
return;
}
const { model } = element;
const parent = doc.getParent(model);
if (!parent) {
return;
}
const index = parent.children.indexOf(model) ?? undefined;
const blockId = doc.addBlock('affine:paragraph', {}, parent, index + 1);
const sel = selection.create('text', {
from: {
blockId,
index: 0,
length: 0,
},
to: null,
});
event.preventDefault();
selection.setGroup('note', [sel]);
return next();
})
.run();
return result;
};
private readonly _onEsc = () => {
const [result] = this._std.command
.chain()
.getBlockSelections()
.inline((ctx, next) => {
const blockSelection = ctx.currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
ctx.std.selection.update(selList => {
return selList.filter(sel => !sel.is('block'));
});
return next();
})
.run();
return result;
};
private readonly _onSelectAll: UIEventHandler = ctx => {
const selection = this._std.selection;
const block = selection.find('block');
if (!block) {
return;
}
const note = this._getClosestNoteByBlockId(block.blockId);
if (!note) {
return;
}
ctx.get('defaultState').event.preventDefault();
const children = note.children;
const blocks: BlockSelection[] = children.map(child => {
return selection.create('block', {
blockId: child.id,
});
});
selection.update(selList => {
return selList
.filter<BaseSelection>(sel => !sel.is('block'))
.concat(blocks);
});
};
private readonly _onShiftArrowDown = () => {
const [result] = this._std.command
.chain()
.try(cmd => [
// block selection
this._onBlockShiftDown(cmd),
])
.run();
return result;
};
private readonly _onShiftArrowUp = () => {
const [result] = this._std.command
.chain()
.try(cmd => [
// block selection
this._onBlockShiftUp(cmd),
])
.run();
return result;
};
private readonly _reset = () => {
this._anchorSel = null;
this._focusBlock = null;
};
private get _std() {
return this.std;
}
override mounted() {
super.mounted();
this.handleEvent('keyDown', ctx => {
const state = ctx.get('keyboardState');
if (['Control', 'Meta', 'Shift'].includes(state.raw.key)) {
return;
}
this._reset();
});
this.bindHotKey({
...this._bindMoveBlockHotKey(),
...this._bindQuickActionHotKey(),
...this._bindTextConversionHotKey(),
Tab: ctx => {
const { success } = this.std.command.exec('indentBlocks');
if (!success) return;
ctx.get('keyboardState').raw.preventDefault();
return true;
},
'Shift-Tab': ctx => {
const { success } = this.std.command.exec('dedentBlocks');
if (!success) return;
ctx.get('keyboardState').raw.preventDefault();
return true;
},
'Mod-Backspace': ctx => {
const { success } = this.std.command.exec('dedentBlocksToRoot');
if (!success) return;
ctx.get('keyboardState').raw.preventDefault();
return true;
},
ArrowDown: this._onArrowDown,
ArrowUp: this._onArrowUp,
'Shift-ArrowDown': this._onShiftArrowDown,
'Shift-ArrowUp': this._onShiftArrowUp,
Escape: this._onEsc,
Enter: this._onEnter,
'Mod-a': this._onSelectAll,
});
}
}
async function onModelElementUpdated(
std: BlockStdScope,
model: BlockModel,
callback: (block: BlockComponent) => void
) {
const page = model.doc;
if (!page.root) return;
const rootComponent = std.view.getBlock(page.root.id);
if (!rootComponent) return;
await rootComponent.updateComplete;
const element = std.view.getBlock(model.id);
if (element) callback(element);
}

View File

@@ -0,0 +1,30 @@
import {
BlockViewExtension,
CommandExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import {
DocNoteBlockAdapterExtensions,
EdgelessNoteBlockAdapterExtensions,
} from './adapters/index.js';
import { commands } from './commands/index.js';
import { NoteBlockService } from './note-service.js';
export const NoteBlockSpec: ExtensionType[] = [
FlavourExtension('affine:note'),
NoteBlockService,
CommandExtension(commands),
BlockViewExtension('affine:note', literal`affine-note`),
DocNoteBlockAdapterExtensions,
].flat();
export const EdgelessNoteBlockSpec: ExtensionType[] = [
FlavourExtension('affine:note'),
NoteBlockService,
CommandExtension(commands),
BlockViewExtension('affine:note', literal`affine-edgeless-note`),
EdgelessNoteBlockAdapterExtensions,
].flat();

View File

@@ -0,0 +1,62 @@
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import type { BlockStdScope } from '@blocksuite/block-std';
export interface QuickActionConfig {
id: string;
hotkey?: string;
showWhen: (std: BlockStdScope) => boolean;
action: (std: BlockStdScope) => void;
}
export const quickActionConfig: QuickActionConfig[] = [
{
id: 'convert-to-linked-doc',
hotkey: `Mod-Shift-l`,
showWhen: std => {
const [_, ctx] = std.command
.chain()
.getSelectedModels({
types: ['block'],
})
.run();
const { selectedModels } = ctx;
return !!selectedModels && selectedModels.length > 0;
},
action: std => {
const [_, ctx] = std.command
.chain()
.getSelectedModels({
types: ['block'],
mode: 'highest',
})
.draftSelectedModels()
.run();
const { selectedModels, draftedModels } = ctx;
if (!selectedModels) return;
if (!selectedModels.length || !draftedModels) return;
std.selection.clear();
const doc = std.doc;
const autofill = getTitleFromSelectedModels(selectedModels);
promptDocTitle(std, autofill)
.then(title => {
if (title === null) return;
convertSelectedBlocksToLinkedDoc(
std,
doc,
draftedModels,
title
).catch(console.error);
notifyDocCreated(std, doc);
})
.catch(console.error);
},
},
];