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

View File

@@ -0,0 +1,159 @@
import type { Disposable } from '@blocksuite/global/utils';
import {
autoPlacement,
autoUpdate,
computePosition,
offset,
type Rect,
shift,
size,
} from '@floating-ui/dom';
export function listenClickAway(
element: HTMLElement,
onClickAway: () => void
): Disposable {
const callback = (event: MouseEvent) => {
const inside = event.composedPath().includes(element);
if (!inside) {
onClickAway();
}
};
document.addEventListener('click', callback);
return {
dispose: () => {
document.removeEventListener('click', callback);
},
};
}
type Display = 'show' | 'hidden';
const ATTR_SHOW = 'data-show';
/**
* Using attribute 'data-show' to control popper visibility.
*
* ```css
* selector {
* display: none;
* }
* selector[data-show] {
* display: block;
* }
* ```
*/
export function createButtonPopper(
reference: HTMLElement,
popperElement: HTMLElement,
stateUpdated: (state: { display: Display }) => void = () => {
/** DEFAULT EMPTY FUNCTION */
},
{
mainAxis,
crossAxis,
rootBoundary,
ignoreShift,
}: {
mainAxis?: number;
crossAxis?: number;
rootBoundary?: Rect | (() => Rect | undefined);
ignoreShift?: boolean;
} = {}
) {
let display: Display = 'hidden';
let cleanup: (() => void) | void;
const originMaxHeight = window.getComputedStyle(popperElement).maxHeight;
function compute() {
const overflowOptions = {
rootBoundary:
typeof rootBoundary === 'function' ? rootBoundary() : rootBoundary,
};
computePosition(reference, popperElement, {
middleware: [
offset({
mainAxis: mainAxis ?? 14,
crossAxis: crossAxis ?? 0,
}),
autoPlacement({
allowedPlacements: ['top', 'bottom'],
...overflowOptions,
}),
shift(overflowOptions),
size({
...overflowOptions,
apply({ availableHeight }) {
popperElement.style.maxHeight = originMaxHeight
? `min(${originMaxHeight}, ${availableHeight}px)`
: `${availableHeight}px`;
},
}),
],
})
.then(({ x, y, middlewareData: data }) => {
if (!ignoreShift) {
x += data.shift?.x ?? 0;
y += data.shift?.y ?? 0;
}
Object.assign(popperElement.style, {
position: 'absolute',
zIndex: 1,
left: `${x}px`,
top: `${y}px`,
});
})
.catch(console.error);
}
const show = (force = false) => {
const displayed = display === 'show';
if (displayed && !force) return;
if (!displayed) {
popperElement.setAttribute(ATTR_SHOW, '');
display = 'show';
stateUpdated({ display });
}
cleanup?.();
cleanup = autoUpdate(reference, popperElement, compute, {
animationFrame: true,
});
};
const hide = () => {
if (display === 'hidden') return;
popperElement.removeAttribute(ATTR_SHOW);
display = 'hidden';
stateUpdated({ display });
cleanup?.();
};
const toggle = () => {
if (popperElement.hasAttribute(ATTR_SHOW)) {
hide();
} else {
show();
}
};
const clickAway = listenClickAway(reference, () => hide());
return {
get state() {
return display;
},
show,
hide,
toggle,
dispose: () => {
cleanup?.();
clickAway.dispose();
},
};
}

View File

@@ -0,0 +1 @@
export * from './paragraph.js';

View File

@@ -0,0 +1,57 @@
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import type { BlockModel } from '@blocksuite/store';
import { matchFlavours } from '../model/checker.js';
export function calculateCollapsedSiblings(
model: ParagraphBlockModel
): BlockModel[] {
const parent = model.parent;
if (!parent) return [];
const children = parent.children;
const index = children.indexOf(model);
if (index === -1) return [];
const collapsedEdgeIndex = children.findIndex((child, i) => {
if (
i > index &&
matchFlavours(child, ['affine:paragraph']) &&
child.type.startsWith('h')
) {
const modelLevel = parseInt(model.type.slice(1));
const childLevel = parseInt(child.type.slice(1));
return childLevel <= modelLevel;
}
return false;
});
let collapsedSiblings: BlockModel[];
if (collapsedEdgeIndex === -1) {
collapsedSiblings = children.slice(index + 1);
} else {
collapsedSiblings = children.slice(index + 1, collapsedEdgeIndex);
}
return collapsedSiblings;
}
export function getNearestHeadingBefore(
model: BlockModel
): ParagraphBlockModel | null {
const parent = model.parent;
if (!parent) return null;
const index = parent.children.indexOf(model);
if (index === -1) return null;
for (let i = index - 1; i >= 0; i--) {
const sibling = parent.children[i];
if (
matchFlavours(sibling, ['affine:paragraph']) &&
sibling.type.startsWith('h')
) {
return sibling;
}
}
return null;
}

View File

@@ -0,0 +1 @@
export * from './legacy.js';

View File

@@ -0,0 +1,175 @@
import type {
EmbedCardStyle,
EmbedSyncedDocModel,
} from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { assertExists, Bound } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import type { OnDragEndProps } from '../../services/index.js';
import { getBlockProps } from '../model/index.js';
function isEmbedSyncedDocBlock(
element: BlockModel | BlockSuite.EdgelessModel | null
): element is EmbedSyncedDocModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-synced-doc'
);
}
/**
* @deprecated
* This is a terrible hack to apply the drag preview,
* do not use it.
* We're migrating to a standard drag and drop API.
*/
export function convertDragPreviewDocToEdgeless({
blockComponent,
dragPreview,
cssSelector,
width,
height,
noteScale,
state,
}: OnDragEndProps & {
blockComponent: BlockComponent;
cssSelector: string;
width?: number;
height?: number;
style?: EmbedCardStyle;
}): boolean {
const edgelessRoot = blockComponent.closest('affine-edgeless-root');
if (!edgelessRoot) {
return false;
}
const previewEl = dragPreview.querySelector(cssSelector);
if (!previewEl) {
return false;
}
const rect = previewEl.getBoundingClientRect();
const border = 2;
const controller = blockComponent.std.get(GfxControllerIdentifier);
const { viewport } = controller;
const { left: viewportLeft, top: viewportTop } = viewport;
const currentViewBound = new Bound(
rect.x - viewportLeft,
rect.y - viewportTop,
rect.width + border / noteScale,
rect.height + border / noteScale
);
const currentModelBound = viewport.toModelBound(currentViewBound);
// Except for embed synced doc block
// The width and height of other card style should be fixed
const newBound = isEmbedSyncedDocBlock(blockComponent.model)
? new Bound(
currentModelBound.x,
currentModelBound.y,
(currentModelBound.w ?? width) * noteScale,
(currentModelBound.h ?? height) * noteScale
)
: new Bound(
currentModelBound.x,
currentModelBound.y,
(width ?? currentModelBound.w) * noteScale,
(height ?? currentModelBound.h) * noteScale
);
const blockModel = blockComponent.model;
const blockProps = getBlockProps(blockModel);
// @ts-expect-error TODO: fix after edgeless refactor
const blockId = edgelessRoot.service.addBlock(
blockComponent.flavour,
{
...blockProps,
xywh: newBound.serialize(),
},
// @ts-expect-error TODO: fix after edgeless refactor
edgelessRoot.surfaceBlockModel
);
// Embed synced doc block should extend the note scale
// @ts-expect-error TODO: fix after edgeless refactor
const newBlock = edgelessRoot.service.getElementById(blockId);
if (isEmbedSyncedDocBlock(newBlock)) {
// @ts-expect-error TODO: fix after edgeless refactor
edgelessRoot.service.updateElement(newBlock.id, {
scale: noteScale,
});
}
const doc = blockComponent.doc;
const host = blockComponent.host;
const altKey = state.raw.altKey;
if (!altKey) {
doc.deleteBlock(blockModel);
host.selection.setGroup('note', []);
}
// @ts-expect-error TODO: fix after edgeless refactor
edgelessRoot.service.selection.set({
elements: [blockId],
editing: false,
});
return true;
}
/**
* @deprecated
* This is a terrible hack to apply the drag preview,
* do not use it.
* We're migrating to a standard drag and drop API.
*/
export function convertDragPreviewEdgelessToDoc({
blockComponent,
dropBlockId,
dropType,
state,
style,
}: OnDragEndProps & {
blockComponent: BlockComponent;
style?: EmbedCardStyle;
}): boolean {
const doc = blockComponent.doc;
const host = blockComponent.host;
const targetBlock = doc.getBlockById(dropBlockId);
if (!targetBlock) return false;
const shouldInsertIn = dropType === 'in';
const parentBlock = shouldInsertIn ? targetBlock : doc.getParent(targetBlock);
assertExists(parentBlock);
const parentIndex = shouldInsertIn
? 0
: parentBlock.children.indexOf(targetBlock) +
(dropType === 'after' ? 1 : 0);
const blockModel = blockComponent.model;
// eslint-disable-next-line no-unused-vars
const { width, height, xywh, rotate, zIndex, ...blockProps } =
getBlockProps(blockModel);
if (style) {
blockProps.style = style;
}
doc.addBlock(
blockModel.flavour as never,
blockProps,
parentBlock,
parentIndex
);
const altKey = state.raw.altKey;
if (!altKey) {
doc.deleteBlock(blockModel);
host.selection.setGroup('gfx', []);
}
return true;
}

