mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 20:16:26 +08:00
04531508cb
Closes: BS-3393 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new "Embed Doc" block, enabling embedding and rendering of linked and synced documents within cards, including support for banners and note previews. - Added new toolbar and quick search options for inserting embedded linked and synced documents. - **Improvements** - Updated dependencies and internal references to support the new embed doc functionality across related blocks and components. - Enhanced support for edgeless environments with new clipboard and configuration options for embedded docs. - **Refactor** - Streamlined and reorganized embed-related code, moving linked and synced doc logic into a dedicated embed doc module. - Removed obsolete adapter and utility files to simplify maintenance. - **Chores** - Updated project and TypeScript configuration files to include the new embed doc module in builds and references. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
206 lines
5.3 KiB
TypeScript
206 lines
5.3 KiB
TypeScript
import { getSurfaceBlock } from '@blocksuite/affine-block-surface';
|
|
import {
|
|
type DocMode,
|
|
ListBlockModel,
|
|
NoteBlockModel,
|
|
NoteDisplayMode,
|
|
ParagraphBlockModel,
|
|
} from '@blocksuite/affine-model';
|
|
import { NotificationProvider } from '@blocksuite/affine-shared/services';
|
|
import { matchModels } from '@blocksuite/affine-shared/utils';
|
|
import type { BlockStdScope } from '@blocksuite/std';
|
|
import {
|
|
type BlockModel,
|
|
type BlockSnapshot,
|
|
type DraftModel,
|
|
Slice,
|
|
type Store,
|
|
Text,
|
|
} from '@blocksuite/store';
|
|
|
|
// Throttle delay for block updates to reduce unnecessary re-renders
|
|
// - Prevents rapid-fire updates when multiple blocks are updated in quick succession
|
|
// - Ensures UI remains responsive while maintaining performance
|
|
// - Small enough to feel instant to users, large enough to batch updates effectively
|
|
export const RENDER_CARD_THROTTLE_MS = 60;
|
|
|
|
function filterTextModel(model: BlockModel) {
|
|
if (matchModels(model, [ParagraphBlockModel, ListBlockModel])) {
|
|
return !!model.text?.toString().length;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function getNotesFromDoc(doc: Store) {
|
|
const notes = doc.root?.children.filter(
|
|
child =>
|
|
matchModels(child, [NoteBlockModel]) &&
|
|
child.props.displayMode !== NoteDisplayMode.EdgelessOnly
|
|
);
|
|
|
|
if (!notes || !notes.length) {
|
|
return null;
|
|
}
|
|
|
|
return notes;
|
|
}
|
|
|
|
export function isEmptyDoc(doc: Store | null, mode: DocMode) {
|
|
if (!doc) {
|
|
return true;
|
|
}
|
|
|
|
if (mode === 'page') {
|
|
const notes = getNotesFromDoc(doc);
|
|
if (!notes || !notes.length) {
|
|
return true;
|
|
}
|
|
return notes.every(note => isEmptyNote(note));
|
|
} else {
|
|
const surface = getSurfaceBlock(doc);
|
|
if (surface?.elementModels.length || doc.blockSize > 2) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function isEmptyNote(note: BlockModel) {
|
|
return note.children.every(block => {
|
|
return (
|
|
block.flavour === 'affine:paragraph' &&
|
|
(!block.text || block.text.length === 0)
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the document content with a max length.
|
|
*/
|
|
export function getDocContentWithMaxLength(doc: Store, maxlength = 500) {
|
|
const notes = getNotesFromDoc(doc);
|
|
if (!notes) return;
|
|
|
|
const noteChildren = notes.flatMap(note =>
|
|
note.children.filter(model => filterTextModel(model))
|
|
);
|
|
if (!noteChildren.length) return;
|
|
|
|
let count = 0;
|
|
let reached = false;
|
|
const texts = [];
|
|
|
|
for (const model of noteChildren) {
|
|
let t = model.text?.toString();
|
|
if (t?.length) {
|
|
const c: number = count + Math.max(0, texts.length - 1);
|
|
|
|
if (t.length + c > maxlength) {
|
|
t = t.substring(0, maxlength - c);
|
|
reached = true;
|
|
}
|
|
|
|
texts.push(t);
|
|
count += t.length;
|
|
|
|
if (reached) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return texts.join('\n');
|
|
}
|
|
|
|
export function getTitleFromSelectedModels(selectedModels: DraftModel[]) {
|
|
const firstBlock = selectedModels[0];
|
|
const isParagraph = (
|
|
model: DraftModel
|
|
): model is DraftModel<ParagraphBlockModel> =>
|
|
model.flavour === 'affine:paragraph';
|
|
if (isParagraph(firstBlock) && firstBlock.props.type.startsWith('h')) {
|
|
return firstBlock.props.text.toString();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function promptDocTitle(std: BlockStdScope, autofill?: string) {
|
|
const notification = std.getOptional(NotificationProvider);
|
|
if (!notification) return Promise.resolve(undefined);
|
|
|
|
return notification.prompt({
|
|
title: 'Create linked doc',
|
|
message: 'Enter a title for the new doc.',
|
|
placeholder: 'Untitled',
|
|
autofill,
|
|
confirmText: 'Confirm',
|
|
cancelText: 'Cancel',
|
|
});
|
|
}
|
|
|
|
export function notifyDocCreated(std: BlockStdScope) {
|
|
std.getOptional(NotificationProvider)?.notifyWithUndoAction({
|
|
title: 'Linked doc created',
|
|
message: 'You can click undo to recovery block content',
|
|
accent: 'info',
|
|
duration: 10 * 1000,
|
|
});
|
|
}
|
|
|
|
export async function convertSelectedBlocksToLinkedDoc(
|
|
std: BlockStdScope,
|
|
doc: Store,
|
|
selectedModels: DraftModel[] | Promise<DraftModel[]>,
|
|
docTitle?: string
|
|
) {
|
|
const models = await selectedModels;
|
|
const slice = std.clipboard.sliceToSnapshot(Slice.fromModels(doc, models));
|
|
if (!slice) {
|
|
return;
|
|
}
|
|
const firstBlock = models[0];
|
|
if (!firstBlock) {
|
|
return;
|
|
}
|
|
// if title undefined, use the first heading block content as doc title
|
|
const title = docTitle || getTitleFromSelectedModels(models);
|
|
const linkedDoc = createLinkedDocFromSlice(std, doc, slice.content, title);
|
|
// insert linked doc card
|
|
doc.addSiblingBlocks(
|
|
doc.getBlock(firstBlock.id)!.model,
|
|
[
|
|
{
|
|
flavour: 'affine:embed-linked-doc',
|
|
pageId: linkedDoc.id,
|
|
},
|
|
],
|
|
'before'
|
|
);
|
|
// delete selected elements
|
|
models.forEach(model => doc.deleteBlock(model.id));
|
|
return linkedDoc;
|
|
}
|
|
|
|
export function createLinkedDocFromSlice(
|
|
std: BlockStdScope,
|
|
doc: Store,
|
|
snapshots: BlockSnapshot[],
|
|
docTitle?: string
|
|
) {
|
|
const _doc = doc.workspace.createDoc();
|
|
const linkedDoc = _doc.getStore();
|
|
linkedDoc.load(() => {
|
|
const rootId = linkedDoc.addBlock('affine:page', {
|
|
title: new Text(docTitle),
|
|
});
|
|
linkedDoc.addBlock('affine:surface', {}, rootId);
|
|
const noteId = linkedDoc.addBlock('affine:note', {}, rootId);
|
|
snapshots.forEach(snapshot => {
|
|
std.clipboard
|
|
.pasteBlockSnapshot(snapshot, linkedDoc, noteId)
|
|
.catch(console.error);
|
|
});
|
|
});
|
|
return linkedDoc;
|
|
}
|