refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
This commit is contained in:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 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,30 @@
import type { ExtensionType } from '@blocksuite/store';
import {
DocNoteBlockHtmlAdapterExtension,
EdgelessNoteBlockHtmlAdapterExtension,
} from './html';
import {
DocNoteBlockMarkdownAdapterExtension,
EdgelessNoteBlockMarkdownAdapterExtension,
} from './markdown';
import {
DocNoteBlockPlainTextAdapterExtension,
EdgelessNoteBlockPlainTextAdapterExtension,
} from './plain-text';
export * from './html';
export * from './markdown';
export * from './plain-text';
export const DocNoteBlockAdapterExtensions: ExtensionType[] = [
DocNoteBlockMarkdownAdapterExtension,
DocNoteBlockHtmlAdapterExtension,
DocNoteBlockPlainTextAdapterExtension,
];
export const EdgelessNoteBlockAdapterExtensions: ExtensionType[] = [
EdgelessNoteBlockMarkdownAdapterExtension,
EdgelessNoteBlockHtmlAdapterExtension,
EdgelessNoteBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,123 @@
import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
FOOTNOTE_DEFINITION_PREFIX,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import type { FootnoteDefinition, Root } from 'mdast';
const isRootNode = (node: MarkdownAST): node is Root => node.type === 'root';
const isFootnoteDefinitionNode = (
node: MarkdownAST
): node is FootnoteDefinition => node.type === 'footnoteDefinition';
const createFootnoteDefinition = (
identifier: string,
content: string
): MarkdownAST => ({
type: 'footnoteDefinition',
label: identifier,
identifier,
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: content,
},
],
},
],
});
/**
* 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: o => isRootNode(o.node),
fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
if (!isRootNode(o.node)) {
return;
}
const noteAst = o.node;
// Find all the footnoteDefinition in the noteAst
const { configs } = context;
noteAst.children.forEach(child => {
if (isFootnoteDefinitionNode(child)) {
const identifier = child.identifier;
const definitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${identifier}`;
// Get the text content of the footnoteDefinition
const textContent = child.children
.find(child => child.type === 'paragraph')
?.children.find(child => child.type === 'text')?.value;
if (textContent) {
configs.set(definitionKey, textContent);
}
}
});
// Remove the footnoteDefinition node from the noteAst
noteAst.children = noteAst.children.filter(
child => !isFootnoteDefinitionNode(child)
);
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const node = o.node;
if (node.props.displayMode === displayModeToSkip) {
context.walkerContext.skipAllChildren();
}
},
leave: (_, context) => {
const { walkerContext, configs } = context;
// Get all the footnote definitions config starts with FOOTNOTE_DEFINITION_PREFIX
// And create footnoteDefinition AST node for each of them
Array.from(configs.keys())
.filter(key => key.startsWith(FOOTNOTE_DEFINITION_PREFIX))
.forEach(key => {
const hasFootnoteDefinition = !!walkerContext.getGlobalContext(key);
// If the footnoteDefinition node is already in md ast, skip it
// In markdown file, we only need to create footnoteDefinition once
if (hasFootnoteDefinition) {
return;
}
const definition = configs.get(key);
const identifier = key.slice(FOOTNOTE_DEFINITION_PREFIX.length);
if (definition && identifier) {
walkerContext
.openNode(
createFootnoteDefinition(identifier, definition),
'children'
)
.closeNode();
// Set the footnoteDefinition node as global context to avoid duplicate creation
walkerContext.setGlobalContext(key, true);
}
});
},
},
});
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,247 @@
import {
CodeBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
asyncSetInlineRange,
focusTextModel,
onModelTextUpdated,
} from '@blocksuite/affine-rich-text';
import {
getBlockSelectionsCommand,
getSelectedBlocksCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import {
matchModels,
mergeToCodeModel,
transformModel,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
type Command,
TextSelection,
} from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
type UpdateBlockConfig = {
flavour: string;
props?: Record<string, unknown>;
};
export const updateBlockType: Command<
UpdateBlockConfig & {
selectedBlocks?: BlockComponent[];
},
{
updatedBlocks: BlockModel[];
}
> = (ctx, next) => {
const { std, flavour, props } = ctx;
const host = std.host;
const doc = std.store;
const getSelectedBlocks = () => {
let { selectedBlocks } = ctx;
if (selectedBlocks == null) {
const [result, ctx] = std.command
.chain()
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, { 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<{}, { updatedBlocks: BlockModel[] }> = (
_,
next
) => {
if (flavour !== 'affine:code') return;
const id = mergeToCodeModel(blockModels);
if (!id) return;
const model = doc.getModelById(id);
if (!model) return;
asyncSetInlineRange(std, model, {
index: model.text?.length ?? 0,
length: 0,
}).catch(console.error);
return next({ updatedBlocks: [model] });
};
const appendDivider: Command<{}, { updatedBlocks: BlockModel[] }> = (
_,
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.getModelById(id);
if (!newModel) {
return next({ updatedBlocks: [] });
}
return next({ updatedBlocks: [newModel] });
};
const focusText: Command<{ updatedBlocks: BlockModel[] }> = (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(std, model)
);
const selectionManager = host.selection;
const textSelection = selectionManager.find(TextSelection);
if (!textSelection) {
return false;
}
const newTextSelection = selectionManager.create(TextSelection, {
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: BlockModel[] }> = (ctx, next) => {
const { updatedBlocks } = ctx;
if (!updatedBlocks || updatedBlocks.length === 0) {
return false;
}
const selectionManager = host.selection;
const blockSelections = selectionManager.filter(BlockSelection);
if (blockSelections.length === 0) {
return false;
}
requestAnimationFrame(() => {
const selections = updatedBlocks.map(model => {
return selectionManager.create(BlockSelection, {
blockId: model.id,
});
});
selectionManager.setGroup('note', selections);
});
return next();
};
const [result, resultCtx] = std.command
.chain()
.pipe((_, next) => {
doc.captureSync();
return next();
})
// update block type
.try<{ updatedBlocks: BlockModel[] }>(chain => [
chain.pipe(mergeToCode),
chain.pipe(appendDivider),
chain.pipe((_, next) => {
const newModels: BlockModel[] = [];
blockModels.forEach(model => {
if (
!matchModels(model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
) {
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.getModelById(newId);
if (newModel) {
newModels.push(newModel);
}
});
return next({ updatedBlocks: newModels });
}),
])
// focus
.try(chain => [
chain.pipe((_, next) => {
if (['affine:code', 'affine:divider'].includes(flavour)) {
return next();
}
return false;
}),
chain.pipe(focusText),
chain.pipe(focusBlock),
chain.pipe((_, next) => next()),
])
.run();
if (!result) {
return false;
}
return next({ updatedBlocks: resultCtx.updatedBlocks });
};

View File

@@ -0,0 +1,60 @@
import { NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/std';
export const changeNoteDisplayMode: Command<{
noteId: string;
mode: NoteDisplayMode;
stopCapture?: boolean;
}> = (ctx, next) => {
const { std, noteId, mode, stopCapture } = ctx;
const noteBlockModel = std.store.getBlock(noteId)?.model;
if (!noteBlockModel || !matchModels(noteBlockModel, [NoteBlockModel])) return;
const currentMode = noteBlockModel.props.displayMode;
if (currentMode === mode) return;
if (stopCapture) std.store.captureSync();
// Update the order in the note list in its parent, for example
// 1. Both mode note | 1. Both mode note
// 2. Page mode note | 2. Page mode note
// ---------------------------- |-> 3. the changed note (Both or Page)
// 3. Edgeless mode note | ---------------------------
// 4. the changing edgeless note -| 4. Edgeless mode note
const parent = std.store.getParent(noteBlockModel);
if (parent) {
const notes = parent.children.filter(child =>
matchModels(child, [NoteBlockModel])
);
const firstEdgelessOnlyNote = notes.find(
note => note.props.displayMode === NoteDisplayMode.EdgelessOnly
);
const lastPageVisibleNote = notes.findLast(
note => note.props.displayMode !== NoteDisplayMode.EdgelessOnly
);
if (currentMode === NoteDisplayMode.EdgelessOnly) {
std.store.moveBlocks(
[noteBlockModel],
parent,
lastPageVisibleNote ?? firstEdgelessOnlyNote,
lastPageVisibleNote ? false : true
);
} else if (mode === NoteDisplayMode.EdgelessOnly) {
std.store.moveBlocks(
[noteBlockModel],
parent,
firstEdgelessOnlyNote ?? lastPageVisibleNote,
firstEdgelessOnlyNote ? true : false
);
}
}
std.store.updateBlock(noteBlockModel, {
displayMode: mode,
});
return next();
};

View File

@@ -0,0 +1,38 @@
import { NoteBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/std';
import { dedentBlock } from './dedent-block';
export const dedentBlockToRoot: Command<{
blockId?: string;
stopCapture?: boolean;
}> = (ctx, next) => {
let { blockId } = ctx;
const { std, stopCapture = true } = ctx;
const { store } = std;
if (!blockId) {
const sel = std.selection.getGroup('note').at(0);
blockId = sel?.blockId;
}
if (!blockId) return;
const model = std.store.getBlock(blockId)?.model;
if (!model) return;
let parent = store.getParent(model);
let changed = false;
while (parent && !matchModels(parent, [NoteBlockModel])) {
if (!changed) {
if (stopCapture) store.captureSync();
changed = true;
}
std.command.exec(dedentBlock, { blockId: model.id, stopCapture: true });
parent = store.getParent(model);
}
if (!changed) {
return;
}
return next();
};

View File

@@ -0,0 +1,67 @@
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import {
calculateCollapsedSiblings,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/std';
/**
* @example
* before unindent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*
* after unindent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*/
export const dedentBlock: Command<{
blockId?: string;
stopCapture?: boolean;
}> = (ctx, next) => {
let { blockId } = ctx;
const { std, stopCapture = true } = ctx;
const { store } = std;
if (!blockId) {
const sel = std.selection.getGroup('note').at(0);
blockId = sel?.blockId;
}
if (!blockId) return;
const model = std.store.getBlock(blockId)?.model;
if (!model) return;
const parent = store.getParent(model);
const grandParent = parent && store.getParent(parent);
if (store.readonly || !parent || parent.role !== 'content' || !grandParent) {
// Top most, can not unindent, do nothing
return;
}
if (stopCapture) store.captureSync();
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type.startsWith('h') &&
model.props.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
store.moveBlocks([model, ...collapsedSiblings], grandParent, parent, false);
return next();
}
try {
const nextSiblings = store.getNexts(model);
store.moveBlocks(nextSiblings, model);
store.moveBlocks([model], grandParent, parent, false);
} catch {
return;
}
return next();
};