View File

@@ -0,0 +1,15 @@
import type { EditorHost } from '@blocksuite/block-std';
export function isInsidePageEditor(host: EditorHost) {
return Array.from(host.children).some(
v => v.tagName.toLowerCase() === 'affine-page-root'
);
}
export function isInsideEdgelessEditor(host: EditorHost) {
return Array.from(host.children).some(
v =>
v.tagName.toLowerCase() === 'affine-edgeless-root' ||
v.tagName.toLowerCase() === 'affine-edgeless-root-preview'
);
}

View File

@@ -0,0 +1,6 @@
export * from './checker.js';
export * from './point-to-block.js';
export * from './point-to-range.js';
export * from './query.js';
export * from './scroll-container.js';
export * from './viewport.js';

View File

@@ -0,0 +1,320 @@
import { BLOCK_ID_ATTR, type BlockComponent } from '@blocksuite/block-std';
import type { Point, Rect } from '@blocksuite/global/utils';
import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '../../consts/index.js';
import { clamp } from '../math.js';
import { matchFlavours } from '../model/checker.js';
const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`;
// margin-top: calc(var(--affine-paragraph-space) + 24px);
// h1.margin-top = 8px + 24px = 32px;
const MAX_SPACE = 32;
const STEPS = MAX_SPACE / 2 / 2;
/**
* Returns `16` if node is contained in the parent.
* Otherwise return `0`.
*/
function contains(parent: Element, node: Element) {
return (
parent.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_CONTAINED_BY
);
}
/**
* Returns `true` if element has `data-block-id` attribute.
*/
function hasBlockId(element: Element): element is BlockComponent {
return element.hasAttribute(BLOCK_ID_ATTR);
}
/**
* Returns `true` if element is default/edgeless page or note.
*/
function isRootOrNoteOrSurface(element: BlockComponent) {
return matchFlavours(element.model, [
'affine:page',
'affine:note',
// @ts-expect-error TODO: migrate surface model to @blocksuite/affine-model
'affine:surface',
]);
}
function isBlock(element: BlockComponent) {
return !isRootOrNoteOrSurface(element);
}
function isImage({ tagName }: Element) {
return tagName === 'AFFINE-IMAGE';
}
function isDatabase({ tagName }: Element) {
return tagName === 'AFFINE-DATABASE-TABLE' || tagName === 'AFFINE-DATABASE';
}
/**
* Returns the closest block element by a point in the rect.
*
* ```
* ############### block
* ||############# block
* ||||########### block
* |||| ...
* |||| y - 2 * n
* |||| ...
* ||||----------- cursor
* |||| ...
* |||| y + 2 * n
* |||| ...
* ||||########### block
* ||############# block
* ############### block
* ```
*/
export function getClosestBlockComponentByPoint(
point: Point,
state: {
rect?: Rect;
container?: Element;
snapToEdge?: {
x: boolean;
y: boolean;
};
} | null = null,
scale = 1
): BlockComponent | null {
const { y } = point;
let container;
let element = null;
let bounds = null;
let childBounds = null;
let diff = 0;
let n = 1;
if (state) {
const {
snapToEdge = {
x: true,
y: false,
},
} = state;
container = state.container;
const rect = state.rect || container?.getBoundingClientRect();
if (rect) {
if (snapToEdge.x) {
point.x = Math.min(
Math.max(point.x, rect.left) +
BLOCK_CHILDREN_CONTAINER_PADDING_LEFT * scale -
1,
rect.right - BLOCK_CHILDREN_CONTAINER_PADDING_LEFT * scale - 1
);
}
if (snapToEdge.y) {
// TODO handle scale
if (scale !== 1) {
console.warn('scale is not supported yet');
}
point.y = clamp(point.y, rect.top + 1, rect.bottom - 1);
}
}
}
// find block element
element = findBlockComponent(
document.elementsFromPoint(point.x, point.y),
container
);
// Horizontal direction: for nested structures
if (element) {
// Database
if (isDatabase(element)) {
bounds = element.getBoundingClientRect();
const rows = getDatabaseBlockRowsElement(element);
if (rows) {
childBounds = rows.getBoundingClientRect();
if (childBounds.height) {
if (point.y < childBounds.top || point.y > childBounds.bottom) {
return element as BlockComponent;
}
childBounds = null;
} else {
return element as BlockComponent;
}
}
} else {
// Indented paragraphs or list
bounds = getRectByBlockComponent(element);
childBounds = element
.querySelector('.affine-block-children-container')
?.firstElementChild?.getBoundingClientRect();
if (childBounds && childBounds.height) {
if (bounds.x < point.x && point.x <= childBounds.x) {
return element as BlockComponent;
}
childBounds = null;
} else {
return element as BlockComponent;
}
}
bounds = null;
element = null;
}
// Vertical direction
do {
point.y = y - n * 2;
if (n < 0) n--;
n *= -1;
// find block element
element = findBlockComponent(
document.elementsFromPoint(point.x, point.y),
container
);
if (element) {
bounds = getRectByBlockComponent(element);
diff = bounds.bottom - point.y;
if (diff >= 0 && diff <= STEPS * 2) {
return element as BlockComponent;
}
diff = point.y - bounds.top;
if (diff >= 0 && diff <= STEPS * 2) {
return element as BlockComponent;
}
bounds = null;
element = null;
}
} while (n <= STEPS);
return element;
}
/**
* Find the most close block on the given position
* @param container container which the blocks can be found inside
* @param point position
* @param selector selector to find the block
*/
export function findClosestBlockComponent(
container: BlockComponent,
point: Point,
selector: string
): BlockComponent | null {
const children = (
Array.from(container.querySelectorAll(selector)) as BlockComponent[]
)
.filter(child => child.host === container.host)
.filter(child => child !== container);
let lastDistance = Number.POSITIVE_INFINITY;
let lastChild = null;
if (!children.length) return null;
for (const child of children) {
const rect = child.getBoundingClientRect();
if (rect.height === 0 || point.y > rect.bottom || point.y < rect.top)
continue;
const distance =
Math.pow(point.y - (rect.y + rect.height / 2), 2) +
Math.pow(point.x - rect.x, 2);
if (distance <= lastDistance) {
lastDistance = distance;
lastChild = child;
} else {
return lastChild;
}
}
return lastChild;
}
/**
* Returns the closest block element by element that does not contain the page element and note element.
*/
export function getClosestBlockComponentByElement(
element: Element | null
): BlockComponent | null {
if (!element) return null;
if (hasBlockId(element) && isBlock(element)) {
return element;
}
const blockComponent = element.closest<BlockComponent>(ATTR_SELECTOR);
if (blockComponent && isBlock(blockComponent)) {
return blockComponent;
}
return null;
}
/**
* Returns rect of the block element.
*
* Compatible with Safari!
* https://github.com/toeverything/blocksuite/issues/902
* https://github.com/toeverything/blocksuite/pull/1121
*/
export function getRectByBlockComponent(element: Element | BlockComponent) {
if (isDatabase(element)) return element.getBoundingClientRect();
return (element.firstElementChild ?? element).getBoundingClientRect();
}
/**
* Returns block elements excluding their subtrees.
* Only keep block elements of same level.
*/
export function getBlockComponentsExcludeSubtrees(
elements: Element[] | BlockComponent[]
): BlockComponent[] {
if (elements.length <= 1) return elements as BlockComponent[];
let parent = elements[0];
return elements.filter((node, index) => {
if (index === 0) return true;
if (contains(parent, node)) {
return false;
} else {
parent = node;
return true;
}
}) as BlockComponent[];
}
/**
* Find block element from an `Element[]`.
* In Chrome/Safari, `document.elementsFromPoint` does not include `affine-image`.
*/
function findBlockComponent(elements: Element[], parent?: Element) {
const len = elements.length;
let element = null;
let i = 0;
while (i < len) {
element = elements[i];
i++;
// if parent does not contain element, it's ignored
if (parent && !contains(parent, element)) continue;
if (hasBlockId(element) && isBlock(element)) return element;
if (isImage(element)) {
const element = elements[i];
if (i < len && hasBlockId(element) && isBlock(element)) {
return elements[i];
}
return getClosestBlockComponentByElement(element);
}
}
return null;
}
/**
* Gets the rows of the database.
*/
function getDatabaseBlockRowsElement(element: Element) {
return element.querySelector('.affine-database-block-rows');
}

View File

@@ -0,0 +1,86 @@
import { IS_FIREFOX } from '@blocksuite/global/env';
declare global {
interface Document {
// firefox API
caretPositionFromPoint(
x: number,
y: number
): {
offsetNode: Node;
offset: number;
};
}
}
/**
* A wrapper for the browser's `caretPositionFromPoint` and `caretRangeFromPoint`,
* but adapted for different browsers.
*/
export function caretRangeFromPoint(
clientX: number,
clientY: number
): Range | null {
if (IS_FIREFOX) {
const caret = document.caretPositionFromPoint(clientX, clientY);
// TODO handle caret is covered by popup
const range = document.createRange();
range.setStart(caret.offsetNode, caret.offset);
return range;
}
const range = document.caretRangeFromPoint(clientX, clientY);
if (!range) {
return null;
}
// See https://github.com/toeverything/blocksuite/issues/1382
const rangeRects = range?.getClientRects();
if (
rangeRects &&
rangeRects.length === 2 &&
range.startOffset === range.endOffset &&
clientY < rangeRects[0].y + rangeRects[0].height
) {
const deltaX = (rangeRects[0].x | 0) - (rangeRects[1].x | 0);
if (deltaX > 0) {
range.setStart(range.startContainer, range.startOffset - 1);
range.setEnd(range.endContainer, range.endOffset - 1);
}
}
return range;
}
export function resetNativeSelection(range: Range | null) {
const selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
range && selection.addRange(range);
}
export function getCurrentNativeRange(selection = window.getSelection()) {
// When called on an <iframe> that is not displayed (e.g., where display: none is set) Firefox will return null
// See https://developer.mozilla.org/en-US/docs/Web/API/Window/getSelection for more details
if (!selection) {
console.error('Failed to get current range, selection is null');
return null;
}
if (selection.rangeCount === 0) {
return null;
}
if (selection.rangeCount > 1) {
console.warn('getCurrentNativeRange may be wrong, rangeCount > 1');
}
return selection.getRangeAt(0);
}
export function handleNativeRangeAtPoint(x: number, y: number) {
const range = caretRangeFromPoint(x, y);
const startContainer = range?.startContainer;
// click on rich text
if (startContainer instanceof Node) {
resetNativeSelection(range);
}
}

View File

@@ -0,0 +1,39 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import { BLOCK_ID_ATTR, type BlockComponent } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`;
export function getModelByElement<Model extends BlockModel>(
element: Element
): Model | null {
const closestBlock = element.closest<BlockComponent>(ATTR_SELECTOR);
if (!closestBlock) {
return null;
}
return closestBlock.model as Model;
}
export function getRootByElement(
element: Element
): BlockComponent<RootBlockModel> | null {
const pageRoot = getPageRootByElement(element);
if (pageRoot) return pageRoot;
const edgelessRoot = getEdgelessRootByElement(element);
if (edgelessRoot) return edgelessRoot;
return null;
}
export function getPageRootByElement(
element: Element
): BlockComponent<RootBlockModel> | null {
return element.closest('affine-page-root');
}
export function getEdgelessRootByElement(
element: Element
): BlockComponent<RootBlockModel> | null {
return element.closest('affine-edgeless-root');
}

