chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,174 @@
import {
getClosestBlockComponentByElement,
getRectByBlockComponent,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { type Point, Rect } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import type { EditingState } from '../types.js';
import { DropFlags, getDropRectByPoint } from './query.js';
/**
* A dropping type.
*/
export type DroppingType = 'none' | 'before' | 'after' | 'database';
export type DropResult = {
type: DroppingType;
rect: Rect;
modelState: EditingState;
};
/**
* Calculates the drop target.
*/
export function calcDropTarget(
point: Point,
model: BlockModel,
element: Element,
draggingElements: BlockComponent[] = [],
scale: number = 1,
flavour: string | null = null // for block-hub
): DropResult | null {
const schema = model.doc.getSchemaByFlavour('affine:database');
const children = schema?.model.children ?? [];
let shouldAppendToDatabase = true;
if (children.length) {
if (draggingElements.length) {
shouldAppendToDatabase = draggingElements
.map(el => el.model)
.every(m => children.includes(m.flavour));
} else if (flavour) {
shouldAppendToDatabase = children.includes(flavour);
}
}
if (!shouldAppendToDatabase && !matchFlavours(model, ['affine:database'])) {
const databaseBlockComponent = element.closest('affine-database');
if (databaseBlockComponent) {
element = databaseBlockComponent;
model = databaseBlockComponent.model;
}
}
let type: DroppingType = 'none';
const height = 3 * scale;
const { rect: domRect, flag } = getDropRectByPoint(point, model, element);
if (flag === DropFlags.EmptyDatabase) {
// empty database
const rect = Rect.fromDOMRect(domRect);
rect.top -= height / 2;
rect.height = height;
type = 'database';
return {
type,
rect,
modelState: {
model,
rect: domRect,
element: element as BlockComponent,
},
};
} else if (flag === DropFlags.Database) {
// not empty database
const distanceToTop = Math.abs(domRect.top - point.y);
const distanceToBottom = Math.abs(domRect.bottom - point.y);
const before = distanceToTop < distanceToBottom;
type = before ? 'before' : 'after';
return {
type,
rect: Rect.fromLWTH(
domRect.left,
domRect.width,
(before ? domRect.top - 1 : domRect.bottom) - height / 2,
height
),
modelState: {
model,
rect: domRect,
element: element as BlockComponent,
},
};
}
const distanceToTop = Math.abs(domRect.top - point.y);
const distanceToBottom = Math.abs(domRect.bottom - point.y);
const before = distanceToTop < distanceToBottom;
type = before ? 'before' : 'after';
let offsetY = 4;
if (type === 'before') {
// before
let prev;
let prevRect;
prev = element.previousElementSibling;
if (prev) {
if (
draggingElements.length &&
prev === draggingElements[draggingElements.length - 1]
) {
type = 'none';
} else {
prevRect = getRectByBlockComponent(prev);
}
} else {
prev = element.parentElement?.previousElementSibling;
if (prev) {
prevRect = prev.getBoundingClientRect();
}
}
if (prevRect) {
offsetY = (domRect.top - prevRect.bottom) / 2;
}
} else {
// after
let next;
let nextRect;
next = element.nextElementSibling;
if (next) {
if (draggingElements.length && next === draggingElements[0]) {
type = 'none';
next = null;
}
} else {
next = getClosestBlockComponentByElement(
element.parentElement
)?.nextElementSibling;
}
if (next) {
nextRect = getRectByBlockComponent(next);
offsetY = (nextRect.top - domRect.bottom) / 2;
}
}
if (type === 'none') return null;
let top = domRect.top;
if (type === 'before') {
top -= offsetY;
} else {
top += domRect.height + offsetY;
}
return {
type,
rect: Rect.fromLWTH(domRect.left, domRect.width, top - height / 2, height),
modelState: {
model,
rect: domRect,
element: element as BlockComponent,
},
};
}
@@ -0,0 +1,17 @@
// Compat with SSR
export * from '../types.js';
export * from './drag-and-drop.js';
export * from './query.js';
export {
createButtonPopper,
getBlockProps,
getImageFilesFromLocal,
isMiddleButtonPressed,
isRightButtonPressed,
isValidUrl,
matchFlavours,
on,
once,
openFileOrFiles,
requestThrottledConnectedFrame,
} from '@blocksuite/affine-shared/utils';
@@ -0,0 +1,211 @@
import {
getRectByBlockComponent,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import { BLOCK_ID_ATTR, type EditorHost } from '@blocksuite/block-std';
import type { Point } from '@blocksuite/global/utils';
import { assertExists } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import type { RootBlockComponent } from '../../index.js';
const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`;
/**
* This function is used to build model's "normal" block path.
* If this function does not meet your needs, you may need to build path manually to satisfy your needs.
* You should not modify this function.
*/
export function buildPath(model: BlockModel | null): string[] {
const path: string[] = [];
let current = model;
while (current) {
path.unshift(current.id);
current = current.doc.getParent(current);
}
return path;
}
export function getRootByEditorHost(
editorHost: EditorHost
): RootBlockComponent | null {
return (
getPageRootByEditorHost(editorHost) ??
getEdgelessRootByEditorHost(editorHost)
);
}
/** If it's not in the page mode, it will return `null` directly */
export function getPageRootByEditorHost(editorHost: EditorHost) {
return editorHost.querySelector('affine-page-root');
}
/** If it's not in the edgeless mode, it will return `null` directly */
export function getEdgelessRootByEditorHost(editorHost: EditorHost) {
return editorHost.querySelector('affine-edgeless-root');
}
/**
* Get block component by model.
* Note that this function is used for compatibility only, and may be removed in the future.
*
* @deprecated
*/
export function getBlockComponentByModel(
editorHost: EditorHost,
model: BlockModel | null
) {
if (!model) return null;
return editorHost.view.getBlock(model.id);
}
function isEdgelessChildNote({ classList }: Element) {
return classList.contains('note-background');
}
/**
* Get hovering note with given a point in edgeless mode.
*/
export function getHoveringNote(point: Point) {
return (
document.elementsFromPoint(point.x, point.y).find(isEdgelessChildNote) ||
null
);
}
/**
* Gets the table of the database.
*/
function getDatabaseBlockTableElement(element: Element) {
return element.querySelector('.affine-database-block-table');
}
/**
* Gets the column header of the database.
*/
function getDatabaseBlockColumnHeaderElement(element: Element) {
return element.querySelector('.affine-database-column-header');
}
/**
* Gets the rows of the database.
*/
function getDatabaseBlockRowsElement(element: Element) {
return element.querySelector('.affine-database-block-rows');
}
/**
* Returns a flag for the drop target.
*/
export enum DropFlags {
Normal,
Database,
EmptyDatabase,
}
/**
* Gets the drop rect by block and point.
*/
export function getDropRectByPoint(
point: Point,
model: BlockModel,
element: Element
): {
rect: DOMRect;
flag: DropFlags;
} {
const result = {
rect: getRectByBlockComponent(element),
flag: DropFlags.Normal,
};
const isDatabase = matchFlavours(model, ['affine:database']);
if (isDatabase) {
const table = getDatabaseBlockTableElement(element);
if (!table) {
return result;
}
let bounds = table.getBoundingClientRect();
if (model.isEmpty.value) {
result.flag = DropFlags.EmptyDatabase;
if (point.y < bounds.top) return result;
const header = getDatabaseBlockColumnHeaderElement(element);
assertExists(header);
bounds = header.getBoundingClientRect();
result.rect = new DOMRect(
result.rect.left,
bounds.bottom,
result.rect.width,
1
);
} else {
result.flag = DropFlags.Database;
const rows = getDatabaseBlockRowsElement(element);
assertExists(rows);
const rowsBounds = rows.getBoundingClientRect();
if (point.y < rowsBounds.top || point.y > rowsBounds.bottom)
return result;
const elements = document.elementsFromPoint(point.x, point.y);
const len = elements.length;
let e;
let i = 0;
for (; i < len; i++) {
e = elements[i];
if (e.classList.contains('affine-database-block-row-cell-content')) {
result.rect = getCellRect(e, bounds);
return result;
}
if (e.classList.contains('affine-database-block-row')) {
e = e.querySelector(ATTR_SELECTOR);
assertExists(e);
result.rect = getCellRect(e, bounds);
return result;
}
}
}
} else {
const parent = element.parentElement;
if (parent?.classList.contains('affine-database-block-row-cell-content')) {
result.flag = DropFlags.Database;
result.rect = getCellRect(parent);
return result;
}
}
return result;
}
function getCellRect(element: Element, bounds?: DOMRect) {
if (!bounds) {
const table = element.closest('.affine-database-block-table');
assertExists(table);
bounds = table.getBoundingClientRect();
}
// affine-database-block-row-cell
const col = element.parentElement;
assertExists(col);
// affine-database-block-row
const row = col.parentElement;
assertExists(row);
const colRect = col.getBoundingClientRect();
return new DOMRect(
bounds.left,
colRect.top,
colRect.right - bounds.left,
colRect.height
);
}
/**
* Return `true` if the element has class name in the class list.
*/
export function hasClassNameInList(element: Element, classList: string[]) {
return classList.some(className => element.classList.contains(className));
}
@@ -0,0 +1,246 @@
import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import {
DocModeProvider,
NotificationProvider,
} from '@blocksuite/affine-shared/services';
import { getBlockProps, matchFlavours } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import {
type BlockModel,
type BlockSnapshot,
type Doc,
type DraftModel,
Slice,
} from '@blocksuite/store';
import { GfxBlockModel } from '../../root-block/edgeless/block-model.js';
import {
getElementProps,
mapFrameIds,
sortEdgelessElements,
} from '../../root-block/edgeless/utils/clone-utils.js';
import {
isFrameBlock,
isNoteBlock,
} from '../../root-block/edgeless/utils/query.js';
import { getSurfaceBlock } from '../../surface-ref-block/utils.js';
export function promptDocTitle(host: EditorHost, autofill?: string) {
const notification = host.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 getTitleFromSelectedModels(selectedModels: DraftModel[]) {
const firstBlock = selectedModels[0];
if (
matchFlavours(firstBlock, ['affine:paragraph']) &&
firstBlock.type.startsWith('h')
) {
return firstBlock.text.toString();
}
return undefined;
}
export function notifyDocCreated(host: EditorHost, doc: Doc) {
const notification = host.std.getOptional(NotificationProvider);
if (!notification) return;
const abortController = new AbortController();
const clear = () => {
doc.history.off('stack-item-added', addHandler);
doc.history.off('stack-item-popped', popHandler);
disposable.dispose();
};
const closeNotify = () => {
abortController.abort();
clear();
};
// edit or undo or switch doc, close notify toast
const addHandler = doc.history.on('stack-item-added', closeNotify);
const popHandler = doc.history.on('stack-item-popped', closeNotify);
const disposable = host.slots.unmounted.on(closeNotify);
notification.notify({
title: 'Linked doc created',
message: 'You can click undo to recovery block content',
accent: 'info',
duration: 10 * 1000,
action: {
label: 'Undo',
onClick: () => {
doc.undo();
clear();
},
},
abort: abortController.signal,
onClose: clear,
});
}
export function addBlocksToDoc(
targetDoc: Doc,
model: BlockModel,
parentId: string
) {
// Add current block to linked doc
const blockProps = getBlockProps(model);
const newModelId = targetDoc.addBlock(
model.flavour as BlockSuite.Flavour,
blockProps,
parentId
);
// Add children to linked doc, parent is the new model
const children = model.children;
if (children.length > 0) {
children.forEach(child => {
addBlocksToDoc(targetDoc, child, newModelId);
});
}
}
export async function convertSelectedBlocksToLinkedDoc(
std: BlockSuite.Std,
doc: Doc,
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];
assertExists(firstBlock);
// 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));
return linkedDoc;
}
export function createLinkedDocFromSlice(
std: BlockSuite.Std,
doc: Doc,
snapshots: BlockSnapshot[],
docTitle?: string
) {
// const modelsWithChildren = (list:BlockModel[]):BlockModel[]=>list.flatMap(model=>[model,...modelsWithChildren(model.children)])
const linkedDoc = doc.collection.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new doc.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;
}
export function createLinkedDocFromNote(
doc: Doc,
note: NoteBlockModel,
docTitle?: string
) {
const linkedDoc = doc.collection.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new doc.Text(docTitle),
});
linkedDoc.addBlock('affine:surface', {}, rootId);
const blockProps = getBlockProps(note);
// keep note props & show in both mode
const noteId = linkedDoc.addBlock(
'affine:note',
{
...blockProps,
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
rootId
);
// Add note to linked doc recursively
note.children.forEach(model => {
addBlocksToDoc(linkedDoc, model, noteId);
});
});
return linkedDoc;
}
export function createLinkedDocFromEdgelessElements(
host: EditorHost,
elements: BlockSuite.EdgelessModel[],
docTitle?: string
) {
const linkedDoc = host.doc.collection.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new host.doc.Text(docTitle),
});
const surfaceId = linkedDoc.addBlock('affine:surface', {}, rootId);
const surface = getSurfaceBlock(linkedDoc);
if (!surface) return;
const sortedElements = sortEdgelessElements(elements);
const ids = new Map<string, string>();
sortedElements.forEach(model => {
let newId = model.id;
if (model instanceof GfxBlockModel) {
const blockProps = getBlockProps(model);
if (isNoteBlock(model)) {
newId = linkedDoc.addBlock('affine:note', blockProps, rootId);
// Add note children to linked doc recursively
model.children.forEach(model => {
addBlocksToDoc(linkedDoc, model, newId);
});
} else {
if (isFrameBlock(model)) {
mapFrameIds(blockProps as unknown as FrameBlockModel, ids);
}
newId = linkedDoc.addBlock(
model.flavour as BlockSuite.Flavour,
blockProps,
surfaceId
);
}
} else {
const props = getElementProps(model, ids);
newId = surface.addElement(props);
}
ids.set(model.id, newId);
});
});
host.std.get(DocModeProvider).setPrimaryMode('edgeless', linkedDoc.id);
return linkedDoc;
}
@@ -0,0 +1,89 @@
import {
DarkLoadingIcon,
EmbedCardDarkBannerIcon,
EmbedCardDarkCubeIcon,
EmbedCardDarkHorizontalIcon,
EmbedCardDarkListIcon,
EmbedCardDarkVerticalIcon,
EmbedCardLightBannerIcon,
EmbedCardLightCubeIcon,
EmbedCardLightHorizontalIcon,
EmbedCardLightListIcon,
EmbedCardLightVerticalIcon,
LightLoadingIcon,
} from '@blocksuite/affine-components/icons';
import {
ColorScheme,
type DocMode,
DocModes,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
type EmbedCardIcons = {
LoadingIcon: TemplateResult<1>;
EmbedCardBannerIcon: TemplateResult<1>;
EmbedCardHorizontalIcon: TemplateResult<1>;
EmbedCardListIcon: TemplateResult<1>;
EmbedCardVerticalIcon: TemplateResult<1>;
EmbedCardCubeIcon: TemplateResult<1>;
};
export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons {
if (theme === ColorScheme.Light) {
return {
LoadingIcon: LightLoadingIcon,
EmbedCardBannerIcon: EmbedCardLightBannerIcon,
EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon,
EmbedCardListIcon: EmbedCardLightListIcon,
EmbedCardVerticalIcon: EmbedCardLightVerticalIcon,
EmbedCardCubeIcon: EmbedCardLightCubeIcon,
};
} else {
return {
LoadingIcon: DarkLoadingIcon,
EmbedCardBannerIcon: EmbedCardDarkBannerIcon,
EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon,
EmbedCardListIcon: EmbedCardDarkListIcon,
EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon,
EmbedCardCubeIcon: EmbedCardDarkCubeIcon,
};
}
}
export function extractSearchParams(link: string) {
try {
const url = new URL(link);
const mode = url.searchParams.get('mode') as DocMode | undefined;
if (mode && DocModes.includes(mode)) {
const params: ReferenceInfo['params'] = { mode: mode as DocMode };
const blockIds = url.searchParams
.get('blockIds')
?.trim()
.split(',')
.map(id => id.trim())
.filter(id => id.length);
const elementIds = url.searchParams
.get('elementIds')
?.trim()
.split(',')
.map(id => id.trim())
.filter(id => id.length);
if (blockIds?.length) {
params.blockIds = blockIds;
}
if (elementIds?.length) {
params.elementIds = elementIds;
}
return { params };
}
} catch (err) {
console.error(err);
}
return null;
}