View File

@@ -0,0 +1,43 @@
import { NoteBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { type Command, TextSelection } from '@blocksuite/std';
import { dedentBlockToRoot } from './dedent-block-to-root';
export const dedentBlocksToRoot: Command<{
blockIds?: string[];
stopCapture?: boolean;
}> = (ctx, next) => {
let { blockIds } = ctx;
const { std, stopCapture = true } = ctx;
const { store } = std;
if (!blockIds || !blockIds.length) {
const text = std.selection.find(TextSelection);
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 || store.readonly) return;
if (stopCapture) store.captureSync();
for (let i = blockIds.length - 1; i >= 0; i--) {
const model = blockIds[i];
const parent = store.getParent(model);
if (parent && !matchModels(parent, [NoteBlockModel])) {
std.command.exec(dedentBlockToRoot, {
blockId: model,
stopCapture: false,
});
}
}
return next();
};

View File

@@ -0,0 +1,87 @@
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import {
calculateCollapsedSiblings,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type Command, TextSelection } from '@blocksuite/std';
import { dedentBlock } from './dedent-block';
export const dedentBlocks: Command<{
blockIds?: string[];
stopCapture?: boolean;
}> = (ctx, next) => {
let { blockIds } = ctx;
const { std, stopCapture = true } = ctx;
const { store, selection, range, host } = std;
const { schema } = store;
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 || store.readonly) return;
// Find the first model that can be unindented
let firstDedentIndex = -1;
for (let i = 0; i < blockIds.length; i++) {
const model = store.getBlock(blockIds[i])?.model;
if (!model) continue;
const parent = store.getParent(blockIds[i]);
if (!parent) continue;
const grandParent = store.getParent(parent);
if (!grandParent) continue;
if (schema.isValid(model.flavour, grandParent.flavour)) {
firstDedentIndex = i;
break;
}
}
if (firstDedentIndex === -1) return;
if (stopCapture) store.captureSync();
const collapsedIds: string[] = [];
blockIds.slice(firstDedentIndex).forEach(id => {
const model = store.getBlock(id)?.model;
if (!model) return;
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type.startsWith('h') &&
model.props.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(TextSelection);
if (textSelection) {
host.updateComplete
.then(() => {
range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
}
return next();
};

View File

@@ -0,0 +1,74 @@
import { ListBlockModel, ParagraphBlockModel } from '@blocksuite/affine-model';
import {
calculateCollapsedSiblings,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { Command } from '@blocksuite/std';
/**
* @example
* before indent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*
* after indent:
* - aaa
* - bbb
* - ccc|
* - ddd
* - eee
*/
export const indentBlock: Command<{
blockId?: string;
stopCapture?: boolean;
}> = (ctx, next) => {
let { blockId } = ctx;
const { std, stopCapture = true } = ctx;
const { store } = std;
const { schema } = store;
if (!blockId) {
const sel = std.selection.getGroup('note').at(0);
blockId = sel?.blockId;
}
if (!blockId) return;
const model = std.store.getBlock(blockId)?.model;
if (!model) return;
const previousSibling = store.getPrev(model);
if (
store.readonly ||
!previousSibling ||
!schema.isValid(model.flavour, previousSibling.flavour)
) {
// can not indent, do nothing
return;
}
if (stopCapture) store.captureSync();
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type.startsWith('h') &&
model.props.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
store.moveBlocks([model, ...collapsedSiblings], previousSibling);
} else {
store.moveBlocks([model], previousSibling);
}
// update collapsed state of affine list
if (
matchModels(previousSibling, [ListBlockModel]) &&
previousSibling.props.collapsed
) {
store.updateBlock(previousSibling, {
collapsed: false,
});
}
return next();
};

View File

@@ -0,0 +1,125 @@
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import {
calculateCollapsedSiblings,
getNearestHeadingBefore,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type Command, TextSelection } from '@blocksuite/std';
import { indentBlock } from './indent-block';
export const indentBlocks: Command<{
blockIds?: string[];
stopCapture?: boolean;
}> = (ctx, next) => {
let { blockIds } = ctx;
const { std, stopCapture = true } = ctx;
const { store, selection, range, host } = std;
const { schema } = store;
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 || store.readonly) return;
// Find the first model that can be indented
let firstIndentIndex = -1;
for (let i = 0; i < blockIds.length; i++) {
const previousSibling = store.getPrev(blockIds[i]);
const model = store.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) store.captureSync();
const collapsedIds: string[] = [];
blockIds.slice(firstIndentIndex).forEach(id => {
const model = store.getBlock(id)?.model;
if (!model) return;
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type.startsWith('h') &&
model.props.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 = store.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 &&
matchModels(nearestHeading, [ParagraphBlockModel]) &&
nearestHeading.props.collapsed
) {
store.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 &&
matchModels(nearestHeading, [ParagraphBlockModel]) &&
nearestHeading.props.collapsed
) {
store.updateBlock(nearestHeading, { collapsed: false });
}
}
const textSelection = selection.find(TextSelection);
if (textSelection) {
host.updateComplete
.then(() => {
range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
}
return next();
};

View File

@@ -0,0 +1,10 @@
export { updateBlockType } from './block-type.js';
export { changeNoteDisplayMode } from './change-note-display-mode.js';
export { dedentBlock } from './dedent-block.js';
export { dedentBlockToRoot } from './dedent-block-to-root.js';
export { dedentBlocks } from './dedent-blocks.js';
export { dedentBlocksToRoot } from './dedent-blocks-to-root.js';
export { indentBlock } from './indent-block.js';
export { indentBlocks } from './indent-blocks.js';
export { selectBlock } from './select-block.js';
export { selectBlocksBetween } from './select-blocks-between.js';

View File

@@ -0,0 +1,22 @@
import {
type BlockComponent,
BlockSelection,
type Command,
} from '@blocksuite/std';
export const selectBlock: Command<{
focusBlock?: BlockComponent;
}> = (ctx, next) => {
const { focusBlock, std } = ctx;
if (!focusBlock) {
return;
}
const { selection } = std;
selection.setGroup('note', [
selection.create(BlockSelection, { blockId: focusBlock.blockId }),
]);
return next();
};

View File

@@ -0,0 +1,53 @@
import {
type BlockComponent,
BlockSelection,
type Command,
} from '@blocksuite/std';
export const selectBlocksBetween: Command<{
focusBlock?: BlockComponent;
anchorBlock?: BlockComponent;
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(BlockSelection, { blockId })]);
return next();
}
// In different blocks
const selections = [...selection.value];
if (selections.every(sel => sel.blockId !== focusBlock.blockId)) {
if (tail) {
selections.push(
selection.create(BlockSelection, { blockId: focusBlock.blockId })
);
} else {
selections.unshift(
selection.create(BlockSelection, { 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,27 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
import {
ACTIVE_NOTE_EXTRA_PADDING,
edgelessNoteContainer,
} from '../note-edgeless-block.css';
export const background = style({
position: 'absolute',
borderColor: cssVar('black10'),
left: 0,
top: 0,
width: '100%',
height: '100%',
selectors: {
[`${edgelessNoteContainer}[data-editing="true"] &`]: {
left: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
top: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
width: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
height: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
transition: 'left 0.3s, top 0.3s, width 0.3s, height 0.3s',
boxShadow: cssVar('activeShadow'),
},
},
});

View File

@@ -0,0 +1,178 @@
import {
DefaultTheme,
ListBlockModel,
NoteBlockModel,
ParagraphBlockModel,
StrokeStyle,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
getClosestBlockComponentByPoint,
handleNativeRangeAtPoint,
matchModels,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { clamp, Point } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type BlockComponent,
type BlockStdScope,
PropTypes,
requiredProperties,
ShadowlessElement,
stdContext,
TextSelection,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { consume } from '@lit/context';
import { computed } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { NoteConfigExtension } from '../config';
import * as styles from './edgeless-note-background.css';
@requiredProperties({
note: PropTypes.instanceOf(NoteBlockModel),
})
export class EdgelessNoteBackground extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
readonly backgroundStyle$ = computed(() => {
const themeProvider = this.std.get(ThemeProvider);
const theme = themeProvider.theme$.value;
const backgroundColor = themeProvider.generateColorProperty(
this.note.props.background$.value,
DefaultTheme.noteBackgrounColor,
theme
);
const { borderRadius, borderSize, borderStyle, shadowType } =
this.note.props.edgeless$.value.style;
return {
borderRadius: borderRadius + 'px',
backgroundColor: backgroundColor,
borderWidth: `${borderSize}px`,
borderStyle: borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle,
boxShadow: !shadowType ? 'none' : `var(${shadowType})`,
};
});
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get doc() {
return this.std.host.doc;
}
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.note.children.indexOf(nearestModel);
const children = this.note.children;
const siblingModel =
children[
clamp(
nearestModelIdx + (insertPos === 'before' ? -1 : 1),
0,
children.length
)
];
if (
(!nearestModel.text ||
!matchModels(nearestModel, [ParagraphBlockModel, ListBlockModel])) &&
(!siblingModel ||
!siblingModel.text ||
!matchModels(siblingModel, [ParagraphBlockModel, ListBlockModel]))
) {
const [pId] = this.doc.addSiblingBlocks(
nearestModel,
[{ flavour: 'affine:paragraph' }],
insertPos
);
this.updateComplete
.then(() => {
this.std.selection.setGroup('note', [
this.std.selection.create(TextSelection, {
from: {
blockId: pId,
index: 0,
length: 0,
},
to: null,
}),
]);
})
.catch(console.error);
}
}
private _handleClickAtBackground(e: MouseEvent) {
e.stopPropagation();
if (!this.editing) return;
const { zoom } = this.gfx.viewport;
const rect = this.getBoundingClientRect();
const offsetY = 16 * zoom;
const offsetX = 2 * 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.std.host.doc.readonly) return;
this._tryAddParagraph(x, y);
}
private _renderHeader() {
const header = this.std
.getOptional(NoteConfigExtension.identifier)
?.edgelessNoteHeader({ note: this.note, std: this.std });
return header;
}
override render() {
return html`<div
class=${styles.background}
style=${styleMap(this.backgroundStyle$.value)}
@pointerdown=${stopPropagation}
@click=${this._handleClickAtBackground}
>
${this.note.isPageBlock() ? this._renderHeader() : nothing}
</div>`;
}
@consume({ context: stdContext })
accessor std!: BlockStdScope;
@property({ attribute: false })
accessor editing: boolean = false;
@property({ attribute: false })
accessor note!: NoteBlockModel;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-background': EdgelessNoteBackground;
}
}

View File

@@ -0,0 +1,42 @@
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { LineWidth, type StrokeStyle } from '@blocksuite/affine-model';
import { LineStyleIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
export class EdgelessNoteBorderDropdownMenu extends ShadowlessElement {
override render() {
const { lineSize, lineStyle } = this;
return html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Border style"
.tooltip="${'Border style'}"
>
${LineStyleIcon()} ${EditorChevronDown}
</editor-icon-button>
`}
>
<edgeless-line-styles-panel
.lineSize=${lineSize}
.lineStyle=${lineStyle}
></edgeless-line-styles-panel>
</editor-menu-button>
`;
}
@property({ attribute: false })
accessor lineStyle!: StrokeStyle;
@property({ attribute: false })
accessor lineSize: LineWidth = LineWidth.Two;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-border-dropdown-menu': EdgelessNoteBorderDropdownMenu;
}
}

View File

@@ -0,0 +1,58 @@
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { ShadowlessElement } from '@blocksuite/std';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
const DisplayModeMap = {
[NoteDisplayMode.DocAndEdgeless]: 'Both',
[NoteDisplayMode.EdgelessOnly]: 'Edgeless',
[NoteDisplayMode.DocOnly]: 'Page',
} as const satisfies Record<NoteDisplayMode, string>;
export class EdgelessNoteDisplayModeDropdownMenu extends ShadowlessElement {
get mode() {
return DisplayModeMap[this.displayMode];
}
select(detail: NoteDisplayMode) {
this.dispatchEvent(new CustomEvent('select', { detail }));
}
override render() {
const { displayMode, mode } = this;
return html`
<span class="display-mode-button-label">Show in</span>
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Mode"
.tooltip="${'Display mode'}"
.justify="${'space-between'}"
.labelHeight="${'20px'}"
>
<span class="label">${mode}</span>
${EditorChevronDown}
</editor-icon-button>
`}
>
<note-display-mode-panel
.displayMode=${displayMode}
.onSelect=${(newMode: NoteDisplayMode) => this.select(newMode)}
>
</note-display-mode-panel>
</editor-menu-button>
`;
}
@property({ attribute: false })
accessor displayMode!: NoteDisplayMode;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-display-mode-dropdown-menu': EdgelessNoteDisplayModeDropdownMenu;
}
}

View File

@@ -0,0 +1,83 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import { almostEqual, Bound } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { type EditorHost, ShadowlessElement } from '@blocksuite/std';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ACTIVE_NOTE_EXTRA_PADDING } from '../note-edgeless-block.css';
export class EdgelessNoteMask extends SignalWatcher(
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.props.edgeless.collapse) {
const bound = Bound.deserialize(this.model.xywh);
const scale = this.model.props.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.editing || this.disableMask ? 'none' : 'auto',
borderRadius: `${
this.model.props.edgeless.style.borderRadius * this.zoom
}px`,
})}
></div>
`;
}
@property({ attribute: false })
accessor disableMask!: 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;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-mask': EdgelessNoteMask;
}
}

View File

@@ -0,0 +1,175 @@
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { ColorScheme, NoteShadow } from '@blocksuite/affine-model';
import { NoteShadowDuotoneIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { NoteNoShadowIcon, NoteShadowSampleIcon } from './icons';
const SHADOWS = [
{
type: NoteShadow.None,
styles: {
light: '',
dark: '',
},
tooltip: 'No shadow',
},
{
type: NoteShadow.Box,
styles: {
light:
'0px 0.2px 4.8px 0px rgba(66, 65, 73, 0.2), 0px 0px 1.6px 0px rgba(66, 65, 73, 0.2)',
dark: '0px 0.2px 6px 0px rgba(0, 0, 0, 0.44), 0px 0px 2px 0px rgba(0, 0, 0, 0.66)',
},
tooltip: 'Box shadow',
},
{
type: NoteShadow.Sticker,
styles: {
light:
'0px 9.6px 10.4px -4px rgba(66, 65, 73, 0.07), 0px 10.4px 7.2px -8px rgba(66, 65, 73, 0.22)',
dark: '0px 9.6px 10.4px -4px rgba(0, 0, 0, 0.66), 0px 10.4px 7.2px -8px rgba(0, 0, 0, 0.44)',
},
tooltip: 'Sticker shadow',
},
{
type: NoteShadow.Paper,
styles: {
light:
'0px 0px 0px 4px rgba(255, 255, 255, 1), 0px 1.2px 2.4px 4.8px rgba(66, 65, 73, 0.16)',
dark: '0px 1.2px 2.4px 4.8px rgba(0, 0, 0, 0.36), 0px 0px 0px 3.4px rgba(75, 75, 75, 1)',
},
tooltip: 'Paper shadow',
},
{
type: NoteShadow.Float,
styles: {
light:
'0px 5.2px 12px 0px rgba(66, 65, 73, 0.13), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.06)',
dark: '0px 5.2px 12px 0px rgba(0, 0, 0, 0.66), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.44)',
},
tooltip: 'Floation shadow',
},
{
type: NoteShadow.Film,
styles: {
light:
'0px 0px 0px 1.4px rgba(0, 0, 0, 1), 2.4px 2.4px 0px 1px rgba(0, 0, 0, 1)',
dark: '0px 0px 0px 1.4px rgba(178, 178, 178, 1), 2.4px 2.4px 0px 1px rgba(178, 178, 178, 1)',
},
tooltip: 'Film shadow',
},
];
export class EdgelessNoteShadowDropdownMenu extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.item {
padding: 8px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.item-icon {
display: flex;
justify-content: center;
align-items: center;
}
.item-icon svg rect:first-of-type {
fill: var(--background);
}
.item:hover {
background-color: var(--affine-hover-color);
}
.item[data-selected] {
border: 1px solid var(--affine-brand-color);
}
`;
select(value: NoteShadow) {
this.dispatchEvent(new CustomEvent('select', { detail: value }));
}
override render() {
const { value, background, theme } = this;
const isDark = theme === ColorScheme.Dark;
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Shadow style"
.tooltip="${'Shadow style'}"
>
${NoteShadowDuotoneIcon()} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div
data-orientation="horizontal"
style=${styleMap({
'--background': background.startsWith('--')
? `var(${background})`
: background,
})}
>
${repeat(
SHADOWS,
shadow => shadow.type,
({ type, tooltip, styles: { dark, light } }, index) =>
html`<div
class="item"
?data-selected="${value === type}"
@click=${() => this.select(type)}
>
<editor-icon-button
class="item-icon"
data-testid="${type.replace('--', '')}"
.tooltip=${tooltip}
.tipPosition="${'bottom'}"
.iconContainerPadding=${0}
.hover=${false}
style=${styleMap({
boxShadow: `${isDark ? dark : light}`,
})}
>
${index === 0 ? NoteNoShadowIcon : NoteShadowSampleIcon}
</editor-icon-button>
</div>`
)}
</div>
</editor-menu-button>
`;
}
@property({ attribute: false })
accessor background!: string;
@property({ attribute: false })
accessor theme!: ColorScheme;
@property({ attribute: false })
accessor value!: NoteShadow;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-shadow-dropdown-menu': EdgelessNoteShadowDropdownMenu;
}
}

View File

@@ -0,0 +1,11 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const pageBlockTitle = style({
position: 'relative',
});
globalStyle(`${pageBlockTitle} .doc-title-container`, {
padding: '26px 0px',
marginLeft: 'unset',
marginRight: 'unset',
});

View File

@@ -0,0 +1,46 @@
import { NoteBlockModel } from '@blocksuite/affine-model';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type BlockStdScope,
PropTypes,
requiredProperties,
ShadowlessElement,
stdContext,
} from '@blocksuite/std';
import { consume } from '@lit/context';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { NoteConfigExtension } from '../config';
import * as styles from './edgeless-page-block-title.css';
@requiredProperties({
note: PropTypes.instanceOf(NoteBlockModel),
})
export class EdgelessPageBlockTitle extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
override render() {
if (!this.note.isPageBlock()) return;
const title = this.std
.getOptional(NoteConfigExtension.identifier)
?.pageBlockTitle({
note: this.note,
std: this.std,
});
return html`<div class=${styles.pageBlockTitle}>${title}</div>`;
}
@consume({ context: stdContext })
accessor std!: BlockStdScope;
@property({ attribute: false })
accessor note!: NoteBlockModel;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-page-block-title': EdgelessPageBlockTitle;
}
}