View File

@@ -0,0 +1,12 @@
export const getScrollContainer = (ele: HTMLElement) => {
let container: HTMLElement | null = ele;
while (container && !isScrollable(container)) {
container = container.parentElement;
}
return container ?? document.body;
};
export const isScrollable = (ele: HTMLElement) => {
const value = window.getComputedStyle(ele).overflowY;
return value === 'scroll' || value === 'auto';
};

View File

@@ -0,0 +1,34 @@
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
import { isInsidePageEditor } from './checker.js';
/**
* Get editor viewport element.
* @example
* ```ts
* const viewportElement = getViewportElement(this.model.doc);
* if (!viewportElement) return;
* this._disposables.addFromEvent(viewportElement, 'scroll', () => {
* updatePosition();
* });
* ```
*/
export function getViewportElement(editorHost: EditorHost): HTMLElement | null {
if (!isInsidePageEditor(editorHost)) return null;
const doc = editorHost.doc;
if (!doc.root) {
console.error('Failed to get root doc');
return null;
}
const rootComponent = editorHost.view.getBlock(doc.root.id);
if (
!rootComponent ||
rootComponent.closest('affine-page-root') !== rootComponent
) {
console.error('Failed to get viewport element!');
return null;
}
return (rootComponent as BlockComponent & { viewportElement: HTMLElement })
.viewportElement;
}

View File

