chore(editor): reorg packages (#10702)

This commit is contained in:
Saul-Mirone
2025-03-08 03:57:04 +00:00
parent 334912e85b
commit 8aedef0a36
961 changed files with 837 additions and 927 deletions

View File

@@ -0,0 +1,49 @@
{
"name": "@blocksuite/affine-block-note",
"description": "Note block for BlockSuite.",
"type": "module",
"scripts": {
"build": "tsc",
"test:unit": "nx vite:test --run --passWithNoTests",
"test:unit:coverage": "nx vite:test --run --coverage",
"test:e2e": "playwright test"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-embed": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-fragment-doc-title": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/affine-widget-slash-menu": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.1",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/mdast": "^4.0.4",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"zod": "^3.23.8"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}

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/block-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.getBlockById(id);
if (!model) return;
asyncSetInlineRange(host, model, {
index: model.text?.length ?? 0,
length: 0,
}).catch(console.error);
return next({ updatedBlocks: [model] });
};
const appendDivider: Command<{}, { 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.getBlockById(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(host, 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.getBlockById(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/block-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.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.displayMode === NoteDisplayMode.EdgelessOnly
);
const lastPageVisibleNote = notes.findLast(
note => note.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/block-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/block-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.type.startsWith('h') &&
model.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/block-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/block-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.type.startsWith('h') &&
model.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
collapsedIds.push(...collapsedSiblings.map(sibling => sibling.id));
}
});
// Models waiting to be dedented
const dedentIds = blockIds
.slice(firstDedentIndex)
.filter(id => !collapsedIds.includes(id));
dedentIds.reverse().forEach(id => {
std.command.exec(dedentBlock, { blockId: id, stopCapture: false });
});
const textSelection = selection.find(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/block-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.type.startsWith('h') &&
model.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.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/block-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.type.startsWith('h') &&
model.collapsed
) {
const collapsedSiblings = calculateCollapsedSiblings(model);
collapsedIds.push(...collapsedSiblings.map(sibling => sibling.id));
}
});
// Models waiting to be indented
const indentIds = blockIds
.slice(firstIndentIndex)
.filter(id => !collapsedIds.includes(id));
const firstModel = 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.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.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/block-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/block-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 {
type BlockComponent,
type BlockStdScope,
PropTypes,
requiredProperties,
ShadowlessElement,
stdContext,
TextSelection,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { clamp, Point } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
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.background$.value,
DefaultTheme.noteBackgrounColor,
theme
);
const { borderRadius, borderSize, borderStyle, shadowType } =
this.note.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,83 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
import { almostEqual, Bound } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
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.edgeless.collapse) {
const bound = Bound.deserialize(this.model.xywh);
const scale = this.model.edgeless.scale ?? 1;
const height = entry.contentRect.height * scale;
if (!height || almostEqual(bound.h, height)) {
return;
}
bound.h = height;
this.model.stash('xywh');
this.model.xywh = bound.serialize();
this.model.pop('xywh');
}
}
});
observer.observe(maskDOM!);
this._disposables.add(() => {
observer.disconnect();
});
}
override render() {
const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
return html`
<div
class="affine-note-mask"
style=${styleMap({
position: 'absolute',
top: `${-extra}px`,
left: `${-extra}px`,
bottom: `${-extra}px`,
right: `${-extra}px`,
zIndex: '1',
pointerEvents: this.editing || this.disableMask ? 'none' : 'auto',
borderRadius: `${
this.model.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,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 {
type BlockStdScope,
PropTypes,
requiredProperties,
ShadowlessElement,
stdContext,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
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,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,19 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import {
type BlockStdScope,
ConfigExtensionFactory,
} from '@blocksuite/block-std';
import type { TemplateResult } from 'lit';
type NoteBlockContext = {
note: NoteBlockModel;
std: BlockStdScope;
};
export type NoteConfig = {
edgelessNoteHeader: (context: NoteBlockContext) => TemplateResult;
pageBlockTitle: (context: NoteBlockContext) => TemplateResult;
};
export const NoteConfigExtension =
ConfigExtensionFactory<NoteConfig>('affine:note');

View File

@@ -0,0 +1,119 @@
import {
formatBlockCommand,
type TextConversionConfig,
textConversionConfigs,
type TextFormatConfig,
textFormatConfigs,
} 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 { BlockSelection } from '@blocksuite/block-std';
import { HeadingsIcon } from '@blocksuite/icons/lit';
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,310 @@
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>
`;
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',
},
};

View File

@@ -0,0 +1,16 @@
import { EdgelessNoteBackground } from './components/edgeless-note-background';
import { EdgelessNoteMask } from './components/edgeless-note-mask';
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);
}

View File

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

View File

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

View File

@@ -0,0 +1,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,325 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { DocTitle } from '@blocksuite/affine-fragment-doc-title';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { toGfxBlockComponent } from '@blocksuite/block-std';
import { Bound } from '@blocksuite/global/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 { 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.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;
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.edgeless;
if (collapse) {
this.model.doc.updateBlock(this.model, () => {
this.model.edgeless.collapse = false;
});
} else if (collapsedHeight) {
const { xywh, edgeless } = this.model;
const bound = Bound.deserialize(xywh);
bound.h = collapsedHeight * (edgeless.scale ?? 1);
this.model.doc.updateBlock(this.model, () => {
this.model.edgeless.collapse = true;
this.model.xywh = bound.serialize();
});
}
this.selection.clear();
}
override connectedCallback(): void {
super.connectedCallback();
const selection = this.gfx.selection;
this._editing = selection.has(this.model.id) && selection.editing;
this._disposables.add(
selection.slots.updated.on(() => {
if (selection.has(this.model.id) && selection.editing) {
this._editing = true;
} else {
this._editing = false;
}
})
);
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.on(() => {
if (selection.selectedElements.includes(this.model)) {
this._isResizing = true;
}
})
);
_disposables.add(
this.edgelessSlots.elementResizeEnd.on(() => {
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.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;
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;
if (!!displayMode && displayMode === NoteDisplayMode.DocOnly)
return nothing;
const { xywh, edgeless } = model;
const { borderRadius } = edgeless.style;
const { collapse = false, collapsedHeight, scale = 1 } = edgeless;
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 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 && (!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>
`;
}
@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,620 @@
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 { matchModels } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
BlockService,
type BlockStdScope,
type Chain,
TextSelection,
type UIEventHandler,
type UIEventStateContext,
} from '@blocksuite/block-std';
import type { BaseSelection, BlockModel } from '@blocksuite/store';
import {
dedentBlocks,
dedentBlocksToRoot,
indentBlocks,
selectBlock,
selectBlocksBetween,
updateBlockType,
} from './commands';
import { moveBlockConfigs } from './move-block';
import { quickActionConfig } from './quick-action';
export class NoteBlockService extends BlockService {
static override readonly flavour = NoteBlockSchema.model.flavour;
private _anchorSel: BlockSelection | null = null;
private readonly _bindMoveBlockHotKey = () => {
return moveBlockConfigs.reduce(
(acc, config) => {
const keys = config.hotkey.reduce(
(acc, key) => {
return {
...acc,
[key]: ctx => {
ctx.get('defaultState').event.preventDefault();
return config.action(this.std);
},
};
},
{} as Record<string, UIEventHandler>
);
return {
...acc,
...keys,
};
},
{} as Record<string, UIEventHandler>
);
};
private readonly _bindQuickActionHotKey = () => {
return quickActionConfig
.filter(config => config.hotkey)
.reduce(
(acc, config) => {
return {
...acc,
[config.hotkey!]: ctx => {
if (!config.showWhen(this.std)) return;
ctx.get('defaultState').event.preventDefault();
config.action(this.std);
},
};
},
{} as Record<string, UIEventHandler>
);
};
private readonly _bindTextConversionHotKey = () => {
return textConversionConfigs
.filter(item => item.hotkey)
.reduce(
(acc, item) => {
const keymap = item.hotkey!.reduce(
(acc, key) => {
return {
...acc,
[key]: ctx => {
ctx.get('defaultState').event.preventDefault();
const [result] = this._std.command
.chain()
.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;
onModelElementUpdated(ctx.std, codeModel, codeElement => {
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]);
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;
}
override mounted() {
super.mounted();
this.handleEvent('keyDown', ctx => {
const state = ctx.get('keyboardState');
if (['Control', 'Meta', 'Shift'].includes(state.raw.key)) {
return;
}
this._reset();
});
this.bindHotKey({
...this._bindMoveBlockHotKey(),
...this._bindQuickActionHotKey(),
...this._bindTextConversionHotKey(),
Tab: ctx => {
const [success] = this.std.command.exec(indentBlocks);
if (!success) return;
ctx.get('keyboardState').raw.preventDefault();
return true;
},
'Shift-Tab': ctx => {
const [success] = this.std.command.exec(dedentBlocks);
if (!success) return;
ctx.get('keyboardState').raw.preventDefault();
return true;
},
'Mod-Backspace': ctx => {
const [success] = this.std.command.exec(dedentBlocksToRoot);
if (!success) return;
ctx.get('keyboardState').raw.preventDefault();
return true;
},
ArrowDown: this._onArrowDown,
ArrowUp: this._onArrowUp,
'Shift-ArrowDown': this._onShiftArrowDown,
'Shift-ArrowUp': this._onShiftArrowUp,
Escape: this._onEsc,
Enter: this._onEnter,
'Mod-a': this._onSelectAll,
});
}
}
async function onModelElementUpdated(
std: BlockStdScope,
model: BlockModel,
callback: (block: BlockComponent) => void
) {
const page = model.doc;
if (!page.root) return;
const rootComponent = std.view.getBlock(page.root.id);
if (!rootComponent) return;
await rootComponent.updateComplete;
const element = std.view.getBlock(model.id);
if (element) callback(element);
}

View File

@@ -0,0 +1,29 @@
import { NoteBlockSchema } from '@blocksuite/affine-model';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import {
DocNoteBlockAdapterExtensions,
EdgelessNoteBlockAdapterExtensions,
} from './adapters/index.js';
import { NoteSlashMenuConfigExtension } from './configs/slash-menu.js';
import { NoteBlockService } from './note-service.js';
const flavour = NoteBlockSchema.model.flavour;
export const NoteBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
NoteBlockService,
BlockViewExtension(flavour, literal`affine-note`),
DocNoteBlockAdapterExtensions,
NoteSlashMenuConfigExtension,
].flat();
export const EdgelessNoteBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
NoteBlockService,
BlockViewExtension(flavour, literal`affine-edgeless-note`),
EdgelessNoteBlockAdapterExtensions,
NoteSlashMenuConfigExtension,
].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/block-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);
},
},
];

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../block-embed" },
{ "path": "../block-surface" },
{ "path": "../../components" },
{ "path": "../../fragments/fragment-doc-title" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../widgets/widget-slash-menu" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/inline" },
{ "path": "../../../framework/store" }
]
}