View File

@@ -0,0 +1,84 @@
import { html } from 'lit';
export const NoteNoShadowIcon = html`
<svg
width="60"
height="72"
viewBox="0 0 60 72"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="60" height="72" />
<rect
x="0.5"
y="0.5"
width="58.0769"
height="71"
stroke="black"
stroke-opacity="0.1"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21.9576 26.8962L38.6423 43.5809C42.5269 38.9268 42.2845 31.993 37.9149 27.6235C33.5454 23.254 26.6117 23.0115 21.9576 26.8962ZM37.1193 45.1038L20.4346 28.4192C16.55 33.0732 16.7924 40.007 21.162 44.3765C25.5315 48.746 32.4652 48.9885 37.1193 45.1038ZM19.639 26.1005C25.1063 20.6332 33.9706 20.6332 39.4379 26.1005C44.9053 31.5678 44.9053 40.4322 39.4379 45.8995C33.9706 51.3668 25.1063 51.3668 19.639 45.8995C14.1716 40.4322 14.1716 31.5678 19.639 26.1005Z"
fill="black"
fill-opacity="0.1"
/>
</svg>
`;
export const NoteShadowSampleIcon = html`
<svg
width="60"
height="72"
viewBox="0 0 60 72"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="60" height="72" />
<rect
x="9.23071"
y="12.0771"
width="32.3077"
height="4.61538"
rx="2"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="25.8462"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="35.6152"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="45.3843"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="55.1533"
width="13.8462"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
</svg>
`;