@@ -0,0 +1,196 @@
import { IS_IOS, IS_MAC } from '@blocksuite/global/env';
export function isTouchPadPinchEvent(e: WheelEvent) {
// two finger pinches on touch pad, ctrlKey is always true.
// https://bugs.chromium.org/p/chromium/issues/detail?id=397027
if (IS_IOS || IS_MAC) {
return e.ctrlKey || e.metaKey;
}
return e.ctrlKey;
}
// See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
export enum MOUSE_BUTTONS {
AUXILIARY = 4,
FIFTH = 16,
FORTH = 8,
NO_BUTTON = 0,
PRIMARY = 1,
SECONDARY = 2,
}
export enum MOUSE_BUTTON {
AUXILIARY = 1,
FIFTH = 4,
FORTH = 3,
MAIN = 0,
SECONDARY = 2,
}
export function isMiddleButtonPressed(e: MouseEvent) {
return (MOUSE_BUTTONS.AUXILIARY & e.buttons) === MOUSE_BUTTONS.AUXILIARY;
}
export function isRightButtonPressed(e: MouseEvent) {
return (MOUSE_BUTTONS.SECONDARY & e.buttons) === MOUSE_BUTTONS.SECONDARY;
}
export function stopPropagation(event: Event) {
event.stopPropagation();
}
export function isControlledKeyboardEvent(e: KeyboardEvent) {
return e.ctrlKey || e.metaKey || e.altKey;
}
export function on<
T extends HTMLElement,
K extends keyof M,
M = HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function on<T extends HTMLElement>(
element: T,
event: string,
handler: (ev: Event) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function on<T extends Document, K extends keyof M, M = DocumentEventMap>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function on<
T extends HTMLElement | Document,
K extends keyof HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
) {
const dispose = () => {
element.removeEventListener(
event as string,
handler as unknown as EventListenerObject,
options
);
};
element.addEventListener(
event as string,
handler as unknown as EventListenerObject,
options
);
return dispose;
}
export function once<
T extends HTMLElement,
K extends keyof M,
M = HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function once<T extends HTMLElement>(
element: T,
event: string,
handler: (ev: Event) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function once<
T extends Document,
K extends keyof M,
M = DocumentEventMap,
>(
element: T,
event: K,
handler: (ev: M[K]) => void,
options?: boolean | AddEventListenerOptions
): () => void;
export function once<
T extends HTMLElement,
K extends keyof HTMLElementEventMap,
>(
element: T,
event: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
) {
const onceHandler = (e: HTMLElementEventMap[K]) => {
dispose();
handler(e);
};
const dispose = () => {
element.removeEventListener(event, onceHandler, options);
};
element.addEventListener(event, onceHandler, options);
return dispose;
}
export function delayCallback(callback: () => void, delay: number = 0) {
const timeoutId = setTimeout(callback, delay);
return () => clearTimeout(timeoutId);
}
/**
* A wrapper around `requestAnimationFrame` that only calls the callback if the
* element is still connected to the DOM.
*/
export function requestConnectedFrame(
callback: () => void,
element?: HTMLElement
) {
return requestAnimationFrame(() => {
// If element is not provided, fallback to `requestAnimationFrame`
if (element === undefined) {
callback();
return;
}
// Only calls callback if element is still connected to the DOM
if (element.isConnected) callback();
});
}
/**
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
*/
export function requestThrottledConnectedFrame<
T extends (...args: unknown[]) => void,
>(func: T, element?: HTMLElement): T {
let raqId: number | undefined = undefined;
let latestArgs: unknown[] = [];
return ((...args: unknown[]) => {
latestArgs = args;
if (raqId) return;
raqId = requestConnectedFrame(() => {
raqId = undefined;
func(...latestArgs);
}, element);
}) as T;
}
export const captureEventTarget = (target: EventTarget | null) => {
const isElementOrNode = target instanceof Element || target instanceof Node;
return isElementOrNode
? target instanceof Element
? target
: target.parentElement
: null;
};

View File

@@ -0,0 +1,327 @@
// Polyfill for `showOpenFilePicker` API
// See https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wicg-file-system-access/index.d.ts
// See also https://caniuse.com/?search=showOpenFilePicker
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
interface OpenFilePickerOptions {
types?:
| {
description?: string | undefined;
accept: Record<string, string | string[]>;
}[]
| undefined;
excludeAcceptAllOption?: boolean | undefined;
multiple?: boolean | undefined;
}
declare global {
interface Window {
// Window API: showOpenFilePicker
showOpenFilePicker?: (
options?: OpenFilePickerOptions
) => Promise<FileSystemFileHandle[]>;
}
}
// See [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
const FileTypes: NonNullable<OpenFilePickerOptions['types']> = [
{
description: 'Images',
accept: {
'image/*': [
'.avif',
'.gif',
// '.ico',
'.jpeg',
'.jpg',
'.png',
'.tif',
'.tiff',
// '.svg',
'.webp',
],
},
},
{
description: 'Videos',
accept: {
'video/*': [
'.avi',
'.mp4',
'.mpeg',
'.ogg',
// '.ts',
'.webm',
'.3gp',
'.3g2',
],
},
},
{
description: 'Audios',
accept: {
'audio/*': [
'.aac',
'.mid',
'.midi',
'.mp3',
'.oga',
'.opus',
'.wav',
'.weba',
'.3gp',
'.3g2',
],
},
},
{
description: 'Markdown',
accept: {
'text/markdown': ['.md', '.markdown'],
},
},
{
description: 'Html',
accept: {
'text/html': ['.html', '.htm'],
},
},
{
description: 'Zip',
accept: {
'application/zip': ['.zip'],
},
},
{
description: 'MindMap',
accept: {
'text/xml': ['.mm', '.opml', '.xml'],
},
},
];
/**
* See https://web.dev/patterns/files/open-one-or-multiple-files/
*/
type AcceptTypes =
| 'Any'
| 'Images'
| 'Videos'
| 'Audios'
| 'Markdown'
| 'Html'
| 'Zip'
| 'MindMap';
export function openFileOrFiles(options?: {
acceptType?: AcceptTypes;
}): Promise<File | null>;
export function openFileOrFiles(options: {
acceptType?: AcceptTypes;
multiple: false;
}): Promise<File | null>;
export function openFileOrFiles(options: {
acceptType?: AcceptTypes;
multiple: true;
}): Promise<File[] | null>;
export async function openFileOrFiles({
acceptType = 'Any',
multiple = false,
} = {}) {
// Feature detection. The API needs to be supported
// and the app not run in an iframe.
const supportsFileSystemAccess =
'showOpenFilePicker' in window &&
(() => {
try {
return window.self === window.top;
} catch {
return false;
}
})();
// If the File System Access API is supported…
if (supportsFileSystemAccess && window.showOpenFilePicker) {
try {
const fileType = FileTypes.find(i => i.description === acceptType);
if (acceptType !== 'Any' && !fileType)
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Unexpected acceptType "${acceptType}"`
);
const pickerOpts = {
types: fileType ? [fileType] : undefined,
multiple,
} satisfies OpenFilePickerOptions;
// Show the file picker, optionally allowing multiple files.
const handles = await window.showOpenFilePicker(pickerOpts);
// Only one file is requested.
if (!multiple) {
// Add the `FileSystemFileHandle` as `.handle`.
const file = await handles[0].getFile();
// Add the `FileSystemFileHandle` as `.handle`.
// file.handle = handles[0];
return file;
} else {
const files = await Promise.all(
handles.map(async handle => {
const file = await handle.getFile();
// Add the `FileSystemFileHandle` as `.handle`.
// file.handle = handles[0];
return file;
})
);
return files;
}
} catch (err) {
console.error('Error opening file');
console.error(err);
return null;
}
}
// Fallback if the File System Access API is not supported.
return new Promise(resolve => {
// Append a new `<input type="file" multiple? />` and hide it.
const input = document.createElement('input');
input.classList.add('affine-upload-input');
input.style.display = 'none';
input.type = 'file';
if (multiple) {
input.multiple = true;
}
if (acceptType !== 'Any') {
// For example, `accept="image/*"` or `accept="video/*,audio/*"`.
input.accept = Object.keys(
FileTypes.find(i => i.description === acceptType)?.accept ?? ''
).join(',');
}
document.body.append(input);
// The `change` event fires when the user interacts with the dialog.
input.addEventListener('change', () => {
// Remove the `<input type="file" multiple? />` again from the DOM.
input.remove();
// If no files were selected, return.
if (!input.files) {
resolve(null);
return;
}
// Return all files or just one file.
if (multiple) {
resolve(Array.from(input.files));
return;
}
resolve(input.files[0]);
});
// The `cancel` event fires when the user cancels the dialog.
input.addEventListener('cancel', () => {
resolve(null);
});
// Show the picker.
if ('showPicker' in HTMLInputElement.prototype) {
input.showPicker();
} else {
input.click();
}
});
}
export async function getImageFilesFromLocal() {
const imageFiles = await openFileOrFiles({
acceptType: 'Images',
multiple: true,
});
if (!imageFiles) return [];
return imageFiles;
}
export function downloadBlob(blob: Blob, name: string) {
const dataURL = URL.createObjectURL(blob);
const tmpLink = document.createElement('a');
const event = new MouseEvent('click');
tmpLink.download = name;
tmpLink.href = dataURL;
tmpLink.dispatchEvent(event);
tmpLink.remove();
URL.revokeObjectURL(dataURL);
}
// Use lru strategy is a better choice, but it's just a temporary solution.
const MAX_TEMP_DATA_SIZE = 100;
/**
* TODO @Saul-Mirone use some other way to store the temp data
*
* @deprecated Waiting for migration
*/
const tempAttachmentMap = new Map<
string,
{
// name for the attachment
name: string;
}
>();
const tempImageMap = new Map<
string,
{
// This information comes from pictures.
// If the user switches between pictures and attachments,
// this information should be retained.
width: number | undefined;
height: number | undefined;
}
>();
/**
* Because the image block and attachment block have different props.
* We need to save some data temporarily when converting between them to ensure no data is lost.
*
* For example, before converting from an image block to an attachment block,
* we need to save the image's width and height.
*
* Similarly, when converting from an attachment block to an image block,
* we need to save the attachment's name.
*
* See also https://github.com/toeverything/blocksuite/pull/4583#pullrequestreview-1610662677
*
* @internal
*/
export function withTempBlobData() {
const saveAttachmentData = (sourceId: string, data: { name: string }) => {
if (tempAttachmentMap.size > MAX_TEMP_DATA_SIZE) {
console.warn(
'Clear the temp attachment data. It may cause filename loss when converting between image and attachment.'
);
tempAttachmentMap.clear();
}
tempAttachmentMap.set(sourceId, data);
};
const getAttachmentData = (blockId: string) => {
const data = tempAttachmentMap.get(blockId);
tempAttachmentMap.delete(blockId);
return data;
};
const saveImageData = (
sourceId: string,
data: { width: number | undefined; height: number | undefined }
) => {
if (tempImageMap.size > MAX_TEMP_DATA_SIZE) {
console.warn(
'Clear temp image data. It may cause image width and height loss when converting between image and attachment.'
);
tempImageMap.clear();
}
tempImageMap.set(sourceId, data);
};
const getImageData = (blockId: string) => {
const data = tempImageMap.get(blockId);
tempImageMap.delete(blockId);
return data;
};
return {
saveAttachmentData,
getAttachmentData,
saveImageData,
getImageData,
};
}

View File

@@ -0,0 +1,32 @@
// https://www.rfc-editor.org/rfc/rfc9110#name-field-names
export const getFilenameFromContentDisposition = (header_value: string) => {
header_value = header_value.trim();
const quote_indices = [];
const quote_map = Object.create(null);
for (let i = 0; i < header_value.length; i++) {
if (header_value[i] === '"' && header_value[i - 1] !== '\\') {
quote_indices.push(i);
}
}
let target_index = header_value.indexOf('filename=');
for (let i = 0; i < quote_indices.length; i += 2) {
const start = quote_indices[i];
const end = quote_indices[i + 1];
quote_map[start] = end;
if (start < target_index && target_index < end) {
target_index = header_value.indexOf('filename=', end);
}
}
if (target_index === -1) {
return undefined;
}
if (quote_map[target_index + 9] === undefined) {
const end_space = header_value.indexOf(' ', target_index);
return header_value.slice(
target_index + 9,
end_space === -1 ? header_value.length : end_space
);
}
return header_value.slice(target_index + 10, quote_map[target_index + 9]);
};

View File

@@ -0,0 +1,2 @@
export * from './filesys.js';
export * from './header-value-parser.js';

View File

@@ -0,0 +1,19 @@
export * from './button-popper.js';
export * from './collapsed/index.js';
export * from './dnd/index.js';
export * from './dom/index.js';
export * from './event.js';
export * from './file/index.js';
export * from './insert.js';
export * from './is-abort-error.js';
export * from './math.js';
export * from './model/index.js';
export * from './print-to-pdf.js';
export * from './reference.js';
export * from './reordering.js';
export * from './signal.js';
export * from './spec/index.js';
export * from './string.js';
export * from './title.js';
export * from './url.js';
export * from './zod-schema.js';

View File

@@ -0,0 +1,51 @@
export type InsertToPosition =
| 'end'
| 'start'
| {
id: string;
before: boolean;
};
export function insertPositionToIndex<
T extends {
id: string;
},
>(position: InsertToPosition, arr: T[]): number;
export function insertPositionToIndex<T>(
position: InsertToPosition,
arr: T[],
key: (value: T) => string
): number;
export function insertPositionToIndex<T>(
position: InsertToPosition,
arr: T[],
key: (value: T) => string = (value: any) => value.id
): number {
if (typeof position === 'object') {
const index = arr.findIndex(v => key(v) === position.id);
return index + (position.before ? 0 : 1);
}
if (position == null || position === 'start') {
return 0;
}
if (position === 'end') {
return arr.length;
}
return arr.findIndex(v => key(v) === position) + 1;
}
export const arrayMove = <T>(
arr: T[],
from: (t: T) => boolean,
to: (arr: T[]) => number
): T[] => {
const columnIndex = arr.findIndex(v => from(v));
if (columnIndex < 0) {
return arr;
}
const newArr = [...arr];
const [ele] = newArr.splice(columnIndex, 1);
const index = to(newArr);
newArr.splice(index, 0, ele);
return newArr;
};

View File

@@ -0,0 +1,3 @@
export function isAbortError(error: unknown) {
return error instanceof Error && error.name === 'AbortError';
}

View File

@@ -0,0 +1,51 @@
export function almostEqual(a: number, b: number, epsilon = 0.0001) {
return Math.abs(a - b) < epsilon;
}
export function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
export function rangeWrap(n: number, min: number, max: number) {
max = max - min;
n = (n - min + max) % max;
return min + (Number.isNaN(n) ? 0 : n);
}
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
*/
export function humanFileSize(bytes: number, si = true, dp = 1) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' bytes';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10 ** dp;
do {
bytes /= thresh;
++u;
} while (
Math.round(Math.abs(bytes) * r) / r >= thresh &&
u < units.length - 1
);
return bytes.toFixed(dp) + ' ' + units[u];
}

View File

@@ -0,0 +1,8 @@
import type { BlockModel } from '@blocksuite/store';
export function getBlockProps(model: BlockModel): Record<string, unknown> {
const keys = model.keys as (keyof typeof model)[];
const values = keys.map(key => model[key]);
const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
return blockProps;
}

View File

@@ -0,0 +1,29 @@
import type { BlockModel, Doc, DraftModel } from '@blocksuite/store';
import { minimatch } from 'minimatch';
export function matchFlavours<Key extends (keyof BlockSuite.BlockModels)[]>(
model: DraftModel | null,
expected: Key
): model is BlockSuite.BlockModels[Key[number]] {
return (
!!model &&
expected.some(key =>
minimatch(model.flavour as keyof BlockSuite.BlockModels, key)
)
);
}
export function isInsideBlockByFlavour(
doc: Doc,
block: BlockModel | string,
flavour: string
): boolean {
const parent = doc.getParent(block);
if (parent === null) {
return false;
}
if (flavour === parent.flavour) {
return true;
}
return isInsideBlockByFlavour(doc, parent, flavour);
}

View File

@@ -0,0 +1,27 @@
import type { DocCollection } from '@blocksuite/store';
export function createDefaultDoc(
collection: DocCollection,
options: { id?: string; title?: string } = {}
) {
const doc = collection.createDoc({ id: options.id });
doc.load();
const title = options.title ?? '';
const rootId = doc.addBlock('affine:page', {
title: new doc.Text(title),
});
collection.setDocMeta(doc.id, {
title,
});
// @ts-expect-error FIXME: will be fixed when surface model migrated to affine-model
doc.addBlock('affine:surface', {}, rootId);
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
// To make sure the content of new doc would not be clear
// By undo operation for the first time
doc.resetHistory();
return doc;
}

View File

@@ -0,0 +1,139 @@
import type { EditorHost } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import { DocModeProvider } from '../../services/doc-mode-service.js';
import { matchFlavours } from './checker.js';
/**
*
* @example
* ```md
* doc
* - note
* - paragraph <- 5
* - note <- 4 (will be skipped)
* - paragraph <- 3
* - child <- 2
* - child <- 1
* - paragraph <- when invoked here, the traverse order will be above
* ```
*
* NOTE: this method will just return blocks with `content` role
*/
export function getPrevContentBlock(
editorHost: EditorHost,
model: BlockModel
): BlockModel | null {
const getPrev = (model: BlockModel) => {
const parent = model.doc.getParent(model);
if (!parent) return null;
const index = parent.children.indexOf(model);
if (index > 0) {
let tmpIndex = index - 1;
let prev = parent.children[tmpIndex];
if (parent.role === 'root' && model.role === 'hub') {
while (prev && prev.flavour !== 'affine:note') {
prev = parent.children[tmpIndex];
tmpIndex--;
}
}
if (!prev) return null;
while (prev.children.length > 0) {
prev = prev.children[prev.children.length - 1];
}
return prev;
}
// in edgeless mode, limit search for the previous block within the same note
if (
editorHost.std.get(DocModeProvider).getEditorMode() === 'edgeless' &&
parent.role === 'hub'
) {
return null;
}
return parent;
};
const map: Record<string, true> = {};
const iterate: (model: BlockModel) => BlockModel | null = (
model: BlockModel
) => {
if (model.id in map) {
console.error(
"Can't get previous block! There's a loop in the block tree!"
);
return null;
}
map[model.id] = true;
const prev = getPrev(model);
if (prev) {
if (prev.role === 'content' && !matchFlavours(prev, ['affine:frame'])) {
return prev;
}
return iterate(prev);
}
return null;
};
return iterate(model);
}
/**
*
* @example
* ```md
* page
* - note
* - paragraph <- when invoked here, the traverse order will be following
* - child <- 1
* - sibling <- 2
* - note <- 3 (will be skipped)
* - paragraph <- 4
* ```
*
* NOTE: this method will skip the `affine:note` block
*/
export function getNextContentBlock(
editorHost: EditorHost,
model: BlockModel,
map: Record<string, true> = {}
): BlockModel | null {
if (model.id in map) {
console.error("Can't get next block! There's a loop in the block tree!");
return null;
}
map[model.id] = true;
const doc = model.doc;
if (model.children.length) {
return model.children[0];
}
let currentBlock: typeof model | null = model;
while (currentBlock) {
const nextSibling = doc.getNext(currentBlock);
if (nextSibling) {
// Assert nextSibling is not possible to be `affine:page`
if (nextSibling.role === 'hub') {
// in edgeless mode, limit search for the next block within the same note
if (
editorHost.std.get(DocModeProvider).getEditorMode() === 'edgeless'
) {
return null;
}
return getNextContentBlock(editorHost, nextSibling);
}
return nextSibling;
}
currentBlock = doc.getParent(currentBlock);
}
return null;
}

View File

@@ -0,0 +1,59 @@
import { type NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
import type { BlockModel, Doc } from '@blocksuite/store';
import { matchFlavours } from './checker.js';
export function findAncestorModel(
model: BlockModel,
match: (m: BlockModel) => boolean
) {
let curModel: BlockModel | null = model;
while (curModel) {
if (match(curModel)) {
return curModel;
}
curModel = curModel.parent;
}
return null;
}
/**
* Get block component by its model and wait for the doc element to finish updating.
*
*/
export async function asyncGetBlockComponent(
editorHost: EditorHost,
id: string
): Promise<BlockComponent | null> {
const rootBlockId = editorHost.doc.root?.id;
if (!rootBlockId) return null;
const rootComponent = editorHost.view.getBlock(rootBlockId);
if (!rootComponent) return null;
await rootComponent.updateComplete;
return editorHost.view.getBlock(id);
}
export function findNoteBlockModel(model: BlockModel) {
return findAncestorModel(model, m =>
matchFlavours(m, ['affine:note'])
) as NoteBlockModel | null;
}
export function getLastNoteBlock(doc: Doc) {
let note: NoteBlockModel | null = null;
if (!doc.root) return null;
const { children } = doc.root;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (
matchFlavours(child, ['affine:note']) &&
child.displayMode !== NoteDisplayMode.EdgelessOnly
) {
note = child as NoteBlockModel;
break;
}
}
return note;
}

View File

@@ -0,0 +1,6 @@
export * from './block-props.js';
export * from './checker.js';
export * from './doc.js';
export * from './get-content-block.js';
export * from './getter.js';
export * from './list.js';

View File

@@ -0,0 +1,103 @@
import type { ListBlockModel } from '@blocksuite/affine-model';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { BlockModel, Doc } from '@blocksuite/store';
import { matchFlavours } from './checker.js';
/**
* Pass in a list model, and this function will look forward to find continuous sibling numbered lists,
* typically used for updating list numbers. The result not contains the list passed in.
*/
export function getNextContinuousNumberedLists(
doc: Doc,
modelOrId: BlockModel | string
): ListBlockModel[] {
const model =
typeof modelOrId === 'string' ? doc.getBlock(modelOrId)?.model : modelOrId;
if (!model) return [];
const parent = doc.getParent(model);
if (!parent) return [];
const modelIndex = parent.children.indexOf(model);
if (modelIndex === -1) return [];
const firstNotNumberedListIndex = parent.children.findIndex(
(model, i) =>
i > modelIndex &&
(!matchFlavours(model, ['affine:list']) || model.type !== 'numbered')
);
const newContinuousLists = parent.children.slice(
modelIndex + 1,
firstNotNumberedListIndex === -1 ? undefined : firstNotNumberedListIndex
);
if (
!newContinuousLists.every(
model =>
matchFlavours(model, ['affine:list']) && model.type === 'numbered'
)
)
return [];
return newContinuousLists as ListBlockModel[];
}
export function toNumberedList(
std: BlockStdScope,
model: BlockModel,
order: number
) {
const { doc } = std;
if (!model.text) return;
const parent = doc.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
const prevSibling = doc.getPrev(model);
let realOrder = order;
// if there is a numbered list before, the order continues from the previous list
if (
prevSibling &&
matchFlavours(prevSibling, ['affine:list']) &&
prevSibling.type === 'numbered'
) {
doc.transact(() => {
if (!prevSibling.order) prevSibling.order = 1;
realOrder = prevSibling.order + 1;
});
}
// add a new list block and delete the current block
const newListId = doc.addBlock(
'affine:list',
{
type: 'numbered',
text: model.text.clone(),
order: realOrder,
},
parent,
index
);
const newList = doc.getBlock(newListId)?.model;
if (!newList) {
return;
}
doc.deleteBlock(model, {
deleteChildren: false,
bringChildrenTo: newList,
});
// if there is a numbered list following, correct their order to keep them continuous
const nextContinuousNumberedLists = getNextContinuousNumberedLists(
doc,
newList
);
let base = realOrder + 1;
nextContinuousNumberedLists.forEach(list => {
doc.transact(() => {
list.order = base;
});
base += 1;
});
return newList.id;
}

View File

@@ -0,0 +1,145 @@
export async function printToPdf(
rootElement: HTMLElement | null = document.querySelector(
'.affine-page-viewport'
),
options: {
/**
* Callback that is called when ready to print.
*/
beforeprint?: (iframe: HTMLIFrameElement) => Promise<void> | void;
/**
* Callback that is called after the print dialog is closed.
* Notice: in some browser this may be triggered immediately.
*/
afterprint?: () => Promise<void> | void;
} = {}
) {
return new Promise<void>((resolve, reject) => {
const iframe = document.createElement('iframe');
document.body.append(iframe);
iframe.style.display = 'none';
iframe.srcdoc = '<!DOCTYPE html>';
iframe.onload = async () => {
if (!iframe.contentWindow) {
reject(new Error('unable to print pdf'));
return;
}
if (!rootElement) {
reject(new Error('Root element not defined, unable to print pdf'));
return;
}
iframe.contentWindow.document
.write(`<!DOCTYPE html><html><head><style>@media print {
html, body {
height: initial !important;
overflow: initial !important;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
::-webkit-scrollbar {
display: none;
}
:root {
--affine-note-shadow-box: none !important;
--affine-note-shadow-sticker: none !important;
}
}</style></head><body></body></html>`);
// copy all styles to iframe
for (const element of document.styleSheets) {
try {
for (const cssRule of element.cssRules) {
const target = iframe.contentWindow.document.styleSheets[0];
target.insertRule(cssRule.cssText, target.cssRules.length);
}
} catch (e) {
if (element.href) {
console.warn(
'css cannot be applied when printing pdf, this may be because of CORS policy from its domain.',
element.href
);
} else {
reject(e);
}
}
}
// convert all canvas to image
const canvasImgObjectUrlMap = new Map<string, string>();
const allCanvas = rootElement.getElementsByTagName('canvas');
let canvasKey = 1;
for (const canvas of allCanvas) {
canvas.dataset['printToPdfCanvasKey'] = canvasKey.toString();
canvasKey++;
const canvasImgObjectUrl = await new Promise<Blob | null>(resolve => {
try {
canvas.toBlob(resolve);
} catch {
resolve(null);
}
});
if (!canvasImgObjectUrl) {
console.warn(
'canvas cannot be converted to image when printing pdf, this may be because of CORS policy'
);
continue;
}
canvasImgObjectUrlMap.set(
canvas.dataset['printToPdfCanvasKey'],
URL.createObjectURL(canvasImgObjectUrl)
);
}
const importedRoot = iframe.contentWindow.document.importNode(
rootElement,
true
) as HTMLDivElement;
// draw saved canvas image to canvas
const allImportedCanvas = importedRoot.getElementsByTagName('canvas');
for (const importedCanvas of allImportedCanvas) {
const canvasKey = importedCanvas.dataset['printToPdfCanvasKey'];
if (canvasKey) {
const canvasImg = canvasImgObjectUrlMap.get(canvasKey);
const ctx = importedCanvas.getContext('2d');
if (canvasImg && ctx) {
const image = new Image();
image.src = canvasImg;
await image.decode();
ctx.drawImage(image, 0, 0, ctx.canvas.width, ctx.canvas.height);
}
}
}
// append to iframe and print
iframe.contentWindow.document.body.append(importedRoot);
await options.beforeprint?.(iframe);
// browser may take some time to load font
await new Promise<void>(resolve => {
setTimeout(() => {
resolve();
}, 1000);
});
iframe.contentWindow.onafterprint = async () => {
iframe.remove();
// clean up
for (const canvas of allCanvas) {
delete canvas.dataset['printToPdfCanvasKey'];
}
for (const [_, url] of canvasImgObjectUrlMap) {
URL.revokeObjectURL(url);
}
await options.afterprint?.();
resolve();
};
iframe.contentWindow.print();
};
});
}

View File

@@ -0,0 +1,44 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import cloneDeep from 'lodash.clonedeep';
/**
* Clones reference info.
*/
export function cloneReferenceInfo({
pageId,
params,
title,
description,
}: ReferenceInfo) {
const info: ReferenceInfo = { pageId };
if (params) info.params = cloneDeep(params);
if (title) info.title = title;
if (description) info.description = description;
return info;
}
/**
* Returns true if it is a link to block or element.
*/
export function referenceToNode({ params }: ReferenceInfo) {
if (!params) return false;
if (!params.mode) return false;
const { blockIds, elementIds, databaseId, databaseRowId } = params;
if (blockIds && blockIds.length > 0) return true;
if (elementIds && elementIds.length > 0) return true;
if (databaseId || databaseRowId) return true;
return false;
}
/**
* Clones reference info without the aliases.
* In `EmbedSyncedDocModel`, the aliases are not needed at the moment.
*/
export function cloneReferenceInfoWithoutAliases({
pageId,
params,
}: ReferenceInfo) {
const info: ReferenceInfo = { pageId };
if (params) info.params = cloneDeep(params);
return info;
}

View File

@@ -0,0 +1,6 @@
export type ReorderingType = 'front' | 'forward' | 'backward' | 'back';
export interface ReorderingAction<T> {
type: ReorderingType;
elements: T[];
}

View File

@@ -0,0 +1,25 @@
import { signal } from '@preact/signals-core';
interface Observable<T> {
subscribe(observer: (value: T) => void): Unsubscribable;
}
interface Unsubscribable {
unsubscribe(): void;
}
export function createSignalFromObservable<T>(
observable$: Observable<T>,
initValue: T
) {
const newSignal = signal(initValue);
const subscription = observable$.subscribe(value => {
newSignal.value = value;
});
return {
signal: newSignal,
cleanup: () => subscription.unsubscribe(),
};
}
export { type Signal } from '@preact/signals-core';

View File

@@ -0,0 +1,2 @@
export * from './spec-builder.js';
export * from './spec-provider.js';

View File

@@ -0,0 +1,17 @@
import type { ExtensionType } from '@blocksuite/block-std';
export class SpecBuilder {
private _value: ExtensionType[];
get value() {
return this._value;
}
constructor(spec: ExtensionType[]) {
this._value = [...spec];
}
extend(extensions: ExtensionType[]) {
this._value = [...this._value, ...extensions];
}
}

View File

@@ -0,0 +1,61 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import { SpecBuilder } from './spec-builder.js';
export class SpecProvider {
static instance: SpecProvider;
private specMap = new Map<string, ExtensionType[]>();
private constructor() {}
static getInstance() {
if (!SpecProvider.instance) {
SpecProvider.instance = new SpecProvider();
}
return SpecProvider.instance;
}
addSpec(id: string, spec: ExtensionType[]) {
if (!this.specMap.has(id)) {
this.specMap.set(id, spec);
}
}
clearSpec(id: string) {
this.specMap.delete(id);
}
extendSpec(id: string, newSpec: ExtensionType[]) {
const existingSpec = this.specMap.get(id);
if (!existingSpec) {
console.error(`Spec not found for ${id}`);
return;
}
this.specMap.set(id, [...existingSpec, ...newSpec]);
}
getSpec(id: string) {
const spec = this.specMap.get(id);
assertExists(spec, `Spec not found for ${id}`);
return new SpecBuilder(spec);
}
hasSpec(id: string) {
return this.specMap.has(id);
}
omitSpec(id: string, targetSpec: ExtensionType) {
const existingSpec = this.specMap.get(id);
if (!existingSpec) {
console.error(`Spec not found for ${id}`);
return;
}
this.specMap.set(
id,
existingSpec.filter(spec => spec !== targetSpec)
);
}
}

View File

@@ -0,0 +1,85 @@
function escapeRegExp(input: string) {
// escape regex characters in the input string to prevent regex format errors
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Checks if the name is a fuzzy match of the query.
*
* @example
* ```ts
* const name = 'John Smith';
* const query = 'js';
* const isMatch = isFuzzyMatch(name, query);
* // isMatch: true
* ```
*/
export function isFuzzyMatch(name: string, query: string) {
const pureName = name
.trim()
.toLowerCase()
.split('')
.filter(char => char !== ' ')
.join('');
const regex = new RegExp(
query
.split('')
.filter(char => char !== ' ')
.map(item => `${escapeRegExp(item)}.*`)
.join(''),
'i'
);
return regex.test(pureName);
}
/**
* Calculate the score of the substring match.
* s = [0.5, 1] if the query is a substring of the name
* s = (0, 0.5) if there exists a common non-maximal length substring
* s = 0 if there is no match
*
* s is greater if the query has a longer substring.
*/
export function substringMatchScore(name: string, query: string) {
if (query.length === 0) return 0;
if (name.length === 0) return 0;
if (query.length > name.length) return 0;
query = query.toLowerCase();
name = name.toLocaleLowerCase();
let score;
if (name.includes(query)) {
score = 1 + query.length / name.length;
} else {
let maxMatchLength = 0;
for (let i = 0; i < query.length; i++) {
for (let j = 0; j < name.length; j++) {
let matchLength = 0;
while (
i + matchLength < query.length &&
j + matchLength < name.length &&
query[i + matchLength] === name[j + matchLength]
) {
matchLength++;
}
maxMatchLength = Math.max(maxMatchLength, matchLength);
}
}
score = maxMatchLength / name.length;
}
// normalize
return 0.5 * score;
}
/**
* Checks if the prefix is a markdown prefix.
* Ex. 1. 2. 3. - * [] [ ] [x] # ## ### #### ##### ###### --- *** > ```
*/
export function isMarkdownPrefix(prefix: string) {
return !!prefix.match(
/^(\d+\.|-|\*|\[ ?\]|\[x\]|(#){1,6}|(-){3}|(\*){3}|>|```([a-zA-Z0-9]*))$/
);
}

View File

@@ -0,0 +1,32 @@
import type { EditorHost } from '@blocksuite/block-std';
import type { InlineEditor } from '@blocksuite/inline';
function getDocTitleByEditorHost(editorHost: EditorHost): HTMLElement | null {
const docViewport = editorHost.closest('.affine-page-viewport');
if (!docViewport) return null;
return docViewport.querySelector('doc-title');
}
export function getDocTitleInlineEditor(
editorHost: EditorHost
): InlineEditor | null {
const docTitle = getDocTitleByEditorHost(editorHost);
if (!docTitle) return null;
const titleRichText = docTitle.querySelector<
HTMLElement & { inlineEditor: InlineEditor }
>('rich-text');
if (!titleRichText || !titleRichText.inlineEditor) return null;
return titleRichText.inlineEditor;
}
export function focusTitle(editorHost: EditorHost, index = Infinity, len = 0) {
const titleInlineEditor = getDocTitleInlineEditor(editorHost);
if (!titleInlineEditor) {
return;
}
if (index > titleInlineEditor.yText.length) {
index = titleInlineEditor.yText.length;
}
titleInlineEditor.setInlineRange({ index, length: len });
}

View File

@@ -0,0 +1,154 @@
export const ALLOWED_SCHEMES = [
'http',
'https',
'ftp',
'sftp',
'mailto',
'tel',
// may need support other schemes
];
// I guess you don't want to use the regex base the RFC 5322 Official Standard
// For more detail see https://stackoverflow.com/questions/201323/how-can-i-validate-an-email-address-using-a-regular-expression/1917982#1917982
const MAIL_REGEX =
/^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
// For more detail see https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url
const URL_REGEX = new RegExp(
'^' +
// protocol identifier (optional)
// short syntax // still required
'(?:(?:(?:https?|ftp):)?\\/\\/)' +
// user:pass BasicAuth (optional)
'(?:\\S+(?::\\S*)?@)?' +
'(?:' +
// IP address exclusion
// private & local networks
'(?!(?:10|127)(?:\\.\\d{1,3}){3})' +
'(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' +
'(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broadcast addresses
// (first & last IP address of each class)
'(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' +
'(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' +
'(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' +
'|' +
// host & domain names, may end with dot
// can be replaced by a shortest alternative
// (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
'(?:' +
'(?:' +
'[a-z0-9\\u00a1-\\uffff]' +
'[a-z0-9\\u00a1-\\uffff_-]{0,62}' +
')?' +
'[a-z0-9\\u00a1-\\uffff]\\.' +
')+' +
// TLD identifier name, may end with dot
// Addition: We limit the TLD to 2-6 characters, because it can cover most of the cases.
'(?:[a-z\\u00a1-\\uffff]{2,6}\\.?)' +
')' +
// port number (optional)
'(?::\\d{2,5})?' +
// resource path (optional)
'(?:[/?#]\\S*)?' +
'$',
'i'
);
export function normalizeUrl(url: string) {
const includeScheme = ALLOWED_SCHEMES.find(scheme =>
url.startsWith(scheme + ':')
);
if (includeScheme) {
// Any link include schema is a valid url
return url;
}
const isEmail = MAIL_REGEX.test(url);
if (isEmail) {
return 'mailto:' + url;
}
return 'http://' + url;
}
/**
* Assume user will input a url, we just need to check if it is valid.
*
* For more detail see https://www.ietf.org/rfc/rfc1738.txt
*/
export function isValidUrl(str: string) {
if (!str) {
return false;
}
const url = normalizeUrl(str);
if (url === str) {
// Skip check if user input scheme manually
try {
new URL(url);
} catch {
return false;
}
return true;
}
return URL_REGEX.test(url);
}
// https://en.wikipedia.org/wiki/Top-level_domain
const COMMON_TLDS = new Set([
'com',
'org',
'net',
'edu',
'gov',
'co',
'io',
'me',
'moe',
'mil',
'top',
'dev',
'xyz',
'info',
'cat',
'ru',
'de',
'jp',
'uk',
'pro',
]);
function isCommonTLD(url: URL) {
const tld = url.hostname.split('.').pop();
if (!tld) {
return false;
}
return COMMON_TLDS.has(tld);
}
/**
* Assuming the user will input anything, we need to check rigorously.
*/
export function isStrictUrl(str: string) {
if (!isValidUrl(str)) {
return false;
}
const url = new URL(normalizeUrl(str));
if (isCommonTLD(url)) {
return true;
}
return false;
}
export function isUrlInClipboard(clipboardData: DataTransfer) {
const url = clipboardData.getData('text/plain');
return isValidUrl(url);
}
export function getHostName(url: string) {
try {
return new URL(url).hostname;
} catch {
return url;
}
}

View File

@@ -0,0 +1,277 @@
import {
ConnectorMode,
DEFAULT_CONNECTOR_COLOR,
DEFAULT_CONNECTOR_TEXT_COLOR,
DEFAULT_FRONT_END_POINT_STYLE,
DEFAULT_NOTE_BACKGROUND_COLOR,
DEFAULT_NOTE_BORDER_SIZE,
DEFAULT_NOTE_BORDER_STYLE,
DEFAULT_NOTE_CORNER,
DEFAULT_NOTE_SHADOW,
DEFAULT_REAR_END_POINT_STYLE,
DEFAULT_ROUGHNESS,
DEFAULT_SHAPE_FILL_COLOR,
DEFAULT_SHAPE_STROKE_COLOR,
DEFAULT_SHAPE_TEXT_COLOR,
DEFAULT_TEXT_COLOR,
FillColorsSchema,
FontFamily,
FontStyle,
FontWeight,
FrameBackgroundColorsSchema,
LayoutType,
LineColor,
LineColorsSchema,
LineWidth,
MindmapStyle,
NoteBackgroundColorsSchema,
NoteDisplayMode,
NoteShadowsSchema,
PointStyle,
ShapeStyle,
StrokeColorsSchema,
StrokeStyle,
TextAlign,
TextVerticalAlign,
} from '@blocksuite/affine-model';
import { z, ZodDefault, ZodObject, type ZodTypeAny, ZodUnion } from 'zod';
const ConnectorEndpointSchema = z.nativeEnum(PointStyle);
const StrokeStyleSchema = z.nativeEnum(StrokeStyle);
const LineWidthSchema = z.nativeEnum(LineWidth);
const ShapeStyleSchema = z.nativeEnum(ShapeStyle);
const FontFamilySchema = z.nativeEnum(FontFamily);
const FontWeightSchema = z.nativeEnum(FontWeight);
const FontStyleSchema = z.nativeEnum(FontStyle);
const TextAlignSchema = z.nativeEnum(TextAlign);
const TextVerticalAlignSchema = z.nativeEnum(TextVerticalAlign);
const NoteDisplayModeSchema = z.nativeEnum(NoteDisplayMode);
const ConnectorModeSchema = z.nativeEnum(ConnectorMode);
const LayoutTypeSchema = z.nativeEnum(LayoutType);
const MindmapStyleSchema = z.nativeEnum(MindmapStyle);
export const ColorSchema = z.union([
z.object({
normal: z.string(),
}),
z.object({
light: z.string(),
dark: z.string(),
}),
]);
const LineColorSchema = z.union([LineColorsSchema, ColorSchema]);
const ShapeFillColorSchema = z.union([FillColorsSchema, ColorSchema]);
const ShapeStrokeColorSchema = z.union([StrokeColorsSchema, ColorSchema]);
const TextColorSchema = z.union([LineColorsSchema, ColorSchema]);
const NoteBackgroundColorSchema = z.union([
NoteBackgroundColorsSchema,
ColorSchema,
]);
const FrameBackgroundColorSchema = z.union([
FrameBackgroundColorsSchema,
ColorSchema,
]);
export const ConnectorSchema = z
.object({
frontEndpointStyle: ConnectorEndpointSchema,
rearEndpointStyle: ConnectorEndpointSchema,
stroke: LineColorSchema,
strokeStyle: StrokeStyleSchema,
strokeWidth: LineWidthSchema,
rough: z.boolean(),
mode: ConnectorModeSchema,
labelStyle: z.object({
color: TextColorSchema,
fontSize: z.number(),
fontFamily: FontFamilySchema,
fontWeight: FontWeightSchema,
fontStyle: FontStyleSchema,
textAlign: TextAlignSchema,
}),
})
.default({
frontEndpointStyle: DEFAULT_FRONT_END_POINT_STYLE,
rearEndpointStyle: DEFAULT_REAR_END_POINT_STYLE,
stroke: DEFAULT_CONNECTOR_COLOR,
strokeStyle: StrokeStyle.Solid,
strokeWidth: LineWidth.Two,
rough: false,
mode: ConnectorMode.Curve,
labelStyle: {
color: DEFAULT_CONNECTOR_TEXT_COLOR,
fontSize: 16,
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Regular,
fontStyle: FontStyle.Normal,
textAlign: TextAlign.Center,
},
});
export const BrushSchema = z
.object({
color: LineColorSchema,
lineWidth: LineWidthSchema,
})
.default({
color: {
dark: LineColor.White,
light: LineColor.Black,
},
lineWidth: LineWidth.Four,
});
const DEFAULT_SHAPE = {
color: DEFAULT_SHAPE_TEXT_COLOR,
fillColor: DEFAULT_SHAPE_FILL_COLOR,
strokeColor: DEFAULT_SHAPE_STROKE_COLOR,
strokeStyle: StrokeStyle.Solid,
strokeWidth: LineWidth.Two,
shapeStyle: ShapeStyle.General,
filled: true,
radius: 0,
fontSize: 20,
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Regular,
fontStyle: FontStyle.Normal,
textAlign: TextAlign.Center,
roughness: DEFAULT_ROUGHNESS,
};
const ShapeObject = {
color: TextColorSchema,
fillColor: ShapeFillColorSchema,
strokeColor: ShapeStrokeColorSchema,
strokeStyle: StrokeStyleSchema,
strokeWidth: z.number(),
shapeStyle: ShapeStyleSchema,
filled: z.boolean(),
radius: z.number(),
fontSize: z.number(),
fontFamily: FontFamilySchema,
fontWeight: FontWeightSchema,
fontStyle: FontStyleSchema,
textAlign: TextAlignSchema,
textHorizontalAlign: TextAlignSchema.optional(),
textVerticalAlign: TextVerticalAlignSchema.optional(),
roughness: z.number(),
};
export const ShapeSchema = z.object(ShapeObject).default(DEFAULT_SHAPE);
export const RoundedShapeSchema = z
.object(ShapeObject)
.default({ ...DEFAULT_SHAPE, radius: 0.1 });
export const TextSchema = z
.object({
color: TextColorSchema,
fontSize: z.number(),
fontFamily: FontFamilySchema,
fontWeight: FontWeightSchema,
fontStyle: FontStyleSchema,
textAlign: TextAlignSchema,
})
.default({
color: DEFAULT_TEXT_COLOR,
fontSize: 24,
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Regular,
fontStyle: FontStyle.Normal,
textAlign: TextAlign.Left,
});
export const EdgelessTextSchema = z
.object({
color: TextColorSchema,
fontFamily: FontFamilySchema,
fontWeight: FontWeightSchema,
fontStyle: FontStyleSchema,
textAlign: TextAlignSchema,
})
.default({
color: DEFAULT_TEXT_COLOR,
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Regular,
fontStyle: FontStyle.Normal,
textAlign: TextAlign.Left,
});
export const NoteSchema = z
.object({
background: NoteBackgroundColorSchema,
displayMode: NoteDisplayModeSchema,
edgeless: z.object({
style: z.object({
borderRadius: z.number(),
borderSize: z.number(),
borderStyle: StrokeStyleSchema,
shadowType: NoteShadowsSchema,
}),
}),
})
.default({
background: DEFAULT_NOTE_BACKGROUND_COLOR,
displayMode: NoteDisplayMode.EdgelessOnly,
edgeless: {
style: {
borderRadius: DEFAULT_NOTE_CORNER,
borderSize: DEFAULT_NOTE_BORDER_SIZE,
borderStyle: DEFAULT_NOTE_BORDER_STYLE,
shadowType: DEFAULT_NOTE_SHADOW,
},
},
});
export const MindmapSchema = z
.object({
layoutType: LayoutTypeSchema,
style: MindmapStyleSchema,
})
.default({
layoutType: LayoutType.RIGHT,
style: MindmapStyle.ONE,
});
export const FrameSchema = z
.object({
background: FrameBackgroundColorSchema.optional(),
})
.default({});
export const NodePropsSchema = z.object({
connector: ConnectorSchema,
brush: BrushSchema,
text: TextSchema,
mindmap: MindmapSchema,
'affine:edgeless-text': EdgelessTextSchema,
'affine:note': NoteSchema,
'affine:frame': FrameSchema,
// shapes
'shape:diamond': ShapeSchema,
'shape:ellipse': ShapeSchema,
'shape:rect': ShapeSchema,
'shape:triangle': ShapeSchema,
'shape:roundedRect': RoundedShapeSchema,
});
export type NodeProps = z.infer<typeof NodePropsSchema>;
export function makeDeepOptional(schema: ZodTypeAny): ZodTypeAny {
if (schema instanceof ZodDefault) {
return makeDeepOptional(schema._def.innerType);
}
if (schema instanceof ZodObject) {
const shape = schema.shape;
const deepOptionalShape = Object.fromEntries(
Object.entries(shape).map(([key, value]) => {
return [key, makeDeepOptional(value as ZodTypeAny)];
})
);
return z.object(deepOptionalShape).optional();
} else if (schema instanceof ZodUnion) {
return schema.or(z.undefined());
} else {
return schema.optional();
}
}