mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
refactor(editor): extract note block (#9310)
This commit is contained in:
44
blocksuite/affine/block-note/src/adapters/html.ts
Normal file
44
blocksuite/affine/block-note/src/adapters/html.ts
Normal 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
|
||||
);
|
||||
26
blocksuite/affine/block-note/src/adapters/index.ts
Normal file
26
blocksuite/affine/block-note/src/adapters/index.ts
Normal 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,
|
||||
];
|
||||
41
blocksuite/affine/block-note/src/adapters/markdown.ts
Normal file
41
blocksuite/affine/block-note/src/adapters/markdown.ts
Normal 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);
|
||||
41
blocksuite/affine/block-note/src/adapters/plain-text.ts
Normal file
41
blocksuite/affine/block-note/src/adapters/plain-text.ts
Normal 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);
|
||||
220
blocksuite/affine/block-note/src/commands/block-type.ts
Normal file
220
blocksuite/affine/block-note/src/commands/block-type.ts
Normal 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 });
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
70
blocksuite/affine/block-note/src/commands/dedent-block.ts
Normal file
70
blocksuite/affine/block-note/src/commands/dedent-block.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
88
blocksuite/affine/block-note/src/commands/dedent-blocks.ts
Normal file
88
blocksuite/affine/block-note/src/commands/dedent-blocks.ts
Normal 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();
|
||||
};
|
||||
21
blocksuite/affine/block-note/src/commands/focus-block-end.ts
Normal file
21
blocksuite/affine/block-note/src/commands/focus-block-end.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
78
blocksuite/affine/block-note/src/commands/indent-block.ts
Normal file
78
blocksuite/affine/block-note/src/commands/indent-block.ts
Normal 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();
|
||||
};
|
||||
130
blocksuite/affine/block-note/src/commands/indent-blocks.ts
Normal file
130
blocksuite/affine/block-note/src/commands/indent-blocks.ts
Normal 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();
|
||||
};
|
||||
40
blocksuite/affine/block-note/src/commands/index.ts
Normal file
40
blocksuite/affine/block-note/src/commands/index.ts
Normal 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,
|
||||
};
|
||||
16
blocksuite/affine/block-note/src/commands/select-block.ts
Normal file
16
blocksuite/affine/block-note/src/commands/select-block.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
52
blocksuite/affine/block-note/src/effects.ts
Normal file
52
blocksuite/affine/block-note/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
blocksuite/affine/block-note/src/index.ts
Normal file
6
blocksuite/affine/block-note/src/index.ts
Normal 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';
|
||||
125
blocksuite/affine/block-note/src/move-block.ts
Normal file
125
blocksuite/affine/block-note/src/move-block.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
];
|
||||
39
blocksuite/affine/block-note/src/note-block.ts
Normal file
39
blocksuite/affine/block-note/src/note-block.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
524
blocksuite/affine/block-note/src/note-edgeless-block.ts
Normal file
524
blocksuite/affine/block-note/src/note-edgeless-block.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
602
blocksuite/affine/block-note/src/note-service.ts
Normal file
602
blocksuite/affine/block-note/src/note-service.ts
Normal 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);
|
||||
}
|
||||
30
blocksuite/affine/block-note/src/note-spec.ts
Normal file
30
blocksuite/affine/block-note/src/note-spec.ts
Normal 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();
|
||||
62
blocksuite/affine/block-note/src/quick-action.ts
Normal file
62
blocksuite/affine/block-note/src/quick-action.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user