View File

@@ -0,0 +1,17 @@
import { html } from 'lit';
export const MoreIndicator = html`<svg
width="34"
height="29"
viewBox="0 0 34 29"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 14.4292L16.8345 18.9019C17.0345 18.9665 17.2498 18.9665 17.4498 18.9019L31.2843 14.4292"
stroke="black"
stroke-opacity="0.3"
stroke-width="5"
stroke-linecap="round"
/>
</svg>`;

View File

@@ -0,0 +1,24 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const viewInPageNotifyFooter = style({
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
});
export const viewInPageNotifyFooterButton = style({
padding: '0px 6px',
borderRadius: '4px',
color: cssVarV2('text/primary'),
fontSize: cssVar('fontSm'),
lineHeight: '22px',
fontWeight: '500',
textAlign: 'center',
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},
});

View File

@@ -0,0 +1,20 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import { type BlockStdScope, ConfigExtensionFactory } from '@blocksuite/std';
import type { TemplateResult } from 'lit';
type NoteBlockContext = {
note: NoteBlockModel;
std: BlockStdScope;
};
export type NoteConfig = {
edgelessNoteHeader: (context: NoteBlockContext) => TemplateResult;
pageBlockTitle: (context: NoteBlockContext) => TemplateResult;
/**
* @returns if the viewport fit animation executed
*/
pageBlockViewportFitAnimation?: (context: NoteBlockContext) => boolean;
};
export const NoteConfigExtension =
ConfigExtensionFactory<NoteConfig>('affine:note');

View File

@@ -0,0 +1,121 @@
import {
formatBlockCommand,
type TextFormatConfig,
textFormatConfigs,
} from '@blocksuite/affine-inline-preset';
import {
type TextConversionConfig,
textConversionConfigs,
} from '@blocksuite/affine-rich-text';
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
import {
type SlashMenuActionItem,
type SlashMenuConfig,
SlashMenuConfigExtension,
type SlashMenuItem,
} from '@blocksuite/affine-widget-slash-menu';
import { HeadingsIcon } from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
import { updateBlockType } from '../commands';
import { tooltips } from './tooltips';
let basicIndex = 0;
const noteSlashMenuConfig: SlashMenuConfig = {
items: [
...textConversionConfigs
.filter(i => i.type && ['h1', 'h2', 'h3', 'text'].includes(i.type))
.map(config => createConversionItem(config, `0_Basic@${basicIndex++}`)),
{
name: 'Other Headings',
icon: HeadingsIcon(),
group: `0_Basic@${basicIndex++}`,
subMenu: textConversionConfigs
.filter(i => i.type && ['h4', 'h5', 'h6'].includes(i.type))
.map(config => createConversionItem(config)),
},
...textConversionConfigs
.filter(i => i.flavour === 'affine:code')
.map(config => createConversionItem(config, `0_Basic@${basicIndex++}`)),
...textConversionConfigs
.filter(i => i.type && ['divider', 'quote'].includes(i.type))
.map(
config =>
({
...createConversionItem(config, `0_Basic@${basicIndex++}`),
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has(config.flavour) &&
!isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'),
}) satisfies SlashMenuActionItem
),
...textConversionConfigs
.filter(i => i.flavour === 'affine:list')
.map((config, index) =>
createConversionItem(config, `1_List@${index++}`)
),
...textFormatConfigs
.filter(i => !['Code', 'Link'].includes(i.name))
.map((config, index) =>
createTextFormatItem(config, `2_Style@${index++}`)
),
],
};
function createConversionItem(
config: TextConversionConfig,
group?: SlashMenuItem['group']
): SlashMenuActionItem {
const { name, description, icon, flavour, type } = config;
return {
name,
group,
description,
icon,
tooltip: tooltips[name],
when: ({ model }) => model.doc.schema.flavourSchemaMap.has(flavour),
action: ({ std }) => {
std.command.exec(updateBlockType, {
flavour,
props: { type },
});
},
};
}
function createTextFormatItem(
config: TextFormatConfig,
group?: SlashMenuItem['group']
): SlashMenuActionItem {
const { name, icon, id, action } = config;
return {
name,
icon,
group,
tooltip: tooltips[name],
action: ({ std, model }) => {
const { host } = std;
if (model.text?.length !== 0) {
std.command.exec(formatBlockCommand, {
blockSelections: [
std.selection.create(BlockSelection, {
blockId: model.id,
}),
],
styles: { [id]: true },
});
} else {
// like format bar when the line is empty
action(host);
}
},
};
}
export const NoteSlashMenuConfigExtension = SlashMenuConfigExtension(
'affine:note',
noteSlashMenuConfig
);

View File

