mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
159
blocksuite/affine/shared/src/utils/button-popper.ts
Normal file
159
blocksuite/affine/shared/src/utils/button-popper.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
1
blocksuite/affine/shared/src/utils/collapsed/index.ts
Normal file
1
blocksuite/affine/shared/src/utils/collapsed/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './paragraph.js';
|
||||
57
blocksuite/affine/shared/src/utils/collapsed/paragraph.ts
Normal file
57
blocksuite/affine/shared/src/utils/collapsed/paragraph.ts
Normal 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;
|
||||
}
|
||||
1
blocksuite/affine/shared/src/utils/dnd/index.ts
Normal file
1
blocksuite/affine/shared/src/utils/dnd/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './legacy.js';
|
||||
175
blocksuite/affine/shared/src/utils/dnd/legacy.ts
Normal file
175
blocksuite/affine/shared/src/utils/dnd/legacy.ts
Normal 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;
|
||||
}
|
||||
15
blocksuite/affine/shared/src/utils/dom/checker.ts
Normal file
15
blocksuite/affine/shared/src/utils/dom/checker.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
6
blocksuite/affine/shared/src/utils/dom/index.ts
Normal file
6
blocksuite/affine/shared/src/utils/dom/index.ts
Normal 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';
|
||||
320
blocksuite/affine/shared/src/utils/dom/point-to-block.ts
Normal file
320
blocksuite/affine/shared/src/utils/dom/point-to-block.ts
Normal 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');
|
||||
}
|
||||
86
blocksuite/affine/shared/src/utils/dom/point-to-range.ts
Normal file
86
blocksuite/affine/shared/src/utils/dom/point-to-range.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
39
blocksuite/affine/shared/src/utils/dom/query.ts
Normal file
39
blocksuite/affine/shared/src/utils/dom/query.ts
Normal 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');
|
||||
}
|
||||
12
blocksuite/affine/shared/src/utils/dom/scroll-container.ts
Normal file
12
blocksuite/affine/shared/src/utils/dom/scroll-container.ts
Normal 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';
|
||||
};
|
||||
34
blocksuite/affine/shared/src/utils/dom/viewport.ts
Normal file
34
blocksuite/affine/shared/src/utils/dom/viewport.ts
Normal 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;
|
||||
}
|
||||
196
blocksuite/affine/shared/src/utils/event.ts
Normal file
196
blocksuite/affine/shared/src/utils/event.ts
Normal 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;
|
||||
};
|
||||
327
blocksuite/affine/shared/src/utils/file/filesys.ts
Normal file
327
blocksuite/affine/shared/src/utils/file/filesys.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
2
blocksuite/affine/shared/src/utils/file/index.ts
Normal file
2
blocksuite/affine/shared/src/utils/file/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './filesys.js';
|
||||
export * from './header-value-parser.js';
|
||||
19
blocksuite/affine/shared/src/utils/index.ts
Normal file
19
blocksuite/affine/shared/src/utils/index.ts
Normal 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';
|
||||
51
blocksuite/affine/shared/src/utils/insert.ts
Normal file
51
blocksuite/affine/shared/src/utils/insert.ts
Normal 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;
|
||||
};
|
||||
3
blocksuite/affine/shared/src/utils/is-abort-error.ts
Normal file
3
blocksuite/affine/shared/src/utils/is-abort-error.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isAbortError(error: unknown) {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
51
blocksuite/affine/shared/src/utils/math.ts
Normal file
51
blocksuite/affine/shared/src/utils/math.ts
Normal 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];
|
||||
}
|
||||
8
blocksuite/affine/shared/src/utils/model/block-props.ts
Normal file
8
blocksuite/affine/shared/src/utils/model/block-props.ts
Normal 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;
|
||||
}
|
||||
29
blocksuite/affine/shared/src/utils/model/checker.ts
Normal file
29
blocksuite/affine/shared/src/utils/model/checker.ts
Normal 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);
|
||||
}
|
||||
27
blocksuite/affine/shared/src/utils/model/doc.ts
Normal file
27
blocksuite/affine/shared/src/utils/model/doc.ts
Normal 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;
|
||||
}
|
||||
139
blocksuite/affine/shared/src/utils/model/get-content-block.ts
Normal file
139
blocksuite/affine/shared/src/utils/model/get-content-block.ts
Normal 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;
|
||||
}
|
||||
59
blocksuite/affine/shared/src/utils/model/getter.ts
Normal file
59
blocksuite/affine/shared/src/utils/model/getter.ts
Normal 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;
|
||||
}
|
||||
6
blocksuite/affine/shared/src/utils/model/index.ts
Normal file
6
blocksuite/affine/shared/src/utils/model/index.ts
Normal 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';
|
||||
103
blocksuite/affine/shared/src/utils/model/list.ts
Normal file
103
blocksuite/affine/shared/src/utils/model/list.ts
Normal 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;
|
||||
}
|
||||
145
blocksuite/affine/shared/src/utils/print-to-pdf.ts
Normal file
145
blocksuite/affine/shared/src/utils/print-to-pdf.ts
Normal 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();
|
||||
};
|
||||
});
|
||||
}
|
||||
44
blocksuite/affine/shared/src/utils/reference.ts
Normal file
44
blocksuite/affine/shared/src/utils/reference.ts
Normal 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;
|
||||
}
|
||||
6
blocksuite/affine/shared/src/utils/reordering.ts
Normal file
6
blocksuite/affine/shared/src/utils/reordering.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type ReorderingType = 'front' | 'forward' | 'backward' | 'back';
|
||||
|
||||
export interface ReorderingAction<T> {
|
||||
type: ReorderingType;
|
||||
elements: T[];
|
||||
}
|
||||
25
blocksuite/affine/shared/src/utils/signal.ts
Normal file
25
blocksuite/affine/shared/src/utils/signal.ts
Normal 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';
|
||||
2
blocksuite/affine/shared/src/utils/spec/index.ts
Normal file
2
blocksuite/affine/shared/src/utils/spec/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './spec-builder.js';
|
||||
export * from './spec-provider.js';
|
||||
17
blocksuite/affine/shared/src/utils/spec/spec-builder.ts
Normal file
17
blocksuite/affine/shared/src/utils/spec/spec-builder.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
61
blocksuite/affine/shared/src/utils/spec/spec-provider.ts
Normal file
61
blocksuite/affine/shared/src/utils/spec/spec-provider.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
85
blocksuite/affine/shared/src/utils/string.ts
Normal file
85
blocksuite/affine/shared/src/utils/string.ts
Normal 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]*))$/
|
||||
);
|
||||
}
|
||||
32
blocksuite/affine/shared/src/utils/title.ts
Normal file
32
blocksuite/affine/shared/src/utils/title.ts
Normal 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 });
|
||||
}
|
||||
154
blocksuite/affine/shared/src/utils/url.ts
Normal file
154
blocksuite/affine/shared/src/utils/url.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
277
blocksuite/affine/shared/src/utils/zod-schema.ts
Normal file
277
blocksuite/affine/shared/src/utils/zod-schema.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user