@@ -0,0 +1,593 @@
import {
EdgelessCRUDIdentifier,
EdgelessLegacySlotIdentifier,
} from '@blocksuite/affine-block-surface';
import {
packColor,
type PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel';
import {
DefaultTheme,
NoteBlockModel,
NoteDisplayMode,
type NoteShadow,
resolveColor,
} from '@blocksuite/affine-model';
import {
NotificationProvider,
SidebarExtensionIdentifier,
type ToolbarAction,
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { getMostCommonResolvedValue } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import {
AutoHeightIcon,
CornerIcon,
CustomizedHeightIcon,
LinkedPageIcon,
ScissorsIcon,
} from '@blocksuite/icons/lit';
import {
BlockFlavourIdentifier,
EditorLifeCycleExtension,
} from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { computed } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import { changeNoteDisplayMode } from '../commands';
import * as styles from '../components/view-in-page-notify.css';
import { NoteConfigExtension } from '../config';
const trackBaseProps = {
category: 'note',
};
const CORNER_LIST = [
{ key: 'None', value: 0 },
{ key: 'Small', value: 8 },
{ key: 'Medium', value: 16 },
{ key: 'Large', value: 24 },
{ key: 'Huge', value: 32 },
] as const;
const builtinSurfaceToolbarConfig = {
actions: [
{
id: 'a.show-in',
when(ctx) {
return (
ctx.getSurfaceModelsByType(NoteBlockModel).length === 1 &&
ctx.features.getFlag('enable_advanced_block_visibility')
);
},
content(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const firstModel = models[0];
const { displayMode } = firstModel.props;
const onSelect = (e: CustomEvent<NoteDisplayMode>) => {
e.stopPropagation();
const newMode = e.detail;
setDisplayMode(ctx, firstModel, newMode);
};
return html`
<edgeless-note-display-mode-dropdown-menu
@select=${onSelect}
.displayMode="${displayMode}"
>
</edgeless-note-display-mode-dropdown-menu>
`;
},
},
{
id: 'b.display-in-page',
when(ctx) {
const elements = ctx.getSurfaceModelsByType(NoteBlockModel);
return (
elements.length === 1 &&
!elements[0].isPageBlock() &&
!ctx.features.getFlag('enable_advanced_block_visibility')
);
},
generate(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const firstModel = models[0];
const shouldShowTooltip$ = computed(
() =>
firstModel.props.displayMode$.value ===
NoteDisplayMode.DocAndEdgeless
);
const label$ = computed(() =>
firstModel.props.displayMode$.value === NoteDisplayMode.EdgelessOnly
? 'Display in Page'
: 'Displayed in Page'
);
const onSelect = () => {
const newMode =
firstModel.props.displayMode === NoteDisplayMode.EdgelessOnly
? NoteDisplayMode.DocAndEdgeless
: NoteDisplayMode.EdgelessOnly;
setDisplayMode(ctx, firstModel, newMode);
};
return {
content: html`<editor-icon-button
aria-label="${label$.value}"
.showTooltip="${shouldShowTooltip$.value}"
.tooltip="${'This note is part of Page Mode. Click to remove it from the page.'}"
data-testid="display-in-page"
@click=${() => onSelect()}
>
${LinkedPageIcon()}
<span class="label">${label$.value}</span>
</editor-icon-button>`,
};
},
},
{
id: 'c.color-picker',
when(ctx) {
const elements = ctx.getSurfaceModelsByType(NoteBlockModel);
return (
elements.length > 0 &&
elements[0].props.displayMode !== NoteDisplayMode.DocOnly
);
},
content(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
const theme = ctx.theme.edgeless$.value;
const firstModel = models[0];
const background =
getMostCommonResolvedValue(
models.map(model => model.props),
'background',
background => resolveColor(background, theme)
) ?? resolveColor(DefaultTheme.noteBackgrounColor, theme);
const onPick = (e: PickColorEvent) => {
const field = 'background';
if (e.type === 'pick') {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
return;
}
for (const model of models) {
model[e.type === 'start' ? 'stash' : 'pop'](field);
}
};
return html`
<edgeless-color-picker-button
class="background"
.label="${'Background'}"
.pick=${onPick}
.color=${background}
.colorPanelClass="${'small'}"
.theme=${theme}
.palettes=${DefaultTheme.NoteBackgroundColorPalettes}
.originalColor=${firstModel.props.background}
.enableCustomColor=${enableCustomColor}
>
</edgeless-color-picker-button>
`;
},
},
{
id: 'd.style',
when(ctx) {
const elements = ctx.getSurfaceModelsByType(NoteBlockModel);
return (
elements.length > 0 &&
elements[0].props.displayMode !== NoteDisplayMode.DocOnly
);
},
actions: [
{
id: 'a.shadow-style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const theme = ctx.theme.edgeless$.value;
const firstModel = models[0];
const { shadowType } = firstModel.props.edgeless.style;
const background =
getMostCommonResolvedValue(
models.map(model => model.props),
'background',
background => resolveColor(background, theme)
) ?? resolveColor(DefaultTheme.noteBackgrounColor, theme);
const onSelect = (e: CustomEvent<NoteShadow>) => {
e.stopPropagation();
const shadowType = e.detail;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
...edgeless.style,
shadowType,
},
},
});
}
};
return html`${keyed(
firstModel,
html`<edgeless-note-shadow-dropdown-menu
@select=${onSelect}
.value="${shadowType}"
.background="${background}"
.theme="${theme}"
></edgeless-note-shadow-dropdown-menu>`
)}`;
},
} satisfies ToolbarAction,
{
id: 'b.border-style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const firstModel = models[0];
const { borderSize, borderStyle } = firstModel.props.edgeless.style;
const onSelect = (e: CustomEvent<LineDetailType>) => {
e.stopPropagation();
const { type, value } = e.detail;
if (type === 'size') {
const borderSize = value;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
...edgeless.style,
borderSize,
},
},
});
}
return;
}
const borderStyle = value;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
...edgeless.style,
borderStyle,
},
},
});
}
};
return html`${keyed(
firstModel,
html`
<edgeless-note-border-dropdown-menu
@select=${onSelect}
.lineSize=${borderSize}
.lineStyle=${borderStyle}
></edgeless-note-border-dropdown-menu>
`
)}`;
},
} satisfies ToolbarAction,
{
id: 'c.corners',
label: 'Corners',
content(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const label = this.label;
const firstModel = models[0];
const borderRadius$ = computed(
() => firstModel.props.edgeless$.value.style.borderRadius
);
const onSelect = (e: CustomEvent<number>) => {
e.stopPropagation();
const borderRadius = e.detail;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
...edgeless.style,
borderRadius,
},
},
});
}
};
return html`${keyed(
firstModel,
html`<affine-size-dropdown-menu
@select=${onSelect}
.label="${label}"
.icon=${CornerIcon()}
.sizes=${CORNER_LIST}
.size$=${borderRadius$}
></affine-size-dropdown-menu>`
)}`;
},
} satisfies ToolbarAction,
],
},
{
id: 'e.slicer',
label: 'Slicer',
icon: ScissorsIcon(),
tooltip: html`<affine-tooltip-content-with-shortcut
data-tip="${'Cutting mode'}"
data-shortcut="${'-'}"
></affine-tooltip-content-with-shortcut>`,
active: false,
when(ctx) {
return (
ctx.getSurfaceModelsByType(NoteBlockModel).length === 1 &&
ctx.features.getFlag('enable_advanced_block_visibility')
);
},
run(ctx) {
ctx.std.get(EdgelessLegacySlotIdentifier).toggleNoteSlicer.next();
},
},
{
id: 'f.auto-height',
label: 'Size',
when(ctx) {
const elements = ctx.getSurfaceModelsByType(NoteBlockModel);
return (
elements.length > 0 &&
(!elements[0].isPageBlock() ||
!ctx.std.getOptional(NoteConfigExtension.identifier)
?.edgelessNoteHeader)
);
},
generate(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const firstModel = models[0];
const { collapse } = firstModel.props.edgeless$.value;
const options: Pick<ToolbarAction, 'tooltip' | 'icon'> = collapse
? {
tooltip: 'Auto height',
icon: AutoHeightIcon(),
}
: {
tooltip: 'Customized height',
icon: CustomizedHeightIcon(),
};
return {
...options,
run(ctx) {
ctx.store.captureSync();
for (const model of models) {
const edgeless = model.props.edgeless;
if (edgeless.collapse) {
ctx.store.updateBlock(model, () => {
model.props.edgeless.collapse = false;
});
continue;
}
if (edgeless.collapsedHeight) {
const bounds = Bound.deserialize(model.xywh);
bounds.h = edgeless.collapsedHeight * (edgeless.scale ?? 1);
const xywh = bounds.serialize();
ctx.store.updateBlock(model, () => {
model.xywh = xywh;
model.props.edgeless.collapse = true;
});
}
}
},
};
},
},
{
id: 'g.scale',
content(ctx) {
const models = ctx.getSurfaceModelsByType(NoteBlockModel);
if (!models.length) return null;
const firstModel = models[0];
const scale$ = computed(() => {
const scale = firstModel.props.edgeless$.value.scale ?? 1;
return Math.round(100 * scale);
});
const onSelect = (e: CustomEvent<number>) => {
e.stopPropagation();
const scale = e.detail / 100;
models.forEach(model => {
const bounds = Bound.deserialize(model.xywh);
const oldScale = model.props.edgeless.scale ?? 1;
const ratio = scale / oldScale;
bounds.w *= ratio;
bounds.h *= ratio;
const xywh = bounds.serialize();
ctx.store.updateBlock(model, () => {
model.xywh = xywh;
model.props.edgeless.scale = scale;
});
});
ctx.track('SelectedCardScale', {
...trackBaseProps,
control: 'select card scale',
});
};
const onToggle = (e: CustomEvent<boolean>) => {
e.stopPropagation();
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedCardScaleSelector', {
...trackBaseProps,
control: 'switch card scale',
});
};
const format = (value: number) => `${value}%`;
return html`<affine-size-dropdown-menu
@select=${onSelect}
@toggle=${onToggle}
.format=${format}
.size$=${scale$}
></affine-size-dropdown-menu>`;
},
},
],
when: ctx => ctx.getSurfaceModelsByType(NoteBlockModel).length > 0,
} as const satisfies ToolbarModuleConfig;
function setDisplayMode(
ctx: ToolbarContext,
model: NoteBlockModel,
newMode: NoteDisplayMode
) {
const displayMode = model.props.displayMode;
ctx.command.exec(changeNoteDisplayMode, {
noteId: model.id,
mode: newMode,
stopCapture: true,
});
// if change note to page only, should clear the selection
if (newMode === NoteDisplayMode.DocOnly) {
ctx.selection.clear();
}
const abortController = new AbortController();
const clear = () => {
ctx.history.off('stack-item-added', addHandler);
ctx.history.off('stack-item-popped', popHandler);
disposable.unsubscribe();
};
const closeNotify = () => {
abortController.abort();
clear();
};
const addHandler = ctx.history.on('stack-item-added', closeNotify);
const popHandler = ctx.history.on('stack-item-popped', closeNotify);
const disposable = ctx.std
.get(EditorLifeCycleExtension)
.slots.unmounted.subscribe(closeNotify);
const undo = () => {
ctx.store.undo();
closeNotify();
};
const viewInToc = () => {
const sidebar = ctx.std.getOptional(SidebarExtensionIdentifier);
sidebar?.open('outline');
closeNotify();
};
const data =
newMode === NoteDisplayMode.EdgelessOnly
? {
title: 'Note removed from Page Mode',
message: 'Content removed from your page.',
}
: {
title: 'Note displayed in Page Mode',
message: 'Content added to your page.',
};
const notification = ctx.std.getOptional(NotificationProvider);
notification?.notify({
title: data.title,
message: `${data.message} Find it in the TOC for quick navigation.`,
accent: 'success',
duration: 5 * 1000,
footer: html`<div class=${styles.viewInPageNotifyFooter}>
<button
class=${styles.viewInPageNotifyFooterButton}
@click=${undo}
data-testid="undo-display-in-page"
>
Undo
</button>
<button
class=${styles.viewInPageNotifyFooterButton}
@click=${viewInToc}
data-testid="view-in-toc"
>
View in Toc
</button>
</div>`,
abort: abortController.signal,
onClose: () => {
clear();
},
});
ctx.track('NoteDisplayModeChanged', {
...trackBaseProps,
control: 'display mode',
other: `from ${displayMode} to ${newMode}`,
});
}
export const createBuiltinToolbarConfigExtension = (
flavour: string
): ExtensionType[] => {
const name = flavour.split(':').pop();
return [
ToolbarModuleExtension({
id: BlockFlavourIdentifier(`affine:surface:${name}`),
config: builtinSurfaceToolbarConfig,
}),
];
};

View File

@@ -0,0 +1,330 @@
import type { SlashMenuTooltip } from '@blocksuite/affine-widget-slash-menu';
import { html } from 'lit';
// prettier-ignore
const TextTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_868" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_868)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const Heading1Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_873" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_873)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="28" font-weight="bold" letter-spacing="-0.24px"><tspan x="8" y="34.1818">Heading 1</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="51.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="63.6364">complexity to our data.&#10;</tspan><tspan x="8" y="79.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="91.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="107.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="119.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="131.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="143.636">other users.&#10;</tspan><tspan x="8" y="159.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="171.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const Heading2Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_880" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_880)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="26" font-weight="600" letter-spacing="-0.24px"><tspan x="8" y="33.4545">Heading 2</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="51.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="63.6364">complexity to our data.&#10;</tspan><tspan x="8" y="79.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="91.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="107.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="119.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="131.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="143.636">other users.&#10;</tspan><tspan x="8" y="159.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="171.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const Heading3Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_887" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_887)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="24" font-weight="600" letter-spacing="-0.24px"><tspan x="8" y="30.7273">Heading 3</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="47.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="59.6364">complexity to our data.&#10;</tspan><tspan x="8" y="75.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="87.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="103.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="115.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="127.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="139.636">other users.&#10;</tspan><tspan x="8" y="155.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="167.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const Heading4Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_894" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_894)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="22" font-weight="600" letter-spacing="0.24px"><tspan x="8" y="29">Heading 4</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="45.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="57.6364">complexity to our data.&#10;</tspan><tspan x="8" y="73.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="85.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="101.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="113.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="125.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="137.636">other users.&#10;</tspan><tspan x="8" y="153.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="165.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const Heading5Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_901" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_901)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="20" font-weight="600" letter-spacing="0.24px"><tspan x="8" y="27.2727">Heading 5</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="55.6364">complexity to our data.&#10;</tspan><tspan x="8" y="71.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="83.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="99.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="111.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="123.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="135.636">other users.&#10;</tspan><tspan x="8" y="151.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="163.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const Heading6Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_908" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_908)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="18" font-weight="600" letter-spacing="0.24px"><tspan x="8" y="25.5455">Heading 6</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="41.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="53.6364">complexity to our data.&#10;</tspan><tspan x="8" y="69.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="81.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="97.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="109.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="121.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="133.636">other users.&#10;</tspan><tspan x="8" y="149.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="161.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const CodeBlockTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_915" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_915)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="47.5742" y="17.46"> </tspan><tspan x="126.723" y="17.46">: </tspan><tspan x="166.297" y="17.46"> {&#10;</tspan><tspan x="8" y="32.46"> </tspan><tspan x="100.34" y="32.46"> helloTo </tspan><tspan x="166.297" y="32.46"> &#34;World&#34;&#10;</tspan><tspan x="8" y="47.46"> </tspan><tspan x="54.1699" y="47.46"> body: </tspan><tspan x="126.723" y="47.46"> </tspan><tspan x="159.701" y="47.46"> {&#10;</tspan><tspan x="8" y="62.46"> </tspan><tspan x="87.1484" y="62.46">(</tspan><tspan x="219.062" y="62.46">)&#10;</tspan><tspan x="8" y="77.46">}&#10;</tspan><tspan x="8" y="92.46">}</tspan></text>
<text fill="#0782A0" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="8" y="17.46">struct</tspan><tspan x="73.957" y="32.46"> var</tspan><tspan x="159.701" y="32.46">=</tspan><tspan x="34.3828" y="47.46">var</tspan><tspan x="100.34" y="47.46">some</tspan><tspan x="139.914" y="62.46">\(</tspan></text>
<text fill="#842ED3" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="54.1699" y="17.46">ContentView</tspan></text>
<text fill="#C62222" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="139.914" y="17.46">View</tspan><tspan x="34.3828" y="32.46">@State</tspan><tspan x="133.318" y="47.46">View</tspan></text>
<text fill="#2159D3" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="60.7656" y="62.46">Text</tspan></text>
<text fill="#D34F0B" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="93.7441" y="62.46">&#34;Hello </tspan><tspan x="153.105" y="62.46">helloTo)!&#34;</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const QuoteTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_920" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_920)">
<rect x="12" y="14" width="2" height="33" rx="1" fill="#C2C1C5"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="26.6364">In a decentralized system, we can have a </tspan><tspan x="24" y="40.6364">kaleidoscopic complexity to our data.&#10;…</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const DividerTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_928" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_928)">
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">In a decentralized system, we can have a </tspan><tspan x="8" y="30.6364">kaleidoscopic complexity to our data.&#10;</tspan><tspan x="8" y="54.6364">Any user may have a different perspective </tspan><tspan x="8" y="68.6364">on what data they either have, choose to </tspan><tspan x="8" y="82.6364">share, or accept.&#10;</tspan><tspan x="8" y="106.636">For example, one user&#x2019;s edits to a </tspan><tspan x="8" y="120.636">document might be on their laptop on an </tspan><tspan x="8" y="134.636">airplane; when the plane lands and the </tspan><tspan x="8" y="148.636">computer reconnects, those changes are </tspan><tspan x="8" y="162.636">distributed to other users.&#10;</tspan><tspan x="8" y="186.636">Other users might choose to accept all, </tspan><tspan x="8" y="200.636">some, or none of those changes to their </tspan><tspan x="8" y="214.636">version of the document.</tspan></text>
<line x1="8.25" y1="40.75" x2="169.75" y2="40.75" stroke="#E3E2E4" stroke-width="0.5" stroke-linecap="round"/>
</g>
</svg>
`;
// prettier-ignore
const BulletedListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_934" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_934)">
<circle cx="14" cy="26" r="1.5" fill="#1C81D9"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="22" y="29.6364">Here&#39;s an example of a bulleted list.</tspan></text>
<circle cx="14" cy="42" r="1.5" fill="#1C81D9"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="22" y="45.6364">You can list your plans such as this</tspan></text>
</g>
</svg>
`;
// prettier-ignore
const NumberedListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_947" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_947)">
<g clip-path="url(#clip0_16460_947)">
<text fill="#1C81D9" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="29.6364">1.</tspan></text>
</g>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="29.6364">Here&#39;s an example of a numbered list.</tspan></text>
<g clip-path="url(#clip1_16460_947)">
<text fill="#1C81D9" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="45.6364">2.</tspan></text>
</g>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="45.6364">You can list your plans such as this</tspan></text>
</g>
<defs>
<clipPath id="clip0_16460_947">
<rect width="16" height="16" fill="white" transform="translate(10 18)"/>
</clipPath>
<clipPath id="clip1_16460_947">
<rect width="16" height="16" fill="white" transform="translate(10 34)"/>
</clipPath>
</defs>
</svg>
`;
// prettier-ignore
export const BoldTextTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_971" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_971)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-weight="bold" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;
// prettier-ignore
export const ItalicTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_976" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_976)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-style="italic" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;
// prettier-ignore
export const StrikethroughTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_986" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_986)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px" text-decoration="line-through"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;
// prettier-ignore
export const UnderlineTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_981" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_981)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px" text-decoration="underline"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;
// prettier-ignore
export const TodoTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_5604_203551" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_5604_203551)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6667 19C12.7462 19 12 19.7462 12 20.6667V27.3333C12 28.2538 12.7462 29 13.6667 29H20.3333C21.2538 29 22 28.2538 22 27.3333V20.6667C22 19.7462 21.2538 19 20.3333 19H13.6667ZM12.9091 20.6667C12.9091 20.2483 13.2483 19.9091 13.6667 19.9091H20.3333C20.7517 19.9091 21.0909 20.2483 21.0909 20.6667V27.3333C21.0909 27.7517 20.7517 28.0909 20.3333 28.0909H13.6667C13.2483 28.0909 12.9091 27.7517 12.9091 27.3333V20.6667Z" fill="#77757D"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="27.6364">Here is an example of todo list.</tspan></text>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 40.6667C12 39.7462 12.7462 39 13.6667 39H20.3333C21.2538 39 22 39.7462 22 40.6667V47.3333C22 48.2538 21.2538 49 20.3333 49H13.6667C12.7462 49 12 48.2538 12 47.3333V40.6667ZM19.7457 42.5032C19.9232 42.3257 19.9232 42.0379 19.7457 41.8604C19.5681 41.6829 19.2803 41.6829 19.1028 41.8604L16.0909 44.8723L15.2002 43.9816C15.0227 43.8041 14.7349 43.8041 14.5574 43.9816C14.3799 44.1591 14.3799 44.4469 14.5574 44.6244L15.7695 45.8366C15.947 46.0141 16.2348 46.0141 16.4123 45.8366L19.7457 42.5032Z" fill="#1E96EB"/>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="47.6364">Make a list for building preview.</tspan></text>
</g>
</svg>
`
export const tooltips: Record<string, SlashMenuTooltip> = {
Text: {
figure: TextTooltip,
caption: 'Text',
},
'Heading 1': {
figure: Heading1Tooltip,
caption: 'Heading #1',
},
'Heading 2': {
figure: Heading2Tooltip,
caption: 'Heading #2',
},
'Heading 3': {
figure: Heading3Tooltip,
caption: 'Heading #3',
},
'Heading 4': {
figure: Heading4Tooltip,
caption: 'Heading #4',
},
'Heading 5': {
figure: Heading5Tooltip,
caption: 'Heading #5',
},
'Heading 6': {
figure: Heading6Tooltip,
caption: 'Heading #6',
},
'Code Block': {
figure: CodeBlockTooltip,
caption: 'Code Block',
},
Quote: {
figure: QuoteTooltip,
caption: 'Quote',
},
Divider: {
figure: DividerTooltip,
caption: 'Divider',
},
'Bulleted List': {
figure: BulletedListTooltip,
caption: 'Bulleted List',
},
'Numbered List': {
figure: NumberedListTooltip,
caption: 'Numbered List',
},
Bold: {
figure: BoldTextTooltip,
caption: 'Bold Text',
},
Italic: {
figure: ItalicTooltip,
caption: 'Italic',
},
Underline: {
figure: UnderlineTooltip,
caption: 'Underline',
},
Strikethrough: {
figure: StrikethroughTooltip,
caption: 'Strikethrough',
},
'To-do List': {
figure: TodoTooltip,
caption: 'To-do List',
},
};

View File

@@ -0,0 +1,28 @@
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
import { type BlockSnapshot } from '@blocksuite/store';
export class EdgelessClipboardNoteConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:note';
override async createBlock(note: BlockSnapshot): Promise<null | string> {
const oldId = note.id;
delete note.props.index;
if (!note.props.xywh) {
console.error(`Note block(id: ${oldId}) does not have xywh property`);
return null;
}
const newId = await this.onBlockSnapshotPaste(
note,
this.std.store,
this.std.store.root!.id
);
if (!newId) {
console.error(`Failed to paste note block(id: ${oldId})`);
return null;
}
return newId;
}
}

View File

@@ -0,0 +1,31 @@
import { EdgelessNoteBackground } from './components/edgeless-note-background';
import { EdgelessNoteBorderDropdownMenu } from './components/edgeless-note-border-dropdown-menu';
import { EdgelessNoteDisplayModeDropdownMenu } from './components/edgeless-note-display-mode-dropdown-menu';
import { EdgelessNoteMask } from './components/edgeless-note-mask';
import { EdgelessNoteShadowDropdownMenu } from './components/edgeless-note-shadow-dropdown-menu';
import { EdgelessPageBlockTitle } from './components/edgeless-page-block-title';
import { NoteBlockComponent } from './note-block';
import {
AFFINE_EDGELESS_NOTE,
EdgelessNoteBlockComponent,
} from './note-edgeless-block';
export function effects() {
customElements.define('affine-note', NoteBlockComponent);
customElements.define(AFFINE_EDGELESS_NOTE, EdgelessNoteBlockComponent);
customElements.define('edgeless-note-mask', EdgelessNoteMask);
customElements.define('edgeless-note-background', EdgelessNoteBackground);
customElements.define('edgeless-page-block-title', EdgelessPageBlockTitle);
customElements.define(
'edgeless-note-shadow-dropdown-menu',
EdgelessNoteShadowDropdownMenu
);
customElements.define(
'edgeless-note-border-dropdown-menu',
EdgelessNoteBorderDropdownMenu
);
customElements.define(
'edgeless-note-display-mode-dropdown-menu',
EdgelessNoteDisplayModeDropdownMenu
);
}

View File

@@ -0,0 +1,8 @@
export * from './adapters';
export * from './commands';
export * from './components/edgeless-note-background';
export * from './config';
export * from './edgeless-clipboard-config';
export * from './note-block';
export * from './note-edgeless-block';
export * from './note-spec';

View File

@@ -0,0 +1,129 @@
import {
BlockSelection,
type BlockStdScope,
TextSelection,
} from '@blocksuite/std';
const getSelection = (std: BlockStdScope) => std.selection;
function getBlockSelectionBySide(std: BlockStdScope, tail: boolean) {
const selection = getSelection(std);
const selections = selection.filter(BlockSelection);
const sel = selections.at(tail ? -1 : 0) as BlockSelection | undefined;
return sel ?? null;
}
function getTextSelection(std: BlockStdScope) {
const selection = getSelection(std);
return selection.find(TextSelection);
}
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.store;
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.store.getParent(previousSiblingModel);
if (!parentModel) return;
std.store.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.store;
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,34 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import { BlockComponent } from '@blocksuite/std';
import { css, html } from 'lit';
export class NoteBlockComponent extends BlockComponent<NoteBlockModel> {
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,74 @@
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const ACTIVE_NOTE_EXTRA_PADDING = 20;
export const edgelessNoteContainer = style({
height: '100%',
padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`,
boxSizing: 'border-box',
pointerEvents: 'all',
transformOrigin: '0 0',
fontWeight: '400',
lineHeight: cssVar('lineHeight'),
});
export const collapseButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
zIndex: 2,
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
opacity: 0.2,
transition: 'opacity 0.3s',
':hover': {
opacity: 1,
},
selectors: {
'&.flip': {
transform: 'translateX(-50%) rotate(180deg)',
},
},
});
export const noteBackground = style({
position: 'absolute',
borderColor: cssVar('black10'),
left: 0,
top: 0,
width: '100%',
height: '100%',
selectors: {
[`${edgelessNoteContainer}[data-editing="true"] &`]: {
left: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
top: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
width: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
height: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
transition: 'left 0.3s, top 0.3s, width 0.3s, height 0.3s',
boxShadow: cssVar('activeShadow'),
},
},
});
export const clipContainer = style({
width: '100%',
height: '100%',
});
export const collapsedContent = style({
position: 'absolute',
background: cssVar('white'),
opacity: 0.5,
pointerEvents: 'none',
border: `2px ${cssVar('blue')} solid`,
borderTop: 'unset',
borderRadius: '0 0 8px 8px',
});

View File

@@ -0,0 +1,401 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { DocTitle } from '@blocksuite/affine-fragment-doc-title';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import {
handleNativeRangeAtPoint,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import { toGfxBlockComponent } from '@blocksuite/std';
import type { SelectedContext } from '@blocksuite/std/gfx';
import { html, nothing, type PropertyValues } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import clamp from 'lodash-es/clamp';
import { MoreIndicator } from './components/more-indicator';
import { NoteConfigExtension } from './config';
import { NoteBlockComponent } from './note-block';
import { ACTIVE_NOTE_EXTRA_PADDING } from './note-edgeless-block.css';
import * as styles from './note-edgeless-block.css';
export const AFFINE_EDGELESS_NOTE = 'affine-edgeless-note';
export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
NoteBlockComponent
) {
private get _isShowCollapsedContent() {
return (
this.model.props.edgeless.collapse &&
this.gfx.selection.has(this.model.id) &&
!this._dragging &&
(this._isResizing || this._isHover)
);
}
private get _dragging() {
return this._isHover && this.gfx.tool.dragging$.value;
}
private _collapsedContent() {
if (!this._isShowCollapsedContent) {
return nothing;
}
const { xywh, edgeless } = this.model.props;
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._noteContent?.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
class=${styles.collapsedContent}
style=${styleMap({
width: `${width}px`,
height: `${this._noteFullHeight - height}px`,
left: `${-(extraPadding + extraBorder / 2)}px`,
top: `${height + extraPadding + extraBorder / 2}px`,
})}
></div>
`;
}
private _handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
this._docTitle?.inlineEditor?.focusEnd();
}
}
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.props.edgeless;
if (collapse) {
this.model.doc.updateBlock(this.model, () => {
this.model.props.edgeless.collapse = false;
});
} else if (collapsedHeight) {
const { xywh, edgeless } = this.model.props;
const bound = Bound.deserialize(xywh);
bound.h = collapsedHeight * (edgeless.scale ?? 1);
this.model.doc.updateBlock(this.model, () => {
this.model.props.edgeless.collapse = true;
this.model.props.xywh = bound.serialize();
});
}
this.selection.clear();
}
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.subscribe(() => {
if (selection.has(this.model.id) && selection.editing) {
this._editing = true;
} else {
this._editing = false;
}
})
);
this.disposables.addFromEvent(this, 'keydown', this._handleKeyDown);
}
get edgelessSlots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
override firstUpdated() {
const { _disposables } = this;
const selection = this.gfx.selection;
_disposables.add(
this.edgelessSlots.elementResizeStart.subscribe(() => {
if (selection.selectedElements.includes(this.model)) {
this._isResizing = true;
}
})
);
_disposables.add(
this.edgelessSlots.elementResizeEnd.subscribe(() => {
this._isResizing = false;
})
);
const observer = new MutationObserver(() => {
const rect = this._noteContent?.getBoundingClientRect();
if (!rect) return;
const zoom = this.gfx.viewport.zoom;
const scale = this.model.props.edgeless.scale ?? 1;
this._noteFullHeight =
rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING;
});
if (this._noteContent) {
observer.observe(this, { childList: true, subtree: true });
_disposables.add(() => observer.disconnect());
}
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('_editing') && this._editing) {
this.std.getOptional(TelemetryProvider)?.track('EdgelessNoteEditing', {
page: 'edgeless',
segment: this.model.isPageBlock() ? 'page' : 'note',
});
}
}
override getRenderingRect() {
const { xywh, edgeless } = this.model.props;
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 : 'unset',
zIndex: this.toZIndex(),
};
}
override renderGfxBlock() {
const { model } = this;
const { displayMode } = model.props;
if (!!displayMode && displayMode === NoteDisplayMode.DocOnly)
return nothing;
const { xywh, edgeless } = model.props;
const { borderRadius } = edgeless.style;
const { collapse = false, collapsedHeight, scale = 1 } = edgeless;
const { tool } = this.gfx;
const bound = Bound.deserialize(xywh);
const height = bound.h / scale;
const style = {
borderRadius: borderRadius + 'px',
transform: `scale(${scale})`,
};
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
const isCollapsable =
collapse != null &&
collapsedHeight != null &&
collapsedHeight !== this._noteFullHeight;
const isCollapseArrowUp = collapse
? this._noteFullHeight < height
: !!collapsedHeight && collapsedHeight < height;
const hasHeader = !!this.std.getOptional(NoteConfigExtension.identifier)
?.edgelessNoteHeader;
return html`
<div
class=${styles.edgelessNoteContainer}
style=${styleMap(style)}
data-model-height="${bound.h}"
data-editing=${this._editing}
data-collapse=${ifDefined(collapse)}
data-testid="edgeless-note-container"
@mouseleave=${this._leaved}
@mousemove=${this._hovered}
data-scale="${scale}"
>
<edgeless-note-background
.editing=${this._editing}
.note=${this.model}
></edgeless-note-background>
<div
data-testid="edgeless-note-clip-container"
class=${styles.clipContainer}
style=${styleMap({
'overflow-y': this._isShowCollapsedContent ? 'initial' : 'clip',
})}
>
<div>
<edgeless-page-block-title
.note=${this.model}
></edgeless-page-block-title>
<div
contenteditable=${String(!this.doc.readonly$.value)}
class="edgeless-note-page-content"
>
${this.renderPageContent()}
</div>
</div>
</div>
<edgeless-note-mask
.model=${this.model}
.host=${this.host}
.zoom=${this.gfx.viewport.zoom ?? 1}
.disableMask=${this.hideMask}
.editing=${this._editing}
></edgeless-note-mask>
${isCollapsable &&
tool.currentToolName$.value !== 'frameNavigator' &&
(!this.model.isPageBlock() || !hasHeader)
? html`<div
class="${classMap({
[styles.collapseButton]: true,
flip: isCollapseArrowUp,
})}"
style=${styleMap({
bottom: this._editing ? `${-extra}px` : '0',
})}
data-testid="edgeless-note-collapse-button"
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@click=${this._setCollapse}
>
${MoreIndicator}
</div>`
: nothing}
${this._collapsedContent()}
</div>
`;
}
override onSelected(context: SelectedContext) {
const { selected, multiSelect, event: e } = context;
const { editing } = this.gfx.selection;
const alreadySelected = this.gfx.selection.has(this.model.id);
if (!multiSelect && selected && (alreadySelected || editing)) {
if (this.model.isLocked()) return;
if (alreadySelected && editing) {
return;
}
this.gfx.selection.set({
elements: [this.model.id],
editing: true,
});
this.updateComplete
.then(() => {
if (!this.isConnected) {
return;
}
if (this.model.children.length === 0) {
const blockId = this.doc.addBlock(
'affine:paragraph',
{ type: 'text' },
this.model.id
);
if (blockId) {
focusTextModel(this.std, blockId);
}
} else {
const rect = this.querySelector(
'.affine-block-children-container'
)?.getBoundingClientRect();
if (rect) {
const offsetY = 8 * this.gfx.viewport.zoom;
const offsetX = 2 * this.gfx.viewport.zoom;
const x = clamp(
e.clientX,
rect.left + offsetX,
rect.right - offsetX
);
const y = clamp(
e.clientY,
rect.top + offsetY,
rect.bottom - offsetY
);
handleNativeRangeAtPoint(x, y);
} else {
handleNativeRangeAtPoint(e.clientX, e.clientY);
}
}
})
.catch(console.error);
} else {
super.onSelected(context);
}
}
@state()
private accessor _editing = false;
@state()
private accessor _isHover = false;
@state()
private accessor _isResizing = false;
@state()
private accessor _noteFullHeight = 0;
@state()
accessor hideMask = false;
@query(`.${styles.clipContainer} > div`)
private accessor _noteContent: HTMLElement | null = null;
@query('doc-title')
private accessor _docTitle: DocTitle | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EDGELESS_NOTE]: EdgelessNoteBlockComponent;
}
}

View File

@@ -0,0 +1,611 @@
import {
CodeBlockModel,
ListBlockModel,
NoteBlockModel,
NoteBlockSchema,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
import {
focusBlockEnd,
focusBlockStart,
getBlockSelectionsCommand,
getNextBlockCommand,
getPrevBlockCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import {
asyncGetBlockComponent,
matchModels,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
type BlockStdScope,
type Chain,
KeymapExtension,
TextSelection,
type UIEventHandler,
type UIEventStateContext,
} from '@blocksuite/std';
import type { BaseSelection } from '@blocksuite/store';
import {
dedentBlocks,
dedentBlocksToRoot,
indentBlocks,
selectBlock,
selectBlocksBetween,
updateBlockType,
} from './commands';
import { moveBlockConfigs } from './move-block';
import { quickActionConfig } from './quick-action';
class NoteKeymap {
constructor(readonly std: BlockStdScope) {}
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()
.pipe(updateBlockType, {
flavour: item.flavour,
props: {
type: item.type,
},
})
.pipe((ctx, next) => {
const newModels = ctx.updatedBlocks;
if (!newModels) {
return;
}
if (item.flavour !== 'affine:code') {
return;
}
const [codeModel] = newModels;
asyncGetBlockComponent(ctx.std, codeModel.id)
.then(codeElement => {
if (!codeElement) {
return;
}
this._std.selection.setGroup('note', [
this._std.selection.create(TextSelection, {
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.store;
let parent = doc.getBlock(blockId)?.model ?? null;
while (parent) {
if (matchModels(parent, [NoteBlockModel])) {
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()
.pipe((_, 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
.pipe(getTextSelectionCommand)
.pipe<{ currentSelectionPath: string }>((ctx, next) => {
const currentTextSelection = ctx.currentTextSelection;
if (!currentTextSelection) {
return;
}
return next({ currentSelectionPath: currentTextSelection.blockId });
})
.pipe(getNextBlockCommand)
.pipe((ctx, next) => {
const { nextBlock } = ctx;
if (!nextBlock) {
return;
}
if (
!matchModels(nextBlock.model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
) {
this._std.command.exec(selectBlock, {
focusBlock: nextBlock,
});
}
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
.pipe(getBlockSelectionsCommand)
.pipe<{ currentSelectionPath: string }>((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
const blockSelection = currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
return next({ currentSelectionPath: blockSelection.blockId });
})
.pipe(getNextBlockCommand)
.pipe<{ focusBlock: BlockComponent }>((ctx, next) => {
const { nextBlock } = ctx;
if (!nextBlock) {
return;
}
event.preventDefault();
if (
matchModels(nextBlock.model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
) {
this._std.command.exec(focusBlockStart, {
focusBlock: nextBlock,
});
return next();
}
this._std.command.exec(selectBlock, {
focusBlock: nextBlock,
});
return next();
}),
])
.run();
return result;
};
private readonly _onArrowUp = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event;
const [result] = this._std.command
.chain()
.pipe((_, 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
.pipe(getTextSelectionCommand)
.pipe<{ currentSelectionPath: string }>((ctx, next) => {
const currentTextSelection = ctx.currentTextSelection;
if (!currentTextSelection) {
return;
}
return next({ currentSelectionPath: currentTextSelection.blockId });
})
.pipe(getPrevBlockCommand)
.pipe((ctx, next) => {
const { prevBlock } = ctx;
if (!prevBlock) {
return;
}
if (
!matchModels(prevBlock.model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
) {
this._std.command.exec(selectBlock, {
focusBlock: prevBlock,
});
}
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
.pipe(getBlockSelectionsCommand)
.pipe<{ currentSelectionPath: string }>((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
const blockSelection = currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
return next({ currentSelectionPath: blockSelection.blockId });
})
.pipe(getPrevBlockCommand)
.pipe((ctx, next) => {
const { prevBlock } = ctx;
if (!prevBlock) {
return;
}
if (
matchModels(prevBlock.model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
) {
event.preventDefault();
this._std.command.exec(focusBlockEnd, {
focusBlock: prevBlock,
});
return next();
}
this._std.command.exec(selectBlock, {
focusBlock: prevBlock,
});
return next();
}),
])
.run();
return result;
};
private readonly _onBlockShiftDown = (cmd: Chain) => {
return cmd
.pipe(getBlockSelectionsCommand)
.pipe<{ currentSelectionPath: string; anchorBlock: BlockComponent }>(
(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,
});
}
)
.pipe(getNextBlockCommand)
.pipe<{ focusBlock: BlockComponent }>((ctx, next) => {
const nextBlock = ctx.nextBlock;
if (!nextBlock) {
return;
}
this._focusBlock = nextBlock;
return next({
focusBlock: this._focusBlock,
});
})
.pipe(selectBlocksBetween, { tail: true });
};
private readonly _onBlockShiftUp = (cmd: Chain) => {
return cmd
.pipe(getBlockSelectionsCommand)
.pipe<{ currentSelectionPath: string; anchorBlock: BlockComponent }>(
(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,
});
}
)
.pipe(getPrevBlockCommand)
.pipe((ctx, next) => {
const prevBlock = ctx.prevBlock;
if (!prevBlock) {
return;
}
this._focusBlock = prevBlock;
return next({
focusBlock: this._focusBlock,
});
})
.pipe(selectBlocksBetween, { tail: false });
};
private readonly _onEnter = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event;
const [result] = this._std.command
.chain()
.pipe(getBlockSelectionsCommand)
.pipe((ctx, next) => {
const blockSelection = ctx.currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
const { view, store, selection } = ctx.std;
const element = view.getBlock(blockSelection.blockId);
if (!element) {
return;
}
const { model } = element;
const parent = store.getParent(model);
if (!parent) {
return;
}
const index = parent.children.indexOf(model) ?? undefined;
const blockId = store.addBlock(
'affine:paragraph',
{},
parent,
index + 1
);
const sel = selection.create(TextSelection, {
from: {
blockId,
index: 0,
length: 0,
},
to: null,
});
event.preventDefault();
selection.setGroup('note', [sel]);
this._reset();
return next();
})
.run();
return result;
};
private readonly _onEsc = () => {
const [result] = this._std.command
.chain()
.pipe(getBlockSelectionsCommand)
.pipe((ctx, next) => {
const blockSelection = ctx.currentBlockSelections?.at(-1);
if (!blockSelection) {
return;
}
ctx.std.selection.update(selList => {
return selList.filter(sel => !sel.is(BlockSelection));
});
return next();
})
.run();
return result;
};
private readonly _onSelectAll: UIEventHandler = ctx => {
const selection = this._std.selection;
const block = selection.find(BlockSelection);
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(BlockSelection, {
blockId: child.id,
});
});
selection.update(selList => {
return selList
.filter<BaseSelection>(sel => !sel.is(BlockSelection))
.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;
}
get hotKeys(): Record<string, UIEventHandler> {
return {
...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,
};
}
}
export const NoteKeymapExtension = KeymapExtension(
std => new NoteKeymap(std).hotKeys,
{
flavour: NoteBlockSchema.model.flavour,
}
);

View File

@@ -0,0 +1,31 @@
import { NoteBlockSchema } from '@blocksuite/affine-model';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import {
DocNoteBlockAdapterExtensions,
EdgelessNoteBlockAdapterExtensions,
} from './adapters/index';
import { NoteSlashMenuConfigExtension } from './configs/slash-menu';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
import { NoteKeymapExtension } from './note-keymap.js';
const flavour = NoteBlockSchema.model.flavour;
export const NoteBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
BlockViewExtension(flavour, literal`affine-note`),
DocNoteBlockAdapterExtensions,
NoteSlashMenuConfigExtension,
NoteKeymapExtension,
].flat();
export const EdgelessNoteBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
BlockViewExtension(flavour, literal`affine-edgeless-note`),
EdgelessNoteBlockAdapterExtensions,
NoteSlashMenuConfigExtension,
createBuiltinToolbarConfigExtension(flavour),
NoteKeymapExtension,
].flat();

View File

@@ -0,0 +1,66 @@
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import {
draftSelectedModelsCommand,
getSelectedModelsCommand,
} from '@blocksuite/affine-shared/commands';
import type { BlockStdScope } from '@blocksuite/std';
import { toDraftModel } from '@blocksuite/store';
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.exec(getSelectedModelsCommand, {
types: ['block'],
});
const { selectedModels } = ctx;
return !!selectedModels && selectedModels.length > 0;
},
action: std => {
const [_, ctx] = std.command
.chain()
.pipe(getSelectedModelsCommand, {
types: ['block'],
mode: 'flat',
})
.pipe(draftSelectedModelsCommand)
.run();
const { selectedModels, draftedModels } = ctx;
if (!selectedModels) return;
if (!selectedModels.length || !draftedModels) return;
std.selection.clear();
const doc = std.store;
const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill)
.then(title => {
if (title === null) return;
convertSelectedBlocksToLinkedDoc(
std,
doc,
draftedModels,
title
).catch(console.error);
notifyDocCreated(std, doc);
})
.catch(console.error);
},
},
];