chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,101 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/block-std/gfx';
import { GfxCompatible } from '@blocksuite/block-std/gfx';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../utils/index.js';
import { AttachmentBlockTransformer } from './attachment-transformer.js';
/**
* When the attachment is uploading, the `sourceId` is `undefined`.
* And we can query the upload status by the `isAttachmentLoading` function.
*
* Other collaborators will see an error attachment block when the blob has not finished uploading.
* This issue can be resolve by sync the upload status through the awareness system in the future.
*
* When the attachment is uploaded, the `sourceId` is the id of the blob.
*
* If there are no `sourceId` and the `isAttachmentLoading` function returns `false`,
* it means that the attachment is failed to upload.
*/
/**
* @deprecated
*/
type BackwardCompatibleUndefined = undefined;
export const AttachmentBlockStyles: EmbedCardStyle[] = [
'cubeThick',
'horizontalThin',
'pdf',
] as const;
export type AttachmentBlockProps = {
name: string;
size: number;
/**
* MIME type
*/
type: string;
caption?: string;
// `loadingKey` was used to indicate whether the attachment is loading,
// which is currently unused but no breaking change is needed.
// The `loadingKey` and `sourceId` should not be existed at the same time.
// loadingKey?: string | null;
sourceId?: string;
/**
* Whether to show the attachment as an embed view.
*/
embed: boolean | BackwardCompatibleUndefined;
style?: (typeof AttachmentBlockStyles)[number];
} & Omit<GfxCommonBlockProps, 'scale'>;
export const defaultAttachmentProps: AttachmentBlockProps = {
name: '',
size: 0,
type: 'application/octet-stream',
sourceId: undefined,
caption: undefined,
embed: false,
style: AttachmentBlockStyles[1],
index: 'a0',
xywh: '[0,0,0,0]',
lockedBySelf: false,
rotate: 0,
};
export const AttachmentBlockSchema = defineBlockSchema({
flavour: 'affine:attachment',
props: (): AttachmentBlockProps => defaultAttachmentProps,
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:surface',
'affine:edgeless-text',
'affine:paragraph',
'affine:list',
],
},
transformer: () => new AttachmentBlockTransformer(),
toModel: () => new AttachmentBlockModel(),
});
export class AttachmentBlockModel
extends GfxCompatible<AttachmentBlockProps>(BlockModel)
implements GfxElementGeometry {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:attachment': AttachmentBlockModel;
}
interface BlockModels {
'affine:attachment': AttachmentBlockModel;
}
}
}
@@ -0,0 +1,17 @@
import type { FromSnapshotPayload, SnapshotNode } from '@blocksuite/store';
import { BaseBlockTransformer } from '@blocksuite/store';
import type { AttachmentBlockProps } from './attachment-model.js';
export class AttachmentBlockTransformer extends BaseBlockTransformer<AttachmentBlockProps> {
override async fromSnapshot(
payload: FromSnapshotPayload
): Promise<SnapshotNode<AttachmentBlockProps>> {
const snapshotRet = await super.fromSnapshot(payload);
const sourceId = snapshotRet.props.sourceId;
if (!payload.assets.isEmpty() && sourceId)
await payload.assets.writeToBlob(sourceId);
return snapshotRet;
}
}
@@ -0,0 +1,2 @@
export * from './attachment-model.js';
export * from './attachment-transformer.js';
@@ -0,0 +1,70 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/block-std/gfx';
import { GfxCompatible } from '@blocksuite/block-std/gfx';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
import type { EmbedCardStyle, LinkPreviewData } from '../../utils/index.js';
export const BookmarkStyles: EmbedCardStyle[] = [
'vertical',
'horizontal',
'list',
'cube',
] as const;
export type BookmarkBlockProps = {
style: (typeof BookmarkStyles)[number];
url: string;
caption: string | null;
} & LinkPreviewData &
Omit<GfxCommonBlockProps, 'scale'>;
const defaultBookmarkProps: BookmarkBlockProps = {
style: BookmarkStyles[1],
url: '',
caption: null,
description: null,
icon: null,
image: null,
title: null,
index: 'a0',
xywh: '[0,0,0,0]',
lockedBySelf: false,
rotate: 0,
};
export const BookmarkBlockSchema = defineBlockSchema({
flavour: 'affine:bookmark',
props: (): BookmarkBlockProps => defaultBookmarkProps,
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:surface',
'affine:edgeless-text',
'affine:paragraph',
'affine:list',
],
},
toModel: () => new BookmarkBlockModel(),
});
export class BookmarkBlockModel
extends GfxCompatible<BookmarkBlockProps>(BlockModel)
implements GfxElementGeometry {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:bookmark': BookmarkBlockModel;
}
interface BlockModels {
'affine:bookmark': BookmarkBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './bookmark-model.js';
@@ -0,0 +1,44 @@
import {
defineBlockSchema,
type SchemaToModel,
type Text,
} from '@blocksuite/store';
interface CodeBlockProps {
text: Text;
language: string | null;
wrap: boolean;
caption: string;
}
export const CodeBlockSchema = defineBlockSchema({
flavour: 'affine:code',
props: internal =>
({
text: internal.Text(),
language: null,
wrap: false,
caption: '',
}) as CodeBlockProps,
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
],
children: [],
},
});
export type CodeBlockModel = SchemaToModel<typeof CodeBlockSchema>;
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:code': CodeBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './code-model.js';
@@ -0,0 +1,30 @@
import type { Text } from '@blocksuite/store';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
import type { Column, SerializedCells, ViewBasicDataType } from './types.js';
export type DatabaseBlockProps = {
views: ViewBasicDataType[];
title: Text;
cells: SerializedCells;
columns: Array<Column>;
};
export class DatabaseBlockModel extends BlockModel<DatabaseBlockProps> {}
export const DatabaseBlockSchema = defineBlockSchema({
flavour: 'affine:database',
props: (internal): DatabaseBlockProps => ({
views: [],
title: internal.Text(),
cells: Object.create(null),
columns: [],
}),
metadata: {
role: 'hub',
version: 3,
parent: ['affine:note'],
children: ['affine:paragraph', 'affine:list'],
},
toModel: () => new DatabaseBlockModel(),
});
@@ -0,0 +1,2 @@
export * from './database-model.js';
export * from './types.js';
@@ -0,0 +1,21 @@
export interface Column<
Data extends Record<string, unknown> = Record<string, unknown>,
> {
id: string;
type: string;
name: string;
data: Data;
}
export type ColumnUpdater<T extends Column = Column> = (data: T) => Partial<T>;
export type Cell<ValueType = unknown> = {
columnId: Column['id'];
value: ValueType;
};
export type SerializedCells = Record<string, Record<string, Cell>>;
export type ViewBasicDataType = {
id: string;
name: string;
mode: string;
};
@@ -0,0 +1,20 @@
import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store';
export const DividerBlockSchema = defineBlockSchema({
flavour: 'affine:divider',
metadata: {
version: 1,
role: 'content',
children: [],
},
});
export type DividerBlockModel = SchemaToModel<typeof DividerBlockSchema>;
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:divider': DividerBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './divider-model.js';
@@ -0,0 +1,74 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/block-std/gfx';
import { GfxCompatible } from '@blocksuite/block-std/gfx';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
import {
FontFamily,
FontStyle,
FontWeight,
TextAlign,
type TextStyleProps,
} from '../../consts/index.js';
type EdgelessTextProps = {
hasMaxWidth: boolean;
} & Omit<TextStyleProps, 'fontSize'> &
GfxCommonBlockProps;
export const EdgelessTextBlockSchema = defineBlockSchema({
flavour: 'affine:edgeless-text',
props: (): EdgelessTextProps => ({
xywh: '[0,0,16,16]',
index: 'a0',
lockedBySelf: false,
color: '#000000',
fontFamily: FontFamily.Inter,
fontStyle: FontStyle.Normal,
fontWeight: FontWeight.Regular,
textAlign: TextAlign.Left,
scale: 1,
rotate: 0,
hasMaxWidth: false,
}),
metadata: {
version: 1,
role: 'hub',
parent: ['affine:surface'],
children: [
'affine:paragraph',
'affine:list',
'affine:code',
'affine:image',
'affine:bookmark',
'affine:attachment',
'affine:embed-!(synced-doc)',
'affine:latex',
],
},
toModel: () => {
return new EdgelessTextBlockModel();
},
});
export class EdgelessTextBlockModel
extends GfxCompatible<EdgelessTextProps>(BlockModel)
implements GfxElementGeometry {}
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:edgeless-text': EdgelessTextBlockModel;
}
interface EdgelessBlockModelMap {
'affine:edgeless-text': EdgelessTextBlockModel;
}
interface EdgelessTextModelMap {
'edgeless-text': EdgelessTextBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './edgeless-text-model.js';
@@ -0,0 +1,32 @@
import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export type EmbedFigmaBlockUrlData = {
title: string | null;
description: string | null;
};
export const EmbedFigmaStyles: EmbedCardStyle[] = ['figma'] as const;
export type EmbedFigmaBlockProps = {
style: (typeof EmbedFigmaStyles)[number];
url: string;
caption: string | null;
} & EmbedFigmaBlockUrlData;
export class EmbedFigmaModel extends defineEmbedModel<EmbedFigmaBlockProps>(
BlockModel
) {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:embed-figma': EmbedFigmaModel;
}
interface BlockModels {
'affine:embed-figma': EmbedFigmaModel;
}
}
}
@@ -0,0 +1,22 @@
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedFigmaBlockProps,
EmbedFigmaModel,
EmbedFigmaStyles,
} from './figma-model.js';
const defaultEmbedFigmaProps: EmbedFigmaBlockProps = {
style: EmbedFigmaStyles[0],
url: '',
caption: null,
title: null,
description: null,
};
export const EmbedFigmaBlockSchema = createEmbedBlockSchema({
name: 'figma',
version: 1,
toModel: () => new EmbedFigmaModel(),
props: (): EmbedFigmaBlockProps => defaultEmbedFigmaProps,
});
@@ -0,0 +1,2 @@
export * from './figma-model.js';
export * from './figma-schema.js';
@@ -0,0 +1,46 @@
import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export type EmbedGithubBlockUrlData = {
image: string | null;
status: string | null;
statusReason: string | null;
title: string | null;
description: string | null;
createdAt: string | null;
assignees: string[] | null;
};
export const EmbedGithubStyles: EmbedCardStyle[] = [
'vertical',
'horizontal',
'list',
'cube',
] as const;
export type EmbedGithubBlockProps = {
style: (typeof EmbedGithubStyles)[number];
owner: string;
repo: string;
githubType: 'issue' | 'pr';
githubId: string;
url: string;
caption: string | null;
} & EmbedGithubBlockUrlData;
export class EmbedGithubModel extends defineEmbedModel<EmbedGithubBlockProps>(
BlockModel
) {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:embed-github': EmbedGithubModel;
}
interface BlockModels {
'affine:embed-github': EmbedGithubModel;
}
}
}
@@ -0,0 +1,31 @@
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedGithubBlockProps,
EmbedGithubModel,
EmbedGithubStyles,
} from './github-model.js';
const defaultEmbedGithubProps: EmbedGithubBlockProps = {
style: EmbedGithubStyles[1],
owner: '',
repo: '',
githubType: 'issue',
githubId: '',
url: '',
caption: null,
image: null,
status: null,
statusReason: null,
title: null,
description: null,
createdAt: null,
assignees: null,
};
export const EmbedGithubBlockSchema = createEmbedBlockSchema({
name: 'github',
version: 1,
toModel: () => new EmbedGithubModel(),
props: (): EmbedGithubBlockProps => defaultEmbedGithubProps,
});
@@ -0,0 +1,2 @@
export * from './github-model.js';
export * from './github-schema.js';
@@ -0,0 +1,28 @@
import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedHtmlStyles: EmbedCardStyle[] = ['html'] as const;
export type EmbedHtmlBlockProps = {
style: (typeof EmbedHtmlStyles)[number];
caption: string | null;
html?: string;
design?: string;
};
export class EmbedHtmlModel extends defineEmbedModel<EmbedHtmlBlockProps>(
BlockModel
) {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:embed-html': EmbedHtmlModel;
}
interface BlockModels {
'affine:embed-html': EmbedHtmlModel;
}
}
}
@@ -0,0 +1,20 @@
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedHtmlBlockProps,
EmbedHtmlModel,
EmbedHtmlStyles,
} from './html-model.js';
const defaultEmbedHtmlProps: EmbedHtmlBlockProps = {
style: EmbedHtmlStyles[0],
caption: null,
html: undefined,
design: undefined,
};
export const EmbedHtmlBlockSchema = createEmbedBlockSchema({
name: 'html',
version: 1,
toModel: () => new EmbedHtmlModel(),
props: (): EmbedHtmlBlockProps => defaultEmbedHtmlProps,
});
@@ -0,0 +1,2 @@
export * from './html-model.js';
export * from './html-schema.js';
@@ -0,0 +1,7 @@
export * from './figma/index.js';
export * from './github/index.js';
export * from './html/index.js';
export * from './linked-doc/index.js';
export * from './loom/index.js';
export * from './synced-doc/index.js';
export * from './youtube/index.js';
@@ -0,0 +1,2 @@
export * from './linked-doc-model.js';
export * from './linked-doc-schema.js';
@@ -0,0 +1,33 @@
import { BlockModel } from '@blocksuite/store';
import type { ReferenceInfo } from '../../../consts/doc.js';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedLinkedDocStyles: EmbedCardStyle[] = [
'vertical',
'horizontal',
'list',
'cube',
'horizontalThin',
];
export type EmbedLinkedDocBlockProps = {
style: EmbedCardStyle;
caption: string | null;
} & ReferenceInfo;
export class EmbedLinkedDocModel extends defineEmbedModel<EmbedLinkedDocBlockProps>(
BlockModel
) {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:embed-linked-doc': EmbedLinkedDocModel;
}
interface BlockModels {
'affine:embed-linked-doc': EmbedLinkedDocModel;
}
}
}
@@ -0,0 +1,22 @@
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedLinkedDocBlockProps,
EmbedLinkedDocModel,
EmbedLinkedDocStyles,
} from './linked-doc-model.js';
const defaultEmbedLinkedDocBlockProps: EmbedLinkedDocBlockProps = {
pageId: '',
style: EmbedLinkedDocStyles[1],
caption: null,
// title & description aliases
title: undefined,
description: undefined,
};
export const EmbedLinkedDocBlockSchema = createEmbedBlockSchema({
name: 'linked-doc',
version: 1,
toModel: () => new EmbedLinkedDocModel(),
props: (): EmbedLinkedDocBlockProps => defaultEmbedLinkedDocBlockProps,
});
@@ -0,0 +1,2 @@
export * from './loom-model.js';
export * from './loom-schema.js';
@@ -0,0 +1,34 @@
import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export type EmbedLoomBlockUrlData = {
videoId: string | null;
image: string | null;
title: string | null;
description: string | null;
};
export const EmbedLoomStyles: EmbedCardStyle[] = ['video'] as const;
export type EmbedLoomBlockProps = {
style: (typeof EmbedLoomStyles)[number];
url: string;
caption: string | null;
} & EmbedLoomBlockUrlData;
export class EmbedLoomModel extends defineEmbedModel<EmbedLoomBlockProps>(
BlockModel
) {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:embed-loom': EmbedLoomModel;
}
interface BlockModels {
'affine:embed-loom': EmbedLoomModel;
}
}
}
@@ -0,0 +1,24 @@
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedLoomBlockProps,
EmbedLoomModel,
EmbedLoomStyles,
} from './loom-model.js';
const defaultEmbedLoomProps: EmbedLoomBlockProps = {
style: EmbedLoomStyles[0],
url: '',
caption: null,
image: null,
title: null,
description: null,
videoId: null,
};
export const EmbedLoomBlockSchema = createEmbedBlockSchema({
name: 'loom',
version: 1,
toModel: () => new EmbedLoomModel(),
props: (): EmbedLoomBlockProps => defaultEmbedLoomProps,
});
@@ -0,0 +1,2 @@
export * from './synced-doc-model.js';
export * from './synced-doc-schema.js';
@@ -0,0 +1,28 @@
import { BlockModel } from '@blocksuite/store';
import type { ReferenceInfo } from '../../../consts/doc.js';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedSyncedDocStyles: EmbedCardStyle[] = ['syncedDoc'];
export type EmbedSyncedDocBlockProps = {
style: EmbedCardStyle;
caption?: string | null;
scale?: number;
} & ReferenceInfo;
export class EmbedSyncedDocModel extends defineEmbedModel<EmbedSyncedDocBlockProps>(
BlockModel
) {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:embed-synced-doc': EmbedSyncedDocModel;
}
interface BlockModels {
'affine:embed-synced-doc': EmbedSyncedDocModel;
}
}
}
@@ -0,0 +1,23 @@
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedSyncedDocBlockProps,
EmbedSyncedDocModel,
EmbedSyncedDocStyles,
} from './synced-doc-model.js';
export const defaultEmbedSyncedDocBlockProps: EmbedSyncedDocBlockProps = {
pageId: '',
style: EmbedSyncedDocStyles[0],
caption: undefined,
scale: undefined,
// title & description aliases
title: undefined,
description: undefined,
};
export const EmbedSyncedDocBlockSchema = createEmbedBlockSchema({
name: 'synced-doc',
version: 1,
toModel: () => new EmbedSyncedDocModel(),
props: (): EmbedSyncedDocBlockProps => defaultEmbedSyncedDocBlockProps,
});
@@ -0,0 +1,2 @@
export * from './youtube-model.js';
export * from './youtube-schema.js';
@@ -0,0 +1,37 @@
import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export type EmbedYoutubeBlockUrlData = {
videoId: string | null;
image: string | null;
title: string | null;
description: string | null;
creator: string | null;
creatorUrl: string | null;
creatorImage: string | null;
};
export const EmbedYoutubeStyles: EmbedCardStyle[] = ['video'] as const;
export type EmbedYoutubeBlockProps = {
style: (typeof EmbedYoutubeStyles)[number];
url: string;
caption: string | null;
} & EmbedYoutubeBlockUrlData;
export class EmbedYoutubeModel extends defineEmbedModel<EmbedYoutubeBlockProps>(
BlockModel
) {}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:embed-youtube': EmbedYoutubeModel;
}
interface BlockModels {
'affine:embed-youtube': EmbedYoutubeModel;
}
}
}
@@ -0,0 +1,27 @@
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedYoutubeBlockProps,
EmbedYoutubeModel,
EmbedYoutubeStyles,
} from './youtube-model.js';
const defaultEmbedYoutubeProps: EmbedYoutubeBlockProps = {
style: EmbedYoutubeStyles[0],
url: '',
caption: null,
image: null,
title: null,
description: null,
creator: null,
creatorUrl: null,
creatorImage: null,
videoId: null,
};
export const EmbedYoutubeBlockSchema = createEmbedBlockSchema({
name: 'youtube',
version: 1,
toModel: () => new EmbedYoutubeModel(),
props: (): EmbedYoutubeBlockProps => defaultEmbedYoutubeProps,
});
@@ -0,0 +1,148 @@
import type {
GfxBlockElementModel,
GfxCompatibleProps,
GfxElementGeometry,
GfxGroupCompatibleInterface,
GfxModel,
PointTestOptions,
} from '@blocksuite/block-std/gfx';
import {
canSafeAddToContainer,
descendantElementsImpl,
generateKeyBetweenV2,
GfxCompatible,
gfxGroupCompatibleSymbol,
hasDescendantElementImpl,
} from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/utils';
import { BlockModel, defineBlockSchema, type Text } from '@blocksuite/store';
import type { Color } from '../../consts/index.js';
export type FrameBlockProps = {
title: Text;
background: Color;
childElementIds?: Record<string, boolean>;
presentationIndex?: string;
} & GfxCompatibleProps;
export const FrameBlockSchema = defineBlockSchema({
flavour: 'affine:frame',
props: (internal): FrameBlockProps => ({
title: internal.Text(),
background: '--affine-palette-transparent',
xywh: `[0,0,100,100]`,
index: 'a0',
childElementIds: Object.create(null),
presentationIndex: generateKeyBetweenV2(null, null),
lockedBySelf: false,
}),
metadata: {
version: 1,
role: 'content',
parent: ['affine:surface'],
children: [],
},
toModel: () => {
return new FrameBlockModel();
},
});
export class FrameBlockModel
extends GfxCompatible<FrameBlockProps>(BlockModel)
implements GfxElementGeometry, GfxGroupCompatibleInterface
{
[gfxGroupCompatibleSymbol] = true as const;
get childElements() {
if (!this.surface) return [];
const elements: GfxModel[] = [];
for (const key of this.childIds) {
const element =
this.surface.getElementById(key) ||
(this.surface.doc.getBlockById(key) as GfxBlockElementModel);
element && elements.push(element);
}
return elements;
}
get childIds() {
return this.childElementIds ? Object.keys(this.childElementIds) : [];
}
get descendantElements(): GfxModel[] {
return descendantElementsImpl(this);
}
addChild(element: GfxModel) {
if (!canSafeAddToContainer(this, element)) return;
this.doc.transact(() => {
this.childElementIds = { ...this.childElementIds, [element.id]: true };
});
}
addChildren(elements: GfxModel[]): void {
elements = [...new Set(elements)].filter(element =>
canSafeAddToContainer(this, element)
);
const newChildren: Record<string, boolean> = {};
for (const element of elements) {
const id = typeof element === 'string' ? element : element.id;
newChildren[id] = true;
}
this.doc.transact(() => {
this.childElementIds = {
...this.childElementIds,
...newChildren,
};
});
}
override containsBound(bound: Bound): boolean {
return this.elementBound.contains(bound);
}
hasChild(element: GfxModel): boolean {
return this.childElementIds ? element.id in this.childElementIds : false;
}
hasDescendant(element: GfxModel): boolean {
return hasDescendantElementImpl(this, element);
}
override includesPoint(x: number, y: number, _: PointTestOptions): boolean {
const bound = Bound.deserialize(this.xywh);
return bound.isPointInBound([x, y]);
}
override intersectsBound(selectedBound: Bound): boolean {
const bound = Bound.deserialize(this.xywh);
return (
bound.isIntersectWithBound(selectedBound) || selectedBound.contains(bound)
);
}
removeChild(element: GfxModel): void {
this.doc.transact(() => {
this.childElementIds && delete this.childElementIds[element.id];
});
}
}
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {
'affine:frame': FrameBlockModel;
}
interface BlockModels {
'affine:frame': FrameBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './frame-model.js';
@@ -0,0 +1,55 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/block-std/gfx';
import { GfxCompatible } from '@blocksuite/block-std/gfx';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
import { ImageBlockTransformer } from './image-transformer.js';
export type ImageBlockProps = {
caption?: string;
sourceId?: string;
width?: number;
height?: number;
rotate: number;
size?: number;
} & Omit<GfxCommonBlockProps, 'scale'>;
const defaultImageProps: ImageBlockProps = {
caption: '',
sourceId: '',
width: 0,
height: 0,
index: 'a0',
xywh: '[0,0,0,0]',
lockedBySelf: false,
rotate: 0,
size: -1,
};
export const ImageBlockSchema = defineBlockSchema({
flavour: 'affine:image',
props: () => defaultImageProps,
metadata: {
version: 1,
role: 'content',
},
transformer: () => new ImageBlockTransformer(),
toModel: () => new ImageBlockModel(),
});
export class ImageBlockModel
extends GfxCompatible<ImageBlockProps>(BlockModel)
implements GfxElementGeometry {}
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:image': ImageBlockModel;
}
interface EdgelessBlockModelMap {
'affine:image': ImageBlockModel;
}
}
}
@@ -0,0 +1,17 @@
import type { FromSnapshotPayload, SnapshotNode } from '@blocksuite/store';
import { BaseBlockTransformer } from '@blocksuite/store';
import type { ImageBlockProps } from './image-model.js';
export class ImageBlockTransformer extends BaseBlockTransformer<ImageBlockProps> {
override async fromSnapshot(
payload: FromSnapshotPayload
): Promise<SnapshotNode<ImageBlockProps>> {
const snapshotRet = await super.fromSnapshot(payload);
const sourceId = snapshotRet.props.sourceId;
if (!payload.assets.isEmpty() && sourceId && !sourceId.startsWith('/'))
await payload.assets.writeToBlob(sourceId);
return snapshotRet;
}
}
@@ -0,0 +1,2 @@
export * from './image-model.js';
export * from './image-transformer.js';
@@ -0,0 +1,15 @@
export * from './attachment/index.js';
export * from './bookmark/index.js';
export * from './code/index.js';
export * from './database/index.js';
export * from './divider/index.js';
export * from './edgeless-text/index.js';
export * from './embed/index.js';
export * from './frame/index.js';
export * from './image/index.js';
export * from './latex/index.js';
export * from './list/index.js';
export * from './note/index.js';
export * from './paragraph/index.js';
export * from './root/index.js';
export * from './surface-ref/index.js';
@@ -0,0 +1 @@
export * from './latex-model.js';
@@ -0,0 +1,51 @@
import {
type GfxCommonBlockProps,
GfxCompatible,
type GfxElementGeometry,
} from '@blocksuite/block-std/gfx';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
export type LatexProps = {
latex: string;
} & GfxCommonBlockProps;
export const LatexBlockSchema = defineBlockSchema({
flavour: 'affine:latex',
props: (): LatexProps => ({
xywh: '[0,0,16,16]',
index: 'a0',
lockedBySelf: false,
scale: 1,
rotate: 0,
latex: '',
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:edgeless-text',
'affine:paragraph',
'affine:list',
],
},
toModel: () => {
return new LatexBlockModel();
},
});
export class LatexBlockModel
extends GfxCompatible<LatexProps>(BlockModel)
implements GfxElementGeometry {}
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:latex': LatexBlockModel;
}
interface EdgelessBlockModelMap {
'affine:latex': LatexBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './list-model.js';
@@ -0,0 +1,48 @@
import type { SchemaToModel, Text } from '@blocksuite/store';
import { defineBlockSchema } from '@blocksuite/store';
// `toggle` type has been deprecated, do not use it
export type ListType = 'bulleted' | 'numbered' | 'todo' | 'toggle';
export interface ListProps {
type: ListType;
text: Text;
checked: boolean;
collapsed: boolean;
order: number | null;
}
export const ListBlockSchema = defineBlockSchema({
flavour: 'affine:list',
props: internal =>
({
type: 'bulleted',
text: internal.Text(),
checked: false,
collapsed: false,
// number type only for numbered list
order: null,
}) as ListProps,
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:list',
'affine:paragraph',
'affine:edgeless-text',
],
},
});
export type ListBlockModel = SchemaToModel<typeof ListBlockSchema>;
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:list': ListBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './note-model.js';
@@ -0,0 +1,126 @@
import type {
GfxCompatibleProps,
GfxElementGeometry,
} from '@blocksuite/block-std/gfx';
import { GfxCompatible } from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/utils';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
import {
type Color,
DEFAULT_NOTE_BACKGROUND_COLOR,
DEFAULT_NOTE_BORDER_SIZE,
DEFAULT_NOTE_BORDER_STYLE,
DEFAULT_NOTE_CORNER,
DEFAULT_NOTE_HEIGHT,
DEFAULT_NOTE_SHADOW,
DEFAULT_NOTE_WIDTH,
NoteDisplayMode,
type StrokeStyle,
} from '../../consts/index.js';
export const NoteBlockSchema = defineBlockSchema({
flavour: 'affine:note',
props: (): NoteProps => ({
xywh: `[0,0,${DEFAULT_NOTE_WIDTH},${DEFAULT_NOTE_HEIGHT}]`,
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
lockedBySelf: false,
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
edgeless: {
style: {
borderRadius: DEFAULT_NOTE_CORNER,
borderSize: DEFAULT_NOTE_BORDER_SIZE,
borderStyle: DEFAULT_NOTE_BORDER_STYLE,
shadowType: DEFAULT_NOTE_SHADOW,
},
},
}),
metadata: {
version: 1,
role: 'hub',
parent: ['affine:page'],
children: [
'affine:paragraph',
'affine:list',
'affine:code',
'affine:divider',
'affine:database',
'affine:data-view',
'affine:image',
'affine:bookmark',
'affine:attachment',
'affine:surface-ref',
'affine:embed-*',
'affine:latex',
],
},
toModel: () => {
return new NoteBlockModel();
},
});
export type NoteProps = {
background: Color;
displayMode: NoteDisplayMode;
edgeless: NoteEdgelessProps;
/**
* @deprecated
* use `displayMode` instead
* hidden:true -> displayMode:NoteDisplayMode.EdgelessOnly:
* means the note is visible only in the edgeless mode
* hidden:false -> displayMode:NoteDisplayMode.DocAndEdgeless:
* means the note is visible in the doc and edgeless mode
*/
hidden: boolean;
} & GfxCompatibleProps;
export type NoteEdgelessProps = {
style: {
borderRadius: number;
borderSize: number;
borderStyle: StrokeStyle;
shadowType: string;
};
collapse?: boolean;
collapsedHeight?: number;
scale?: number;
};
export class NoteBlockModel
extends GfxCompatible<NoteProps>(BlockModel)
implements GfxElementGeometry
{
private _isSelectable(): boolean {
return this.displayMode !== NoteDisplayMode.DocOnly;
}
override containsBound(bounds: Bound): boolean {
if (!this._isSelectable()) return false;
return super.containsBound(bounds);
}
override includesPoint(x: number, y: number): boolean {
if (!this._isSelectable()) return false;
const bound = Bound.deserialize(this.xywh);
return bound.isPointInBound([x, y], 0);
}
override intersectsBound(bound: Bound): boolean {
if (!this._isSelectable()) return false;
return super.intersectsBound(bound);
}
}
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:note': NoteBlockModel;
}
interface EdgelessBlockModelMap {
'affine:note': NoteBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './paragraph-model.js';
@@ -0,0 +1,52 @@
import { BlockModel, defineBlockSchema, type Text } from '@blocksuite/store';
export type ParagraphType =
| 'text'
| 'quote'
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6';
export type ParagraphProps = {
type: ParagraphType;
text: Text;
collapsed: boolean;
};
export const ParagraphBlockSchema = defineBlockSchema({
flavour: 'affine:paragraph',
props: (internal): ParagraphProps => ({
type: 'text',
text: internal.Text(),
collapsed: false,
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
],
},
toModel: () => new ParagraphBlockModel(),
});
export class ParagraphBlockModel extends BlockModel<ParagraphProps> {
override flavour!: 'affine:paragraph';
override text!: Text;
}
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:paragraph': ParagraphBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './root-block-model.js';
@@ -0,0 +1,45 @@
import type { Text } from '@blocksuite/store';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
export type RootBlockProps = {
title: Text;
};
export class RootBlockModel extends BlockModel<RootBlockProps> {
constructor() {
super();
this.created.once(() => {
this.doc.slots.rootAdded.on(id => {
const model = this.doc.getBlockById(id);
if (model instanceof RootBlockModel) {
const newDocMeta = this.doc.collection.meta.getDocMeta(model.doc.id);
if (!newDocMeta || newDocMeta.title !== model.title.toString()) {
this.doc.collection.setDocMeta(model.doc.id, {
title: model.title.toString(),
});
}
}
});
});
}
}
export const RootBlockSchema = defineBlockSchema({
flavour: 'affine:page',
props: (internal): RootBlockProps => ({
title: internal.Text(),
}),
metadata: {
version: 2,
role: 'root',
},
toModel: () => new RootBlockModel(),
});
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:page': RootBlockModel;
}
}
}
@@ -0,0 +1 @@
export * from './surface-ref-model.js';
@@ -0,0 +1,31 @@
import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store';
export type SurfaceRefProps = {
reference: string;
caption: string;
refFlavour: string;
};
export const SurfaceRefBlockSchema = defineBlockSchema({
flavour: 'affine:surface-ref',
props: () =>
({
reference: '',
caption: '',
}) as SurfaceRefProps,
metadata: {
version: 1,
role: 'content',
parent: ['affine:note', 'affine:paragraph', 'affine:list'],
},
});
export type SurfaceRefBlockModel = SchemaToModel<typeof SurfaceRefBlockSchema>;
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:surface-ref': SurfaceRefBlockModel;
}
}
}
@@ -0,0 +1,3 @@
import { LineColor } from './line.js';
export const DEFAULT_BRUSH_COLOR = LineColor.Blue;
@@ -0,0 +1,39 @@
import { createEnumMap } from '../utils/enum.js';
import { LineColor } from './line.js';
export enum ConnectorEndpoint {
Front = 'Front',
Rear = 'Rear',
}
export enum PointStyle {
Arrow = 'Arrow',
Circle = 'Circle',
Diamond = 'Diamond',
None = 'None',
Triangle = 'Triangle',
}
export const PointStyleMap = createEnumMap(PointStyle);
export const DEFAULT_CONNECTOR_COLOR = LineColor.Grey;
export const DEFAULT_CONNECTOR_TEXT_COLOR = LineColor.Black;
export const DEFAULT_FRONT_END_POINT_STYLE = PointStyle.None;
export const DEFAULT_REAR_END_POINT_STYLE = PointStyle.Arrow;
export const CONNECTOR_LABEL_MAX_WIDTH = 280;
export enum ConnectorLabelOffsetAnchor {
Bottom = 'bottom',
Center = 'center',
Top = 'top',
}
export enum ConnectorMode {
Straight,
Orthogonal,
Curve,
}
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
export type DocMode = 'edgeless' | 'page';
export const DocModes = ['edgeless', 'page'] as const;
/**
* Custom title and description information.
*
* Supports the following blocks:
*
* 1. Inline View: `AffineReference` - title
* 2. Card View: `EmbedLinkedDocBlock` - title & description
* 3. Embed View: `EmbedSyncedDocBlock` - title
*/
export const AliasInfoSchema = z
.object({
title: z.string(),
description: z.string(),
})
.partial();
export type AliasInfo = z.infer<typeof AliasInfoSchema>;
export const ReferenceParamsSchema = z
.object({
mode: z.enum(DocModes),
blockIds: z.string().array(),
elementIds: z.string().array(),
databaseId: z.string().optional(),
databaseRowId: z.string().optional(),
})
.partial();
export type ReferenceParams = z.infer<typeof ReferenceParamsSchema>;
export const ReferenceInfoSchema = z
.object({
pageId: z.string(),
params: ReferenceParamsSchema.optional(),
})
.merge(AliasInfoSchema);
export type ReferenceInfo = z.infer<typeof ReferenceInfoSchema>;
@@ -0,0 +1,27 @@
import { z } from 'zod';
export enum FrameBackgroundColor {
Blue = '--affine-tag-blue',
Gray = '--affine-tag-gray',
Green = '--affine-tag-green',
Orange = '--affine-tag-orange',
Pink = '--affine-tag-pink',
Purple = '--affine-tag-purple',
Red = '--affine-tag-red',
Teal = '--affine-tag-teal',
Yellow = '--affine-tag-yellow',
}
export const FRAME_BACKGROUND_COLORS = [
FrameBackgroundColor.Gray,
FrameBackgroundColor.Red,
FrameBackgroundColor.Orange,
FrameBackgroundColor.Yellow,
FrameBackgroundColor.Green,
FrameBackgroundColor.Teal,
FrameBackgroundColor.Blue,
FrameBackgroundColor.Purple,
FrameBackgroundColor.Pink,
];
export const FrameBackgroundColorsSchema = z.nativeEnum(FrameBackgroundColor);
@@ -0,0 +1,9 @@
export * from './brush.js';
export * from './connector.js';
export * from './doc.js';
export * from './frame.js';
export * from './line.js';
export * from './mindmap.js';
export * from './note.js';
export * from './shape.js';
export * from './text.js';
@@ -0,0 +1,46 @@
import { z } from 'zod';
import { createEnumMap } from '../utils/enum.js';
export enum LineWidth {
Eight = 8,
// Thin
Four = 4,
Six = 6,
// Thick
Ten = 10,
Twelve = 12,
Two = 2,
}
export enum LineColor {
Black = '--affine-palette-line-black',
Blue = '--affine-palette-line-blue',
Green = '--affine-palette-line-green',
Grey = '--affine-palette-line-grey',
Magenta = '--affine-palette-line-magenta',
Orange = '--affine-palette-line-orange',
Purple = '--affine-palette-line-purple',
Red = '--affine-palette-line-red',
Teal = '--affine-palette-line-teal',
White = '--affine-palette-line-white',
Yellow = '--affine-palette-line-yellow',
}
export const LineColorMap = createEnumMap(LineColor);
export const LINE_COLORS = [
LineColor.Yellow,
LineColor.Orange,
LineColor.Red,
LineColor.Magenta,
LineColor.Purple,
LineColor.Blue,
LineColor.Teal,
LineColor.Green,
LineColor.Black,
LineColor.Grey,
LineColor.White,
] as const;
export const LineColorsSchema = z.nativeEnum(LineColor);
@@ -0,0 +1,12 @@
export enum LayoutType {
BALANCE = 2,
LEFT = 1,
RIGHT = 0,
}
export enum MindmapStyle {
FOUR = 4,
ONE = 1,
THREE = 3,
TWO = 2,
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from 'zod';
import { createEnumMap } from '../utils/enum.js';
export const NOTE_MIN_WIDTH = 450 + 24 * 2;
export const NOTE_MIN_HEIGHT = 92;
export const DEFAULT_NOTE_WIDTH = NOTE_MIN_WIDTH;
export const DEFAULT_NOTE_HEIGHT = NOTE_MIN_HEIGHT;
export enum NoteBackgroundColor {
Black = '--affine-note-background-black',
Blue = '--affine-note-background-blue',
Green = '--affine-note-background-green',
Grey = '--affine-note-background-grey',
Magenta = '--affine-note-background-magenta',
Orange = '--affine-note-background-orange',
Purple = '--affine-note-background-purple',
Red = '--affine-note-background-red',
Teal = '--affine-note-background-teal',
White = '--affine-note-background-white',
Yellow = '--affine-note-background-yellow',
}
export const NoteBackgroundColorMap = createEnumMap(NoteBackgroundColor);
export const NOTE_BACKGROUND_COLORS = [
NoteBackgroundColor.Yellow,
NoteBackgroundColor.Orange,
NoteBackgroundColor.Red,
NoteBackgroundColor.Magenta,
NoteBackgroundColor.Purple,
NoteBackgroundColor.Blue,
NoteBackgroundColor.Teal,
NoteBackgroundColor.Green,
NoteBackgroundColor.Black,
NoteBackgroundColor.Grey,
NoteBackgroundColor.White,
] as const;
export const DEFAULT_NOTE_BACKGROUND_COLOR = NoteBackgroundColor.White;
export const NoteBackgroundColorsSchema = z.nativeEnum(NoteBackgroundColor);
export enum NoteShadow {
Box = '--affine-note-shadow-box',
Film = '--affine-note-shadow-film',
Float = '--affine-note-shadow-float',
None = '',
Paper = '--affine-note-shadow-paper',
Sticker = '--affine-note-shadow-sticker',
}
export const NoteShadowMap = createEnumMap(NoteShadow);
export const NOTE_SHADOWS = [
NoteShadow.None,
NoteShadow.Box,
NoteShadow.Sticker,
NoteShadow.Paper,
NoteShadow.Float,
NoteShadow.Film,
] as const;
export const DEFAULT_NOTE_SHADOW = NoteShadow.Box;
export const NoteShadowsSchema = z.nativeEnum(NoteShadow);
export enum NoteDisplayMode {
DocAndEdgeless = 'both',
DocOnly = 'doc',
EdgelessOnly = 'edgeless',
}
export enum StrokeStyle {
Dash = 'dash',
None = 'none',
Solid = 'solid',
}
export const DEFAULT_NOTE_BORDER_STYLE = StrokeStyle.None;
export const StrokeStyleMap = createEnumMap(StrokeStyle);
export enum NoteCorners {
Huge = 32,
Large = 24,
Medium = 16,
None = 0,
Small = 8,
}
export const NoteCornersMap = createEnumMap(NoteCorners);
export const NOTE_CORNERS = [
NoteCorners.None,
NoteCorners.Small,
NoteCorners.Medium,
NoteCorners.Large,
NoteCorners.Huge,
] as const;
export const DEFAULT_NOTE_CORNER = NoteCorners.Small;
export const NoteCornersSchema = z.nativeEnum(NoteCorners);
export const DEFAULT_NOTE_BORDER_SIZE = 4;
@@ -0,0 +1,90 @@
import { z } from 'zod';
import { LINE_COLORS, LineColor } from './line.js';
export const DEFAULT_ROUGHNESS = 1.4;
// TODO: need to check the default central area ratio
export const DEFAULT_CENTRAL_AREA_RATIO = 0.3;
export enum ShapeTextFontSize {
LARGE = 28,
MEDIUM = 20,
SMALL = 12,
XLARGE = 36,
}
export enum ShapeType {
Diamond = 'diamond',
Ellipse = 'ellipse',
Rect = 'rect',
Triangle = 'triangle',
}
export type ShapeName = ShapeType | 'roundedRect';
export function getShapeName(type: ShapeType, radius: number): ShapeName {
if (type === ShapeType.Rect && radius > 0) {
return 'roundedRect';
}
return type;
}
export function getShapeType(name: ShapeName): ShapeType {
if (name === 'roundedRect') {
return ShapeType.Rect;
}
return name;
}
export function getShapeRadius(name: ShapeName): number {
if (name === 'roundedRect') {
return 0.1;
}
return 0;
}
export enum ShapeStyle {
General = 'General',
Scribbled = 'Scribbled',
}
export enum ShapeFillColor {
Black = '--affine-palette-shape-black',
Blue = '--affine-palette-shape-blue',
Green = '--affine-palette-shape-green',
Grey = '--affine-palette-shape-grey',
Magenta = '--affine-palette-shape-magenta',
Orange = '--affine-palette-shape-orange',
Purple = '--affine-palette-shape-purple',
Red = '--affine-palette-shape-red',
Teal = '--affine-palette-shape-teal',
White = '--affine-palette-shape-white',
Yellow = '--affine-palette-shape-yellow',
}
export const SHAPE_FILL_COLORS = [
ShapeFillColor.Yellow,
ShapeFillColor.Orange,
ShapeFillColor.Red,
ShapeFillColor.Magenta,
ShapeFillColor.Purple,
ShapeFillColor.Blue,
ShapeFillColor.Teal,
ShapeFillColor.Green,
ShapeFillColor.Black,
ShapeFillColor.Grey,
ShapeFillColor.White,
] as const;
export const DEFAULT_SHAPE_FILL_COLOR = ShapeFillColor.Yellow;
export const FillColorsSchema = z.nativeEnum(ShapeFillColor);
export const SHAPE_STROKE_COLORS = LINE_COLORS;
export const DEFAULT_SHAPE_STROKE_COLOR = LineColor.Yellow;
export const DEFAULT_SHAPE_TEXT_COLOR = LineColor.Black;
export const StrokeColorsSchema = z.nativeEnum(LineColor);
@@ -0,0 +1,70 @@
import { createEnumMap } from '../utils/enum.js';
import { LineColor } from './line.js';
export enum ColorScheme {
Dark = 'dark',
Light = 'light',
}
export type Color = string | Partial<Record<ColorScheme | 'normal', string>>;
export enum TextAlign {
Center = 'center',
Left = 'left',
Right = 'right',
}
export const TextAlignMap = createEnumMap(TextAlign);
export enum TextVerticalAlign {
Bottom = 'bottom',
Center = 'center',
Top = 'top',
}
export type TextStyleProps = {
color: Color;
fontFamily: FontFamily;
fontSize: number;
fontStyle: FontStyle;
fontWeight: FontWeight;
textAlign: TextAlign;
};
export enum FontWeight {
Bold = '700',
Light = '300',
Medium = '500',
Regular = '400',
SemiBold = '600',
}
export const FontWeightMap = createEnumMap(FontWeight);
export enum FontStyle {
Italic = 'italic',
Normal = 'normal',
}
export enum FontFamily {
BebasNeue = 'blocksuite:surface:BebasNeue',
Inter = 'blocksuite:surface:Inter',
Kalam = 'blocksuite:surface:Kalam',
Lora = 'blocksuite:surface:Lora',
OrelegaOne = 'blocksuite:surface:OrelegaOne',
Poppins = 'blocksuite:surface:Poppins',
Satoshi = 'blocksuite:surface:Satoshi',
}
export const FontFamilyMap = createEnumMap(FontFamily);
export const FontFamilyList = Object.entries(FontFamilyMap) as {
[K in FontFamily]: [K, (typeof FontFamilyMap)[K]];
}[FontFamily][];
export enum TextResizing {
AUTO_WIDTH_AND_HEIGHT,
AUTO_HEIGHT,
}
export const DEFAULT_TEXT_COLOR = LineColor.Blue;
@@ -0,0 +1,234 @@
import type {
BaseElementProps,
PointTestOptions,
} from '@blocksuite/block-std/gfx';
import {
convert,
derive,
field,
GfxPrimitiveElementModel,
watch,
} from '@blocksuite/block-std/gfx';
import {
Bound,
getBoundFromPoints,
getPointsFromBoundWithRotation,
getQuadBoundWithRotation,
getSolidStrokePoints,
getSvgPathFromStroke,
inflateBound,
isPointOnlines,
type IVec,
type IVec3,
lineIntersects,
PointLocation,
polyLineNearestPoint,
type SerializedXYWH,
transformPointsToNewBound,
Vec,
} from '@blocksuite/global/utils';
import type { Color } from '../../consts/index.js';
export type BrushProps = BaseElementProps & {
/**
* [[x0,y0,pressure0?],[x1,y1,pressure1?]...]
* pressure is optional and exsits when pressure sensitivity is supported, otherwise not.
*/
points: number[][];
color: Color;
lineWidth: number;
};
export class BrushElementModel extends GfxPrimitiveElementModel<BrushProps> {
/**
* The SVG path commands for the brush.
*/
get commands() {
if (!this._local.has('commands')) {
const stroke = getSolidStrokePoints(this.points, this.lineWidth);
const commands = getSvgPathFromStroke(stroke);
this._local.set('commands', commands);
}
return this._local.get('commands') as string;
}
override get connectable() {
return false;
}
override get type() {
return 'brush';
}
static override propsToY(props: BrushProps) {
return props;
}
override containsBound(bounds: Bound) {
const points = getPointsFromBoundWithRotation(this);
return points.some(point => bounds.containsPoint(point));
}
override getLineIntersections(start: IVec, end: IVec) {
const tl = [this.x, this.y];
const points = getPointsFromBoundWithRotation(this, _ =>
this.points.map(point => Vec.add(point, tl))
);
const box = Bound.fromDOMRect(getQuadBoundWithRotation(this));
if (box.w < 8 && box.h < 8) {
return Vec.distanceToLineSegment(start, end, box.center) < 5 ? [] : null;
}
if (box.intersectLine(start, end, true)) {
const len = points.length;
for (let i = 1; i < len; i++) {
const result = lineIntersects(start, end, points[i - 1], points[i]);
if (result) {
return [
new PointLocation(
result,
Vec.normalize(Vec.sub(points[i], points[i - 1]))
),
];
}
}
}
return null;
}
override getNearestPoint(point: IVec): IVec {
const { x, y } = this;
return polyLineNearestPoint(
this.points.map(p => Vec.add(p, [x, y])),
point
) as IVec;
}
override getRelativePointLocation(position: IVec): PointLocation {
const point = Bound.deserialize(this.xywh).getRelativePoint(position);
return new PointLocation(point);
}
override includesPoint(
px: number,
py: number,
options?: PointTestOptions
): boolean {
const hit = isPointOnlines(
Bound.deserialize(this.xywh),
this.points as [number, number][],
this.rotate,
[px, py],
(options?.hitThreshold ?? 10) / Math.min(options?.zoom ?? 1, 1)
);
return hit;
}
@field()
accessor color: Color = '#000000';
@watch((_, instance) => {
instance['_local'].delete('commands');
})
@derive((lineWidth: number, instance: Instance) => {
const oldBound = instance.elementBound;
if (
lineWidth === instance.lineWidth ||
oldBound.w === 0 ||
oldBound.h === 0
)
return {};
const points = instance.points;
const transformed = transformPointsToNewBound(
points.map(([x, y]) => ({ x, y })),
oldBound,
instance.lineWidth / 2,
inflateBound(oldBound, lineWidth - instance.lineWidth),
lineWidth / 2
);
return {
points: transformed.points.map((p, i) => [
p.x,
p.y,
...(points[i][2] !== undefined ? [points[i][2]] : []),
]),
xywh: transformed.bound.serialize(),
};
})
@field()
accessor lineWidth: number = 4;
@watch((_, instance) => {
instance['_local'].delete('commands');
})
@derive((points: IVec[], instance: Instance) => {
const lineWidth = instance.lineWidth;
const bound = getBoundFromPoints(points);
const boundWidthLineWidth = inflateBound(bound, lineWidth);
return {
xywh: boundWidthLineWidth.serialize(),
};
})
@convert((points: (IVec | IVec3)[], instance) => {
const lineWidth = instance.lineWidth;
const bound = getBoundFromPoints(points as IVec[]);
const boundWidthLineWidth = inflateBound(bound, lineWidth);
const relativePoints = points.map(([x, y, pressure]) => [
x - boundWidthLineWidth.x,
y - boundWidthLineWidth.y,
...(pressure !== undefined ? [pressure] : []),
]);
return relativePoints;
})
@field()
accessor points: (IVec | IVec3)[] = [];
@field(0)
accessor rotate: number = 0;
@derive((xywh: SerializedXYWH, instance: Instance) => {
const bound = Bound.deserialize(xywh);
if (bound.w === instance.w && bound.h === instance.h) return {};
const { lineWidth } = instance;
const transformed = transformPointsToNewBound(
instance.points.map(([x, y]) => ({ x, y })),
instance,
instance.lineWidth / 2,
bound,
lineWidth / 2
);
return {
points: transformed.points.map((p, i) => [
p.x,
p.y,
...(instance.points[i][2] !== undefined ? [instance.points[i][2]] : []),
]),
};
})
@field()
accessor xywh: SerializedXYWH = '[0,0,0,0]';
}
type Instance = GfxPrimitiveElementModel<BrushProps> & BrushProps;
declare global {
namespace BlockSuite {
interface SurfaceElementModelMap {
brush: BrushElementModel;
}
}
}
@@ -0,0 +1 @@
export * from './brush.js';
@@ -0,0 +1,521 @@
import type {
BaseElementProps,
PointTestOptions,
SerializedElement,
} from '@blocksuite/block-std/gfx';
import {
derive,
field,
GfxPrimitiveElementModel,
local,
} from '@blocksuite/block-std/gfx';
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/utils';
import {
Bound,
curveIntersects,
getBezierNearestPoint,
getBezierNearestTime,
getBezierParameters,
getBezierPoint,
linePolylineIntersects,
PointLocation,
Polyline,
polyLineNearestPoint,
Vec,
} from '@blocksuite/global/utils';
import { DocCollection, type Y } from '@blocksuite/store';
import {
type Color,
CONNECTOR_LABEL_MAX_WIDTH,
ConnectorLabelOffsetAnchor,
ConnectorMode,
DEFAULT_ROUGHNESS,
FontFamily,
FontStyle,
FontWeight,
type PointStyle,
StrokeStyle,
TextAlign,
type TextStyleProps,
} from '../../consts/index.js';
export type SerializedConnection = {
id?: string;
position?: `[${number},${number}]` | PointLocation;
};
// at least one of id and position is not null
// both exists means the position is relative to the element
export type Connection = {
id?: string;
position?: [number, number];
};
export const getConnectorModeName = (mode: ConnectorMode) => {
return {
[ConnectorMode.Straight]: 'Straight',
[ConnectorMode.Orthogonal]: 'Elbowed',
[ConnectorMode.Curve]: 'Curve',
}[mode];
};
export type ConnectorLabelOffsetProps = {
// [0, 1], `0.5` by default
distance: number;
// `center` by default
anchor?: ConnectorLabelOffsetAnchor;
};
export type ConnectorLabelConstraintsProps = {
hasMaxWidth: boolean;
maxWidth: number;
};
export type ConnectorLabelProps = {
// Label's content
text?: Y.Text;
labelEditing?: boolean;
labelDisplay?: boolean;
labelXYWH?: XYWH;
labelOffset?: ConnectorLabelOffsetProps;
labelStyle?: TextStyleProps;
labelConstraints?: ConnectorLabelConstraintsProps;
};
export type SerializedConnectorElement = SerializedElement & {
source: SerializedConnection;
target: SerializedConnection;
};
export type ConnectorElementProps = BaseElementProps & {
mode: ConnectorMode;
stroke: Color;
strokeWidth: number;
strokeStyle: StrokeStyle;
roughness?: number;
rough?: boolean;
source: Connection;
target: Connection;
frontEndpointStyle?: PointStyle;
rearEndpointStyle?: PointStyle;
} & ConnectorLabelProps;
export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorElementProps> {
updatingPath = false;
override get connectable() {
return false as const;
}
get connected() {
return !!(this.source.id || this.target.id);
}
override get elementBound() {
let bounds = super.elementBound;
if (this.hasLabel()) {
bounds = bounds.unite(Bound.fromXYWH(this.labelXYWH!));
}
return bounds;
}
get type() {
return 'connector';
}
static override propsToY(props: ConnectorElementProps) {
if (props.text && !(props.text instanceof DocCollection.Y.Text)) {
props.text = new DocCollection.Y.Text(props.text);
}
return props;
}
override containsBound(bounds: Bound) {
return (
this.absolutePath.some(point => bounds.containsPoint(point)) ||
(this.hasLabel() &&
Bound.fromXYWH(this.labelXYWH!).points.some(p =>
bounds.containsPoint(p)
))
);
}
override getLineIntersections(start: IVec, end: IVec) {
const { mode, absolutePath: path } = this;
let intersected = null;
if (mode === ConnectorMode.Curve && path.length > 1) {
intersected = curveIntersects(path, [start, end]);
} else {
intersected = linePolylineIntersects(start, end, path);
}
if (!intersected && this.hasLabel()) {
intersected = linePolylineIntersects(
start,
end,
Bound.fromXYWH(this.labelXYWH!).points
);
}
return intersected;
}
/**
* Calculate the closest point on the curve via a point.
*/
override getNearestPoint(point: IVec): IVec {
const { mode, absolutePath: path } = this;
if (mode === ConnectorMode.Straight) {
const first = path[0];
const last = path[path.length - 1];
return Vec.nearestPointOnLineSegment(first, last, point, true);
}
if (mode === ConnectorMode.Orthogonal) {
const points = path.map<IVec>(p => [p[0], p[1]]);
return Polyline.nearestPoint(points, point);
}
const b = getBezierParameters(path);
const t = getBezierNearestTime(b, point);
const p = getBezierPoint(b, t);
if (p) return p;
const { x, y } = this;
return [x, y];
}
/**
* Calculating the computed distance along a path via a point.
*
* The point is relative to the viewport.
*/
getOffsetDistanceByPoint(point: IVec, bounds?: Bound) {
const { mode, absolutePath: path } = this;
let { x, y, w, h } = this;
if (bounds) {
x = bounds.x;
y = bounds.y;
w = bounds.w;
h = bounds.h;
}
point[0] = Vec.clamp(point[0], x, x + w);
point[1] = Vec.clamp(point[1], y, y + h);
if (mode === ConnectorMode.Straight) {
const s = path[0];
const e = path[path.length - 1];
const pl = Vec.dist(s, point);
const fl = Vec.dist(s, e);
return pl / fl;
}
if (mode === ConnectorMode.Orthogonal) {
const points = path.map<IVec>(p => [p[0], p[1]]);
const p = Polyline.nearestPoint(points, point);
const pl = Polyline.lenAtPoint(points, p);
const fl = Polyline.len(points);
return pl / fl;
}
const b = getBezierParameters(path);
return getBezierNearestTime(b, point);
}
/**
* Calculating the computed point along a path via a offset distance.
*
* Returns a point relative to the viewport.
*/
getPointByOffsetDistance(offsetDistance = 0.5, bounds?: Bound): IVec {
const { mode, absolutePath: path } = this;
if (mode === ConnectorMode.Straight) {
const first = path[0];
const last = path[path.length - 1];
return Vec.lrp(first, last, offsetDistance);
}
let { x, y, w, h } = this;
if (bounds) {
x = bounds.x;
y = bounds.y;
w = bounds.w;
h = bounds.h;
}
if (mode === ConnectorMode.Orthogonal) {
const points = path.map<IVec>(p => [p[0], p[1]]);
const point = Polyline.pointAt(points, offsetDistance);
if (point) return point;
return [x + w / 2, y + h / 2];
}
const b = getBezierParameters(path);
const point = getBezierPoint(b, offsetDistance);
if (point) return point;
return [x + w / 2, y + h / 2];
}
override getRelativePointLocation(point: IVec): PointLocation {
return new PointLocation(
Bound.deserialize(this.xywh).getRelativePoint(point)
);
}
hasLabel() {
return Boolean(!this.lableEditing && this.labelDisplay && this.labelXYWH);
}
override includesPoint(
x: number,
y: number,
options?: PointTestOptions | undefined
): boolean {
const currentPoint: IVec = [x, y];
if (this.labelIncludesPoint(currentPoint as IVec)) {
return true;
}
const { mode, strokeWidth, absolutePath: path } = this;
const point =
mode === ConnectorMode.Curve
? getBezierNearestPoint(getBezierParameters(path), currentPoint)
: polyLineNearestPoint(path, currentPoint);
return (
Vec.dist(point, currentPoint) <
(options?.hitThreshold ? strokeWidth / 2 : 0) + 8
);
}
labelIncludesPoint(point: IVec) {
return (
this.hasLabel() && Bound.fromXYWH(this.labelXYWH!).isPointInBound(point)
);
}
moveTo(bound: Bound) {
const oldBound = Bound.deserialize(this.xywh);
const offset = Vec.sub([bound.x, bound.y], [oldBound.x, oldBound.y]);
const { source, target } = this;
if (!source.id && source.position) {
this.source = {
position: Vec.add(source.position, offset) as [number, number],
};
}
if (!target.id && target.position) {
this.target = {
position: Vec.add(target.position, offset) as [number, number],
};
}
// Updates Connector's Label position.
if (this.hasLabel()) {
const [x, y, w, h] = this.labelXYWH!;
this.labelXYWH = [x + offset[0], y + offset[1], w, h];
}
}
resize(bounds: Bound, originalPath: PointLocation[], matrix: DOMMatrix) {
this.updatingPath = false;
const path = this.resizePath(originalPath, matrix);
// the property assignment order matters
this.xywh = bounds.serialize();
this.path = path.map(p => p.clone().setVec(Vec.sub(p, bounds.tl)));
const props: {
labelXYWH?: XYWH;
source?: Connection;
target?: Connection;
} = {};
// Updates Connector's Label position.
if (this.hasLabel()) {
const [cx, cy] = this.getPointByOffsetDistance(this.labelOffset.distance);
const [, , w, h] = this.labelXYWH!;
props.labelXYWH = [cx - w / 2, cy - h / 2, w, h];
}
if (!this.source.id) {
props.source = {
...this.source,
position: path[0].toVec() as [number, number],
};
}
if (!this.target.id) {
props.target = {
...this.target,
position: path[path.length - 1].toVec() as [number, number],
};
}
return props;
}
resizePath(originalPath: PointLocation[], matrix: DOMMatrix) {
if (this.mode === ConnectorMode.Curve) {
return originalPath.map(point => {
const [p, t, absIn, absOut] = [
point,
point.tangent,
point.absIn,
point.absOut,
]
.map(p => new DOMPoint(...p).matrixTransform(matrix))
.map(p => [p.x, p.y] as IVec);
const ip = Vec.sub(absIn, p);
const op = Vec.sub(absOut, p);
return new PointLocation(p, t, ip, op);
});
}
return originalPath.map(point => {
const { x, y } = new DOMPoint(...point).matrixTransform(matrix);
const p: IVec = [x, y];
return PointLocation.fromVec(p);
});
}
override serialize() {
const result = super.serialize();
result.xywh = this.xywh;
result.source = structuredClone(this.source);
result.target = structuredClone(this.target);
return result as SerializedConnectorElement;
}
@local()
accessor absolutePath: PointLocation[] = [];
@field('None' as PointStyle)
accessor frontEndpointStyle!: PointStyle;
/**
* Defines the size constraints of the label.
*/
@field({
hasMaxWidth: true,
maxWidth: CONNECTOR_LABEL_MAX_WIDTH,
} as ConnectorLabelConstraintsProps)
accessor labelConstraints!: ConnectorLabelConstraintsProps;
/**
* Control display and hide.
*/
@field(true)
accessor labelDisplay!: boolean;
/**
* The offset property specifies the label along the connector path.
*/
@field({
distance: 0.5,
anchor: ConnectorLabelOffsetAnchor.Center,
} as ConnectorLabelOffsetProps)
accessor labelOffset!: ConnectorLabelOffsetProps;
/**
* Defines the style of the label.
*/
@field({
color: '#000000',
fontFamily: FontFamily.Inter,
fontSize: 16,
fontStyle: FontStyle.Normal,
fontWeight: FontWeight.Regular,
textAlign: TextAlign.Center,
} as TextStyleProps)
accessor labelStyle!: TextStyleProps;
/**
* Returns a `XYWH` array providing information about the size of a label
* and its position relative to the viewport.
*/
@field()
accessor labelXYWH: XYWH | undefined = undefined;
/**
* Local control display and hide, mainly used in editing scenarios.
*/
@local()
accessor lableEditing: boolean = false;
@field()
accessor mode: ConnectorMode = ConnectorMode.Orthogonal;
@derive((path: PointLocation[], instance) => {
const { x, y } = instance;
return {
absolutePath: path.map(p => p.clone().setVec(Vec.add(p, [x, y]))),
};
})
@local()
accessor path: PointLocation[] = [];
@field('Arrow' as PointStyle)
accessor rearEndpointStyle!: PointStyle;
@local()
accessor rotate: number = 0;
@field()
accessor rough: boolean | undefined = undefined;
@field()
accessor roughness: number = DEFAULT_ROUGHNESS;
@field()
accessor source: Connection = {
position: [0, 0],
};
@field()
accessor stroke: Color = '#000000';
@field()
accessor strokeStyle: StrokeStyle = StrokeStyle.Solid;
@field()
accessor strokeWidth: number = 4;
@field()
accessor target: Connection = {
position: [0, 0],
};
/**
* The content of the label.
*/
@field()
accessor text: Y.Text | undefined = undefined;
@local()
accessor xywh: SerializedXYWH = '[0,0,0,0]';
}
declare global {
namespace BlockSuite {
interface SurfaceElementModelMap {
connector: ConnectorElementModel;
}
interface EdgelessTextModelMap {
connector: ConnectorElementModel;
}
}
}
@@ -0,0 +1,2 @@
export * from './connector.js';
export * from './local-connector.js';
@@ -0,0 +1,66 @@
import { GfxLocalElementModel } from '@blocksuite/block-std/gfx';
import type { PointLocation } from '@blocksuite/global/utils';
import {
type Color,
ConnectorMode,
DEFAULT_ROUGHNESS,
type PointStyle,
StrokeStyle,
} from '../../consts/index.js';
import type { Connection } from './connector.js';
export class LocalConnectorElementModel extends GfxLocalElementModel {
private _path: PointLocation[] = [];
absolutePath: PointLocation[] = [];
frontEndpointStyle!: PointStyle;
mode: ConnectorMode = ConnectorMode.Orthogonal;
rearEndpointStyle!: PointStyle;
rough?: boolean;
roughness: number = DEFAULT_ROUGHNESS;
source: Connection = {
position: [0, 0],
};
stroke: Color = '#000000';
strokeStyle: StrokeStyle = StrokeStyle.Solid;
strokeWidth: number = 4;
target: Connection = {
position: [0, 0],
};
updatingPath = false;
get path(): PointLocation[] {
return this._path;
}
set path(value: PointLocation[]) {
const { x, y } = this;
this._path = value;
this.absolutePath = value.map(p => p.clone().setVec([p[0] + x, p[1] + y]));
}
get type() {
return 'connector';
}
}
declare global {
namespace BlockSuite {
interface SurfaceLocalModelMap {
connector: LocalConnectorElementModel;
}
}
}
@@ -0,0 +1,124 @@
import type {
BaseElementProps,
GfxModel,
SerializedElement,
} from '@blocksuite/block-std/gfx';
import {
canSafeAddToContainer,
field,
GfxGroupLikeElementModel,
local,
observe,
} from '@blocksuite/block-std/gfx';
import type { IVec, PointLocation } from '@blocksuite/global/utils';
import { Bound, keys, linePolygonIntersects } from '@blocksuite/global/utils';
import type { Y } from '@blocksuite/store';
import { DocCollection } from '@blocksuite/store';
type GroupElementProps = BaseElementProps & {
children: Y.Map<boolean>;
title: Y.Text;
};
export type SerializedGroupElement = SerializedElement & {
title: string;
children: Record<string, boolean>;
};
export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProps> {
get rotate() {
return 0;
}
set rotate(_: number) {}
get type() {
return 'group';
}
static override propsToY(props: Record<string, unknown>) {
if ('title' in props && !(props.title instanceof DocCollection.Y.Text)) {
props.title = new DocCollection.Y.Text(props.title as string);
}
if (props.children && !(props.children instanceof DocCollection.Y.Map)) {
const children = new DocCollection.Y.Map() as Y.Map<boolean>;
keys(props.children).forEach(key => {
children.set(key as string, true);
});
props.children = children;
}
return props as GroupElementProps;
}
override addChild(element: GfxModel) {
if (!canSafeAddToContainer(this, element)) {
return;
}
this.surface.doc.transact(() => {
this.children.set(element.id, true);
});
}
override containsBound(bound: Bound): boolean {
return bound.contains(Bound.deserialize(this.xywh));
}
override getLineIntersections(
start: IVec,
end: IVec
): PointLocation[] | null {
const bound = Bound.deserialize(this.xywh);
return linePolygonIntersects(start, end, bound.points);
}
removeChild(element: GfxModel) {
if (!this.children) {
return;
}
this.surface.doc.transact(() => {
this.children.delete(element.id);
});
}
override serialize() {
const result = super.serialize();
return result as SerializedGroupElement;
}
@observe(
// use `GroupElementModel` type in decorator will cause playwright error
(_, instance: GfxGroupLikeElementModel<GroupElementProps>, transaction) => {
if (instance.children.doc) {
instance.setChildIds(
Array.from(instance.children.keys()),
transaction?.local ?? false
);
}
}
)
@field()
accessor children: Y.Map<boolean> = new DocCollection.Y.Map<boolean>();
@local()
accessor showTitle: boolean = true;
@field()
accessor title: Y.Text = new DocCollection.Y.Text();
}
declare global {
namespace BlockSuite {
interface SurfaceGroupLikeModelMap {
group: GroupElementModel;
}
interface SurfaceElementModelMap {
group: GroupElementModel;
}
}
}
@@ -0,0 +1 @@
export * from './group.js';
@@ -0,0 +1,6 @@
export * from './brush/index.js';
export * from './connector/index.js';
export * from './group/index.js';
export * from './mindmap/index.js';
export * from './shape/index.js';
export * from './text/index.js';
@@ -0,0 +1,2 @@
export * from './mindmap.js';
export * from './style.js';
@@ -0,0 +1,975 @@
import type {
BaseElementProps,
GfxModel,
PointTestOptions,
SerializedElement,
} from '@blocksuite/block-std/gfx';
import {
convert,
field,
GfxGroupLikeElementModel,
observe,
watch,
} from '@blocksuite/block-std/gfx';
import type { Bound, SerializedXYWH, XYWH } from '@blocksuite/global/utils';
import {
assertType,
deserializeXYWH,
keys,
last,
noop,
pick,
} from '@blocksuite/global/utils';
import { DocCollection, type Y } from '@blocksuite/store';
import { generateKeyBetween } from 'fractional-indexing';
import { z } from 'zod';
import { ConnectorMode } from '../../consts/connector.js';
import { LayoutType, MindmapStyle } from '../../consts/mindmap.js';
import { LocalConnectorElementModel } from '../connector/local-connector.js';
import type { MindmapStyleGetter } from './style.js';
import { mindmapStyleGetters } from './style.js';
import { findInfiniteLoop } from './utils.js';
export type NodeDetail = {
/**
* The index of the node, it decides the layout order of the node
*/
index: string;
parent?: string;
collapsed?: boolean;
};
export type MindmapNode = {
id: string;
detail: NodeDetail;
element: BlockSuite.SurfaceElementModel;
children: MindmapNode[];
parent: MindmapNode | null;
/**
* This area is used to determine where to place the dragged node.
*
* When dragging another node into this area, it will become a sibling of the this node.
* But if it is dragged into the small area located right after the this node, it will become a child of the this node.
*/
responseArea?: Bound;
/**
* This property override the preferredDir or default layout direction.
* It is used during dragging that would temporary change the layout direction
*/
overriddenDir?: LayoutType;
};
export type MindmapRoot = MindmapNode & {
left: MindmapNode[];
right: MindmapNode[];
};
const baseNodeSchema = z.object({
text: z.string(),
xywh: z.optional(z.string()),
});
type Node = z.infer<typeof baseNodeSchema> & {
children?: Node[];
};
const nodeSchema: z.ZodType<Node> = baseNodeSchema.extend({
children: z.lazy(() => nodeSchema.array()).optional(),
});
export type NodeType = z.infer<typeof nodeSchema>;
function isNodeType(node: Record<string, unknown>): node is NodeType {
return typeof node.text === 'string' && Array.isArray(node.children);
}
export type SerializedMindmapElement = SerializedElement & {
children: Record<string, NodeDetail>;
};
type MindmapElementProps = BaseElementProps & {
children: Y.Map<NodeDetail>;
};
function observeChildren(
_: unknown,
instance: MindmapElementModel,
transaction: Y.Transaction | null
) {
if (instance.children.doc) {
instance.setChildIds(
Array.from(instance.children.keys()),
transaction?.local ?? true
);
instance.buildTree();
instance.connectors.clear();
}
}
function watchLayoutType(
_: unknown,
instance: MindmapElementModel,
local: boolean
) {
if (!local) {
return;
}
instance.surface.doc.transact(() => {
instance['_tree']?.children.forEach(child => {
if (!instance.children.has(child.id)) {
return;
}
instance.children.set(child.id, {
index: child.detail.index,
parent: child.detail.parent,
});
});
});
instance.buildTree();
}
function watchStyle(_: unknown, instance: MindmapElementModel, local: boolean) {
if (!local) return;
instance.layout();
}
export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElementProps> {
private _layout: MindmapElementModel['layout'] | null = null;
private _nodeMap = new Map<string, MindmapNode>();
private _queueBuildTree = false;
private _queuedLayout = false;
private _stashedNode = new Set<string>();
private _tree!: MindmapRoot;
connectors = new Map<string, LocalConnectorElementModel>();
get nodeMap() {
return this._nodeMap;
}
override get rotate() {
return 0;
}
override set rotate(_: number) {}
get styleGetter(): MindmapStyleGetter {
return mindmapStyleGetters[this.style];
}
get tree() {
return this._tree;
}
get type() {
return 'mindmap';
}
static override propsToY(props: Record<string, unknown>) {
if (
props.children &&
!isNodeType(props.children as Record<string, unknown>) &&
!(props.children instanceof DocCollection.Y.Map)
) {
const children: Y.Map<NodeDetail> = new DocCollection.Y.Map();
keys(props.children).forEach(key => {
const detail = pick<Record<string, unknown>, keyof NodeDetail>(
props.children![key],
['index', 'parent']
);
children.set(key as string, detail as NodeDetail);
});
props.children = children;
}
return props as MindmapElementProps;
}
private _cfgBalanceLayoutDir() {
if (this.layoutType !== LayoutType.BALANCE) {
return;
}
const tree = this._tree;
const splitPoint = Math.ceil(tree.children.length / 2);
tree.right.push(...tree.children.slice(0, splitPoint));
tree.left.push(...tree.children.slice(splitPoint));
tree.left.reverse();
}
private _isConnectorOutdated(
options:
| {
connector: LocalConnectorElementModel;
from: MindmapNode;
to: MindmapNode;
layout: LayoutType;
}
| {
connector: LocalConnectorElementModel;
from: MindmapNode;
layout: LayoutType;
collapsed: boolean;
},
updateKey: boolean = true
) {
const collapsed = 'collapsed' in options;
const { connector, from, layout } = options;
if (!from.element || (!collapsed && !options.to.element)) {
return { outdated: true, cacheKey: '' };
}
const cacheKey = collapsed
? `${from.element.xywh}-collapsed-${layout}-${this.style}`
: `${from.element.xywh}-${options.to.element.xywh}-${layout}-${this.style}`;
if (connector.cache.get('MINDMAP_CONNECTOR') === cacheKey) {
return false;
} else if (updateKey) {
connector.cache.set('MINDMAP_CONNECTOR', cacheKey);
}
return true;
}
protected override _getXYWH(): Bound {
return super._getXYWH();
}
/**
* @deprecated
* you should not call this method directly
*/
addChild(_element: GfxModel) {
noop();
}
addNode(
/**
* The parent node id of the new node. If it's null, the node will be the root node
*/
parent: string | MindmapNode | null,
sibling?: string | number,
position: 'before' | 'after' = 'after',
props: Record<string, unknown> = {}
) {
if (parent && typeof parent !== 'string') {
parent = parent.id;
}
assertType<string | null>(parent);
if (parent && !this._nodeMap.has(parent)) {
throw new Error(`Parent node ${parent} not found`);
}
props['text'] = new DocCollection.Y.Text(
(props['text'] as string) ?? 'New node'
);
const type = (props.type as string) ?? 'shape';
let id: string;
this.surface.doc.transact(() => {
const parentNode = parent ? this._nodeMap.get(parent)! : null;
if (parentNode) {
let index = last(parentNode.children)
? generateKeyBetween(last(parentNode.children)!.detail.index, null)
: 'a0';
sibling = sibling ?? last(parentNode.children)?.id;
const siblingNode =
typeof sibling === 'number'
? parentNode.children[sibling]
: sibling
? this._nodeMap.get(sibling)
: undefined;
const path = siblingNode
? this.getPath(siblingNode)
: this.getPath(parentNode).concat([0]);
const style = this.styleGetter.getNodeStyle(
siblingNode ?? parentNode,
path
);
id = this.surface.addElement({
type,
xywh: '[0,0,100,30]',
maxWidth: false,
...props,
...style.node,
});
if (siblingNode) {
const siblingIndex = parentNode.children.findIndex(
val => val.id === sibling
);
index =
position === 'after'
? generateKeyBetween(
siblingNode.detail.index,
parentNode.children[siblingIndex + 1]?.detail.index ?? null
)
: generateKeyBetween(
parentNode.children[siblingIndex - 1]?.detail.index ?? null,
siblingNode.detail.index
);
}
const nodeDetail: NodeDetail = {
index,
parent: parent!,
};
this.children.set(id, nodeDetail);
} else {
const rootStyle = this.styleGetter.root;
id = this.surface.addElement({
type,
xywh: '[0,0,113,41]',
maxWidth: false,
...props,
...rootStyle,
});
this.children.clear();
this.children.set(id, {
index: 'a0',
});
}
});
this.layout();
return id!;
}
buildTree() {
const mindmapNodeMap = new Map<string, MindmapNode>();
const nodesMap = this.children;
// The element may be removed
if (!nodesMap || nodesMap.size === 0) {
this._nodeMap = mindmapNodeMap;
// @ts-expect-error FIXME: ts error
this._tree = null;
return;
}
let rootNode: MindmapRoot | undefined;
nodesMap.forEach((val, id) => {
if (!mindmapNodeMap.has(id)) {
mindmapNodeMap.set(id, {
id,
index: val.index,
detail: val,
element: this.surface.getElementById(id)!,
children: [],
parent: null,
} as MindmapNode);
}
const node = mindmapNodeMap.get(id)!;
// some node may be already created during
// iterating its children
if (!node.detail) {
node.detail = val;
}
if (!val.parent) {
rootNode = node as MindmapRoot;
rootNode.left = [];
rootNode.right = [];
} else {
if (!mindmapNodeMap.has(val.parent)) {
mindmapNodeMap.set(val.parent, {
id: val.parent,
detail: nodesMap.get(val.parent)!,
parent: null,
children: [],
element: this.surface.getElementById(val.parent)!,
} as MindmapNode);
}
const parent = mindmapNodeMap.get(val.parent)!;
parent.children.push(node);
node.parent = parent;
}
});
mindmapNodeMap.forEach(node => {
node.children.sort((a, b) =>
a.detail.index === b.detail.index
? 0
: a.detail.index > b.detail.index
? 1
: -1
);
});
if (!rootNode) {
return;
}
const loops = findInfiniteLoop(rootNode, mindmapNodeMap);
if (loops.length) {
this.surface.doc.withoutTransact(() => {
loops.forEach(loop => {
if (loop.detached) {
loop.chain.forEach(node => {
this.children.delete(node.id);
});
} else {
const child = last(loop.chain);
if (child) {
this.children.set(child.id, {
index: child.detail.index,
});
}
}
});
});
return;
}
this._nodeMap = mindmapNodeMap;
this._tree = rootNode;
if (this.layoutType === LayoutType.BALANCE) {
this._cfgBalanceLayoutDir();
} else {
this._tree[this.layoutType === LayoutType.RIGHT ? 'right' : 'left'] =
this._tree.children;
}
}
/**
*
* @param subtree The subtree of root, this only take effects when the layout type is BALANCED.
* @returns
*/
getChildNodes(id: string, subtree?: 'left' | 'right') {
const node = this._nodeMap.get(id);
if (!node) {
return [];
}
if (subtree && id === this._tree.id) {
return this._tree[subtree];
}
return node.children;
}
/**
* Get all the connectors start from the given node
* @param node
* @returns
*/
getConnectors(node: MindmapNode) {
if (!this._nodeMap.has(node.id)) {
return null;
}
if (node.detail.collapsed) {
const id = `#${node.id}-collapsed`;
const layout = this.getLayoutDir(node)!;
const connector =
this.connectors.get(id) ?? new LocalConnectorElementModel(this.surface);
const connectorExist = this.connectors.has(id);
const connectorStyle = this.styleGetter.getNodeStyle(
node,
this.getPath(node).concat([0])
).connector;
const outdated = this._isConnectorOutdated({
connector,
from: node,
collapsed: true,
layout,
});
if (!connectorExist) {
connector.id = id;
this.connectors.set(id, connector);
}
if (outdated) {
const nodeBound = node.element.elementBound;
connector.id = id;
connector.source = {
id: node.id,
position: layout === LayoutType.LEFT ? [0, 0.5] : [1, 0.5],
};
connector.target = {
position:
layout === LayoutType.LEFT
? [nodeBound.x - 6, nodeBound.y + nodeBound.h / 2]
: [nodeBound.x + nodeBound.w + 6, nodeBound.y + nodeBound.h / 2],
};
Object.entries(connectorStyle).forEach(([key, value]) => {
// @ts-expect-error FIXME: ts error
connector[key as unknown] = value;
});
connector.mode = ConnectorMode.Straight;
}
return [{ outdated, connector }];
} else {
const from = node;
return from.children.map(to => {
const layout = this.getLayoutDir(to)!;
const id = `#${from.id}-${to.id}`;
const connectorExist = this.connectors.has(id);
const connectorStyle = this.styleGetter.getNodeStyle(
to,
this.getPath(to)
).connector;
const connector =
this.connectors.get(id) ??
new LocalConnectorElementModel(this.surface);
const outdated = this._isConnectorOutdated({
connector,
from,
to,
layout,
});
if (!connectorExist) {
connector.id = id;
this.connectors.set(id, connector);
}
if (outdated) {
connector.source = {
id: from.id,
position: layout === LayoutType.RIGHT ? [1, 0.5] : [0, 0.5],
};
connector.target = {
id: to.id,
position: layout === LayoutType.RIGHT ? [0, 0.5] : [1, 0.5],
};
Object.entries(connectorStyle).forEach(([key, value]) => {
// @ts-expect-error FIXME: ts error
connector[key as unknown] = value;
});
}
return { outdated, connector };
});
}
}
getLayoutDir(node: string | MindmapNode): LayoutType {
node = typeof node === 'string' ? this._nodeMap.get(node)! : node;
assertType<MindmapNode>(node);
let current: MindmapNode | null = node;
const root = this._tree;
while (current) {
if (current.overriddenDir !== undefined) {
return current.overriddenDir;
}
const parent: MindmapNode | null = current.detail.parent
? (this._nodeMap.get(current.detail.parent) ?? null)
: null;
if (parent === root) {
return (
parent.overriddenDir ??
(root.left.includes(current)
? LayoutType.LEFT
: root.right.includes(current)
? LayoutType.RIGHT
: this.layoutType)
);
}
current = parent;
}
return this.layoutType;
}
getNode(id: string) {
return this._nodeMap.get(id) ?? null;
}
getParentNode(id: string) {
const node = this.children.get(id);
return node?.parent ? (this._nodeMap.get(node.parent) ?? null) : null;
}
/**
* Path is an array of indexes that represent the path from the root node to the target node.
* The first element of the array is always 0, which represents the root node.
* @param element
* @returns
*
* @example
* ```ts
* const path = mindmap.getPath('nodeId');
* // [0, 1, 2]
* ```
*/
getPath(element: string | MindmapNode) {
let node = this._nodeMap.get(
typeof element === 'string' ? element : element.id
);
if (!node) {
throw new Error('Node not found');
}
const path: number[] = [];
while (node && node !== this._tree) {
const parent = this._nodeMap.get(node!.detail.parent!);
path.unshift(parent!.children.indexOf(node!));
node = parent;
}
path.unshift(0);
return path;
}
getSiblingNode(
id: string,
direction: 'prev' | 'next' = 'next',
/**
* The subtree of which that the sibling node belongs to,
* this is used when the layout type is BALANCED.
*/
subtree?: 'left' | 'right'
) {
const node = this._nodeMap.get(id);
if (!node) {
return null;
}
const parent = this._nodeMap.get(node.detail.parent!);
if (!parent) {
return null;
}
const childrenTree =
subtree && parent.id === this._tree.id
? this._tree[subtree]
: parent.children;
const idx = childrenTree.indexOf(node);
if (idx === -1) {
return null;
}
const siblingIndex = direction === 'next' ? idx + 1 : idx - 1;
const sibling = childrenTree[siblingIndex] ?? null;
return sibling;
}
override includesPoint(x: number, y: number, options: PointTestOptions) {
const bound = this.elementBound;
bound.x -= options.responsePadding?.[0] ?? 0;
bound.w += (options.responsePadding?.[0] ?? 0) * 2;
bound.y -= options.responsePadding?.[1] ?? 0;
bound.h += (options.responsePadding?.[1] ?? 0) * 2;
return bound.containsPoint([x, y]);
}
layout(
_tree: MindmapNode | MindmapRoot = this.tree,
_options: {
applyStyle?: boolean;
layoutType?: LayoutType;
calculateTreeBound?: boolean;
stashed?: boolean;
} = {
applyStyle: true,
calculateTreeBound: true,
stashed: true,
}
) {
// should be implemented by the view
// otherwise, it would be just an empty function
if (this._layout) {
this._layout(_tree, _options);
}
}
moveTo(targetXYWH: SerializedXYWH | XYWH) {
const { x, y } = this;
const targetPos =
typeof targetXYWH === 'string' ? deserializeXYWH(targetXYWH) : targetXYWH;
const offsetX = targetPos[0] - x;
const offsetY = targetPos[1] - y + targetPos[3];
this.surface.doc.transact(() => {
this.childElements.forEach(el => {
const deserializedXYWH = deserializeXYWH(el.xywh);
el.xywh =
`[${deserializedXYWH[0] + offsetX},${deserializedXYWH[1] + offsetY},${deserializedXYWH[2]},${deserializedXYWH[3]}]` as SerializedXYWH;
});
});
}
override onCreated(): void {
this.buildTree();
}
removeChild(element: GfxModel) {
if (!this._nodeMap.has(element.id)) {
return;
}
const surface = this.surface;
const removedDescendants: string[] = [];
const remove = (node: MindmapNode) => {
node.children?.forEach(child => {
remove(child);
});
this.children?.delete(node.id);
removedDescendants.push(node.id);
};
surface.doc.transact(() => {
remove(this._nodeMap.get(element.id)!);
});
queueMicrotask(() => {
removedDescendants.forEach(id => surface.deleteElement(id));
});
// This transaction may not end
// force to build the elements
this.buildTree();
this.requestLayout();
}
protected requestBuildTree() {
if (this._queueBuildTree) {
return;
}
this._queueBuildTree = true;
queueMicrotask(() => {
this.buildTree();
this._queueBuildTree = false;
});
}
requestLayout() {
if (!this._queuedLayout) {
this._queuedLayout = true;
queueMicrotask(() => {
this.layout();
this._queuedLayout = false;
});
}
}
override serialize() {
const result = super.serialize();
return result as SerializedMindmapElement;
}
setLayoutMethod(layoutMethod: MindmapElementModel['layout']) {
this._layout = layoutMethod;
}
/**
* Stash mind map node and its children's xywh property
* @param node
* @returns a function that write back the stashed xywh into yjs
*/
stashTree(node: MindmapNode | string) {
const mindNode = typeof node === 'string' ? this.getNode(node) : node;
if (!mindNode || this._stashedNode.has(mindNode.id)) {
return;
}
const stashed = new Set<BlockSuite.SurfaceElementModel>();
const traverse = (node: MindmapNode) => {
node.element.stash('xywh');
stashed.add(node.element);
if (node.children.length) {
node.children.forEach(child => traverse(child));
}
};
traverse(mindNode);
return () => {
this._stashedNode.delete(mindNode.id);
stashed.forEach(el => {
el.pop('xywh');
});
};
}
toggleCollapse(node: MindmapNode, options: { layout?: boolean } = {}) {
if (!this._nodeMap.has(node.id)) {
return;
}
const { layout = false } = options;
if (node && node.children.length > 0) {
const collapsed = node.detail.collapsed ? false : true;
const isExpand = !collapsed;
const changeNodesVisibility = (node: MindmapNode) => {
node.element.hidden = collapsed;
if (isExpand && node.detail.collapsed) {
return;
}
node.children.forEach(child => {
changeNodesVisibility(child);
});
};
node.children.forEach(changeNodesVisibility);
this.surface.doc.transact(() => {
this.children.set(node.id, {
...node.detail,
collapsed,
});
});
}
if (layout) {
this.requestLayout();
}
}
traverse(
callback: (node: MindmapNode, parent: MindmapNode | null) => void,
root: MindmapNode = this._tree,
options: { stopOnCollapse?: boolean } = {}
) {
const { stopOnCollapse = false } = options;
const traverse = (node: MindmapNode, parent: MindmapNode | null) => {
callback(node, parent);
if (stopOnCollapse && node.detail.collapsed) {
return;
}
node?.children.forEach(child => {
traverse(child, node);
});
};
if (root) {
traverse(root, null);
}
}
@convert((initialValue, instance) => {
if (!(initialValue instanceof DocCollection.Y.Map)) {
nodeSchema.parse(initialValue);
assertType<NodeType>(initialValue);
const map: Y.Map<NodeDetail> = new DocCollection.Y.Map();
const surface = instance.surface;
const doc = surface.doc;
const recursive = (
node: NodeType,
parent: string | null = null,
index: string = 'a0'
) => {
const id = surface.addElement({
type: 'shape',
text: node.text,
xywh: node.xywh ? node.xywh : `[0, 0, 100, 30]`,
});
map.set(id, {
index,
parent: parent ?? undefined,
});
let curIdx = 'a0';
node.children?.forEach(childNode => {
recursive(childNode, id, curIdx);
curIdx = generateKeyBetween(curIdx, null);
});
};
doc.transact(() => {
recursive(initialValue);
});
instance.requestBuildTree();
instance.requestLayout();
return map;
} else {
instance.requestBuildTree();
instance.requestLayout();
return initialValue;
}
})
// Use extracted function to avoid playwright test failure
// since this model package is imported by playwright
@observe(observeChildren)
@field()
accessor children: Y.Map<NodeDetail> = new DocCollection.Y.Map();
@watch(watchLayoutType)
@field()
accessor layoutType: LayoutType = LayoutType.RIGHT;
@watch(watchStyle)
@field()
accessor style: MindmapStyle = MindmapStyle.ONE;
}
declare global {
namespace BlockSuite {
interface SurfaceGroupLikeModelMap {
mindmap: MindmapElementModel;
}
}
}
@@ -0,0 +1,504 @@
import { isEqual, last } from '@blocksuite/global/utils';
import { ConnectorMode } from '../../consts/connector.js';
import { LineColor } from '../../consts/line.js';
import { MindmapStyle } from '../../consts/mindmap.js';
import { StrokeStyle } from '../../consts/note.js';
import { ShapeFillColor } from '../../consts/shape.js';
import { FontFamily, FontWeight, TextResizing } from '../../consts/text.js';
import type { MindmapNode } from './mindmap.js';
export type CollapseButton = {
width: number;
height: number;
radius: number;
filled: boolean;
fillColor: string;
strokeColor: string;
strokeWidth: number;
};
export type ExpandButton = CollapseButton & {
fontFamily: FontFamily;
fontSize: number;
fontWeight: FontWeight;
color: string;
};
export type NodeStyle = {
radius: number;
strokeWidth: number;
strokeColor: string;
textResizing: TextResizing;
fontSize: number;
fontFamily: string;
fontWeight: FontWeight;
color: string;
filled: boolean;
fillColor: string;
padding: [number, number];
shadow?: {
blur: number;
offsetX: number;
offsetY: number;
color: string;
};
};
export type ConnectorStyle = {
strokeStyle: StrokeStyle;
stroke: string;
strokeWidth: number;
mode: ConnectorMode;
};
export abstract class MindmapStyleGetter {
abstract readonly root: NodeStyle;
abstract getNodeStyle(
node: MindmapNode,
path: number[]
): {
connector: ConnectorStyle;
collapseButton: CollapseButton;
expandButton: ExpandButton;
node: NodeStyle;
};
}
export class StyleOne extends MindmapStyleGetter {
private _colorOrders = [
LineColor.Purple,
LineColor.Magenta,
LineColor.Orange,
LineColor.Yellow,
LineColor.Green,
'#7ae2d5',
];
readonly root = {
radius: 8,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
strokeWidth: 4,
strokeColor: '#84CFFF',
fontFamily: FontFamily.Poppins,
fontSize: 20,
fontWeight: FontWeight.SemiBold,
color: '--affine-black',
filled: true,
fillColor: '--affine-white',
padding: [11, 22] as [number, number],
shadow: {
offsetX: 0,
offsetY: 6,
blur: 12,
color: 'rgba(0, 0, 0, 0.14)',
},
};
private _getColor(number: number) {
return this._colorOrders[number % this._colorOrders.length];
}
getNodeStyle(_: MindmapNode, path: number[]) {
const color = this._getColor(path[1] ?? 0);
return {
connector: {
strokeStyle: StrokeStyle.Solid,
stroke: color,
strokeWidth: 3,
mode: ConnectorMode.Curve,
},
collapseButton: {
width: 16,
height: 16,
radius: 0.5,
filled: true,
fillColor: '--affine-white',
strokeColor: color,
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 8,
filled: true,
fillColor: color,
strokeColor: color,
strokeWidth: 0,
padding: [4, 0],
color: '--affine-white',
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Bold,
fontSize: 15,
},
node: {
radius: 8,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
strokeWidth: 3,
strokeColor: color,
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.Medium,
color: '--affine-black',
filled: true,
fillColor: '--affine-white',
padding: [6, 22] as [number, number],
shadow: {
offsetX: 0,
offsetY: 6,
blur: 12,
color: 'rgba(0, 0, 0, 0.14)',
},
},
};
}
}
export const styleOne = new StyleOne();
export class StyleTwo extends MindmapStyleGetter {
private _colorOrders = [
ShapeFillColor.Blue,
'#7ae2d5',
ShapeFillColor.Yellow,
];
readonly root = {
radius: 3,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
strokeWidth: 3,
strokeColor: '--affine-black',
fontFamily: FontFamily.Poppins,
fontSize: 18,
fontWeight: FontWeight.SemiBold,
color: ShapeFillColor.Black,
filled: true,
fillColor: ShapeFillColor.Orange,
padding: [11, 22] as [number, number],
shadow: {
blur: 0,
offsetX: 3,
offsetY: 3,
color: '--affine-black',
},
};
private _getColor(number: number) {
return number >= this._colorOrders.length
? last(this._colorOrders)!
: this._colorOrders[number];
}
getNodeStyle(_: MindmapNode, path: number[]) {
const color = this._getColor(path.length - 2);
return {
connector: {
strokeStyle: StrokeStyle.Solid,
stroke: '--affine-black',
strokeWidth: 3,
mode: ConnectorMode.Orthogonal,
},
collapseButton: {
width: 16,
height: 16,
radius: 0.5,
filled: true,
fillColor: '--affine-white',
strokeColor: '--affine-black',
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 2,
filled: true,
fillColor: '--affine-black',
padding: [4, 0],
strokeColor: '--affine-black',
strokeWidth: 0,
color: '--affine-white',
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Bold,
fontSize: 15,
},
node: {
radius: 3,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
strokeWidth: 3,
strokeColor: '--affine-black',
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.SemiBold,
color: ShapeFillColor.Black,
filled: true,
fillColor: color,
padding: [6, 22] as [number, number],
shadow: {
blur: 0,
offsetX: 3,
offsetY: 3,
color: '--affine-black',
},
},
};
}
}
export const styleTwo = new StyleTwo();
export class StyleThree extends MindmapStyleGetter {
private _strokeColor = [LineColor.Yellow, LineColor.Green, LineColor.Teal];
readonly root = {
radius: 10,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
strokeWidth: 0,
strokeColor: 'transparent',
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.Medium,
color: ShapeFillColor.Black,
filled: true,
fillColor: ShapeFillColor.Yellow,
padding: [10, 22] as [number, number],
shadow: {
blur: 12,
offsetX: 0,
offsetY: 0,
color: 'rgba(66, 65, 73, 0.18)',
},
};
private _getColor(number: number) {
return this._strokeColor[number % this._strokeColor.length];
}
override getNodeStyle(_: MindmapNode, path: number[]) {
const strokeColor = this._getColor(path.length - 2);
return {
node: {
radius: 10,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
strokeWidth: 2,
strokeColor: strokeColor,
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.Medium,
color: ShapeFillColor.Black,
filled: true,
fillColor: ShapeFillColor.White,
padding: [6, 22] as [number, number],
shadow: {
blur: 12,
offsetX: 0,
offsetY: 0,
color: 'rgba(66, 65, 73, 0.18)',
},
},
collapseButton: {
width: 16,
height: 16,
radius: 0.5,
filled: true,
fillColor: '--affine-white',
strokeColor: '#3CBC36',
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 8,
filled: true,
fillColor: '#3CBC36',
padding: [4, 0],
strokeColor: '#3CBC36',
strokeWidth: 0,
color: '#fff',
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Bold,
fontSize: 15,
},
connector: {
strokeStyle: StrokeStyle.Solid,
stroke: strokeColor,
strokeWidth: 2,
mode: ConnectorMode.Curve,
},
};
}
}
export const styleThree = new StyleThree();
export class StyleFour extends MindmapStyleGetter {
private _colors = [
ShapeFillColor.Purple,
ShapeFillColor.Magenta,
ShapeFillColor.Orange,
ShapeFillColor.Yellow,
ShapeFillColor.Green,
ShapeFillColor.Blue,
];
readonly root = {
radius: 0,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
strokeWidth: 0,
strokeColor: 'transparent',
fontFamily: FontFamily.Kalam,
fontSize: 22,
fontWeight: FontWeight.Bold,
color: '--affine-black',
filled: true,
fillColor: 'transparent',
padding: [0, 10] as [number, number],
};
private _getColor(order: number) {
return this._colors[order % this._colors.length];
}
getNodeStyle(_: MindmapNode, path: number[]) {
const stroke = this._getColor(path[1] ?? 0);
return {
connector: {
strokeStyle: StrokeStyle.Solid,
stroke,
strokeWidth: 3,
mode: ConnectorMode.Curve,
},
collapseButton: {
width: 16,
height: 16,
radius: 0.5,
filled: true,
fillColor: '--affine-white',
strokeColor: stroke,
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 8,
filled: true,
fillColor: stroke,
padding: [4, 0],
strokeColor: stroke,
strokeWidth: 0,
color: '--affine-white',
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Bold,
fontSize: 15,
},
node: {
...this.root,
fontSize: 18,
padding: [1.5, 10] as [number, number],
},
};
}
}
export const styleFour = new StyleFour();
export const mindmapStyleGetters: Record<MindmapStyle, MindmapStyleGetter> = {
[MindmapStyle.ONE]: styleOne,
[MindmapStyle.TWO]: styleTwo,
[MindmapStyle.THREE]: styleThree,
[MindmapStyle.FOUR]: styleFour,
};
export const applyNodeStyle = (node: MindmapNode, nodeStyle: NodeStyle) => {
Object.entries(nodeStyle).forEach(([key, value]) => {
// @ts-expect-error FIXME: ts error
if (!isEqual(node.element[key], value)) {
// @ts-expect-error FIXME: ts error
node.element[key] = value;
}
});
};
@@ -0,0 +1,46 @@
import type { MindmapNode } from './mindmap.js';
export function findInfiniteLoop(
root: MindmapNode,
nodeMap: Map<string, MindmapNode>
) {
const visited = new Set<string>();
const loop: {
detached: boolean;
chain: MindmapNode[];
}[] = [];
const traverse = (
node: MindmapNode,
traverseChain: MindmapNode[] = [],
detached = false
) => {
if (visited.has(node.id)) {
loop.push({
detached,
chain: traverseChain,
});
return;
}
visited.add(node.id);
traverseChain.push(node);
node.children.forEach(child =>
traverse(child, traverseChain.slice(), detached)
);
};
traverse(root);
nodeMap.forEach(node => {
if (visited.has(node.id)) {
return;
}
traverse(node, [], true);
});
return loop;
}
@@ -0,0 +1,120 @@
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
import type { IBound, IVec } from '@blocksuite/global/utils';
import {
Bound,
getCenterAreaBounds,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
PointLocation,
pointOnPolygonStoke,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/utils';
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
import type { ShapeElementModel } from '../shape.js';
export const diamond = {
points({ x, y, w, h }: IBound): IVec[] {
return [
[x, y + h / 2],
[x + w / 2, y],
[x + w, y + h / 2],
[x + w / 2, y + h],
];
},
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
const cx = x + w / 2;
const cy = y + h / 2;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((rotate * Math.PI) / 180);
ctx.translate(-cx, -cy);
ctx.beginPath();
ctx.moveTo(x, y + h / 2);
ctx.lineTo(x + w / 2, y);
ctx.lineTo(x + w, y + h / 2);
ctx.lineTo(x + w / 2, y + h);
ctx.closePath();
ctx.restore();
},
includesPoint(
this: ShapeElementModel,
x: number,
y: number,
options: PointTestOptions
) {
const point: IVec = [x, y];
const points = getPointsFromBoundWithRotation(this, diamond.points);
let hit = pointOnPolygonStoke(
point,
points,
(options?.hitThreshold ?? 1) / (options.zoom ?? 1)
);
if (!hit) {
if (!options.ignoreTransparent || this.filled) {
hit = pointInPolygon([x, y], points);
} else {
// If shape is not filled or transparent
const text = this.text;
if (!text || !text.length) {
// Check the center area of the shape
const centralBounds = getCenterAreaBounds(
this,
DEFAULT_CENTRAL_AREA_RATIO
);
const centralPoints = getPointsFromBoundWithRotation(
centralBounds,
diamond.points
);
hit = pointInPolygon(point, centralPoints);
} else if (this.textBound) {
hit = pointInPolygon(
point,
getPointsFromBoundWithRotation(
this,
() => Bound.from(this.textBound!).points
)
);
}
}
}
return hit;
},
containsBound(bounds: Bound, element: ShapeElementModel) {
const points = getPointsFromBoundWithRotation(element, diamond.points);
return points.some(point => bounds.containsPoint(point));
},
getNearestPoint(point: IVec, element: ShapeElementModel) {
const points = getPointsFromBoundWithRotation(element, diamond.points);
return polygonNearestPoint(points, point);
},
getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) {
const points = getPointsFromBoundWithRotation(element, diamond.points);
return linePolygonIntersects(start, end, points);
},
getRelativePointLocation(position: IVec, element: ShapeElementModel) {
const bound = Bound.deserialize(element.xywh);
const point = bound.getRelativePoint(position);
let points = diamond.points(bound);
points.push(point);
points = rotatePoints(points, bound.center, element.rotate);
const rotatePoint = points.pop() as IVec;
const tangent = polygonGetPointTangent(points, rotatePoint);
return new PointLocation(rotatePoint, tangent);
},
};
@@ -0,0 +1,202 @@
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
import {
Bound,
clamp,
getPointsFromBoundWithRotation,
type IBound,
type IVec,
lineEllipseIntersects,
pointInEllipse,
pointInPolygon,
PointLocation,
rotatePoints,
toRadian,
Vec,
} from '@blocksuite/global/utils';
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
import type { ShapeElementModel } from '../shape.js';
export const ellipse = {
points({ x, y, w, h }: IBound): IVec[] {
return [
[x, y + h / 2],
[x + w / 2, y],
[x + w, y + h / 2],
[x + w / 2, y + h],
];
},
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
const cx = x + w / 2;
const cy = y + h / 2;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((rotate * Math.PI) / 180);
ctx.translate(-cx, -cy);
ctx.beginPath();
ctx.ellipse(cx, cy, w / 2, h / 2, 0, 0, 2 * Math.PI);
ctx.restore();
},
includesPoint(
this: ShapeElementModel,
x: number,
y: number,
options: PointTestOptions
) {
const point: IVec = [x, y];
const expand = (options?.hitThreshold ?? 1) / (options?.zoom ?? 1);
const rx = this.w / 2;
const ry = this.h / 2;
const center: IVec = [this.x + rx, this.y + ry];
const rad = (this.rotate * Math.PI) / 180;
let hit =
pointInEllipse(point, center, rx + expand, ry + expand, rad) &&
!pointInEllipse(point, center, rx - expand, ry - expand, rad);
if (!hit) {
if (!options.ignoreTransparent || this.filled) {
hit = pointInEllipse(point, center, rx, ry, rad);
} else {
// If shape is not filled or transparent
const text = this.text;
if (!text || !text.length) {
// Check the center area of the shape
const centralRx = rx * DEFAULT_CENTRAL_AREA_RATIO;
const centralRy = ry * DEFAULT_CENTRAL_AREA_RATIO;
hit = pointInEllipse(point, center, centralRx, centralRy, rad);
} else if (this.textBound) {
hit = pointInPolygon(
point,
getPointsFromBoundWithRotation(
this,
() => Bound.from(this.textBound!).points
)
);
}
}
}
return hit;
},
containsBound(bounds: Bound, element: ShapeElementModel): boolean {
const points = getPointsFromBoundWithRotation(element, ellipse.points);
return points.some(point => bounds.containsPoint(point));
},
// See links:
// * https://github.com/0xfaded/ellipse_demo/issues/1
// * https://blog.chatfield.io/simple-method-for-distance-to-ellipse/
// * https://gist.github.com/fundon/11331322d3ca223c42e216df48c339e1
// * https://github.com/excalidraw/excalidraw/blob/master/packages/utils/geometry/geometry.ts#L888 (MIT)
getNearestPoint(point: IVec, { rotate, xywh }: ShapeElementModel) {
const { center, w, h } = Bound.deserialize(xywh);
const rad = toRadian(rotate);
const a = w / 2;
const b = h / 2;
// Use the center of the ellipse as the origin
const [rotatedPointX, rotatedPointY] = Vec.rot(
Vec.sub(point, center),
-rad
);
const px = Math.abs(rotatedPointX);
const py = Math.abs(rotatedPointY);
let tx = Math.SQRT1_2; // 0.707
let ty = Math.SQRT1_2; // 0.707
let i = 0;
for (; i < 3; i++) {
const x = a * tx;
const y = b * ty;
const ex = ((a * a - b * b) * tx ** 3) / a;
const ey = ((b * b - a * a) * ty ** 3) / b;
const rx = x - ex;
const ry = y - ey;
const qx = px - ex;
const qy = py - ey;
const r = Math.hypot(ry, rx);
const q = Math.hypot(qy, qx);
tx = clamp(((qx * r) / q + ex) / a, 0, 1);
ty = clamp(((qy * r) / q + ey) / b, 0, 1);
const t = Math.hypot(ty, tx);
tx /= t;
ty /= t;
}
return Vec.add(
Vec.rot(
[a * tx * Math.sign(rotatedPointX), b * ty * Math.sign(rotatedPointY)],
rad
),
center
);
},
getLineIntersections(
start: IVec,
end: IVec,
{ rotate, xywh }: ShapeElementModel
) {
const rad = toRadian(rotate);
const bound = Bound.deserialize(xywh);
return lineEllipseIntersects(
start,
end,
bound.center,
bound.w / 2,
bound.h / 2,
rad
);
},
getRelativePointLocation(
relativePoint: IVec,
{ rotate, xywh }: ShapeElementModel
) {
const bounds = Bound.deserialize(xywh);
const point = bounds.getRelativePoint(relativePoint);
const { x, y, w, h, center } = bounds;
const points = rotatePoints(
[
[x, y],
[x + w / 2, y],
[x + w, y],
[x + w, y + h / 2],
[x + w, y + h],
[x + w / 2, y + h],
[x, y + h],
[x, y + h / 2],
point,
],
center,
rotate
);
const rotatedPoint = points.pop() as IVec;
const len = points.length;
let tangent: IVec = [0, 0.5];
let i = 0;
for (; i < len; i++) {
const p0 = points[i];
const p1 = points[(i + 1) % len];
const bounds = Bound.fromPoints([p0, p1, center]);
if (bounds.containsPoint(rotatedPoint)) {
tangent = Vec.normalize(Vec.sub(p1, p0));
break;
}
}
return new PointLocation(rotatedPoint, tangent);
},
};
@@ -0,0 +1,12 @@
import type { ShapeType } from '../../../consts/shape.js';
import { diamond } from './diamond.js';
import { ellipse } from './ellipse.js';
import { rect } from './rect.js';
import { triangle } from './triangle.js';
export const shapeMethods: Record<ShapeType, typeof rect> = {
rect,
triangle,
ellipse,
diamond,
};
@@ -0,0 +1,119 @@
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
import type { IBound, IVec } from '@blocksuite/global/utils';
import {
Bound,
getCenterAreaBounds,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
PointLocation,
pointOnPolygonStoke,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/utils';
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
import type { ShapeElementModel } from '../shape.js';
export const rect = {
points({ x, y, w, h }: IBound) {
return [
[x, y],
[x + w, y],
[x + w, y + h],
[x, y + h],
];
},
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
ctx.save();
ctx.translate(x + w / 2, y + h / 2);
ctx.rotate((rotate * Math.PI) / 180);
ctx.translate(-x - w / 2, -y - h / 2);
ctx.rect(x, y, w, h);
ctx.restore();
},
includesPoint(
this: ShapeElementModel,
x: number,
y: number,
options: PointTestOptions
) {
const point: IVec = [x, y];
const points = getPointsFromBoundWithRotation(
this,
undefined,
options.responsePadding
);
let hit = pointOnPolygonStoke(
point,
points,
(options?.hitThreshold ?? 1) / (options.zoom ?? 1)
);
if (!hit) {
// If the point is not on the stroke, check if it is in the shape
// When the shape is filled and transparent is not ignored
if (!options.ignoreTransparent || this.filled) {
hit = pointInPolygon([x, y], points);
} else {
// If shape is not filled or transparent
// Check if hit the text area
const text = this.text;
if (!text || !text.length) {
// if not, check the default center area of the shape
const centralBounds = getCenterAreaBounds(
this,
DEFAULT_CENTRAL_AREA_RATIO
);
const centralPoints = getPointsFromBoundWithRotation(centralBounds);
// Check if the point is in the center area
hit = pointInPolygon([x, y], centralPoints);
} else if (this.textBound) {
hit = pointInPolygon(
point,
getPointsFromBoundWithRotation(
this,
() => Bound.from(this.textBound!).points
)
);
}
}
}
return hit;
},
containsBound(bounds: Bound, element: ShapeElementModel): boolean {
const points = getPointsFromBoundWithRotation(element);
return points.some(point => bounds.containsPoint(point));
},
getNearestPoint(point: IVec, element: ShapeElementModel) {
const points = getPointsFromBoundWithRotation(element);
return polygonNearestPoint(points, point);
},
getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) {
const points = getPointsFromBoundWithRotation(element);
return linePolygonIntersects(start, end, points);
},
getRelativePointLocation(relativePoint: IVec, element: ShapeElementModel) {
const bound = Bound.deserialize(element.xywh);
const point = bound.getRelativePoint(relativePoint);
const rotatePoint = rotatePoints(
[point],
bound.center,
element.rotate ?? 0
)[0];
const points = rotatePoints(
bound.points,
bound.center,
element.rotate ?? 0
);
const tangent = polygonGetPointTangent(points, rotatePoint);
return new PointLocation(rotatePoint, tangent);
},
};
@@ -0,0 +1,116 @@
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
import type { IBound, IVec } from '@blocksuite/global/utils';
import {
Bound,
getCenterAreaBounds,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
PointLocation,
pointOnPolygonStoke,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/utils';
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
import type { ShapeElementModel } from '../shape.js';
export const triangle = {
points({ x, y, w, h }: IBound): IVec[] {
return [
[x, y + h],
[x + w / 2, y],
[x + w, y + h],
];
},
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
const cx = x + w / 2;
const cy = y + h / 2;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((rotate * Math.PI) / 180);
ctx.translate(-cx, -cy);
ctx.beginPath();
ctx.moveTo(x, y + h);
ctx.lineTo(x + w / 2, y);
ctx.lineTo(x + w, y + h);
ctx.closePath();
ctx.restore();
},
includesPoint(
this: ShapeElementModel,
x: number,
y: number,
options: PointTestOptions
) {
const point: IVec = [x, y];
const points = getPointsFromBoundWithRotation(this, triangle.points);
let hit = pointOnPolygonStoke(
point,
points,
(options?.hitThreshold ?? 1) / (options?.zoom ?? 1)
);
if (!hit) {
if (!options.ignoreTransparent || this.filled) {
hit = pointInPolygon([x, y], points);
} else {
// If shape is not filled or transparent
const text = this.text;
if (!text || !text.length) {
// Check the center area of the shape
const centralBounds = getCenterAreaBounds(
this,
DEFAULT_CENTRAL_AREA_RATIO
);
const centralPoints = getPointsFromBoundWithRotation(
centralBounds,
triangle.points
);
hit = pointInPolygon([x, y], centralPoints);
} else if (this.textBound) {
hit = pointInPolygon(
point,
getPointsFromBoundWithRotation(
this,
() => Bound.from(this.textBound!).points
)
);
}
}
}
return hit;
},
containsBound(bounds: Bound, element: ShapeElementModel): boolean {
const points = getPointsFromBoundWithRotation(element, triangle.points);
return points.some(point => bounds.containsPoint(point));
},
getNearestPoint(point: IVec, element: ShapeElementModel) {
const points = getPointsFromBoundWithRotation(element, triangle.points);
return polygonNearestPoint(points, point);
},
getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) {
const points = getPointsFromBoundWithRotation(element, triangle.points);
return linePolygonIntersects(start, end, points);
},
getRelativePointLocation(position: IVec, element: ShapeElementModel) {
const bound = Bound.deserialize(element.xywh);
const point = bound.getRelativePoint(position);
let points = triangle.points(bound);
points.push(point);
points = rotatePoints(points, bound.center, element.rotate);
const rotatePoint = points.pop() as IVec;
const tangent = polygonGetPointTangent(points, rotatePoint);
return new PointLocation(rotatePoint, tangent);
},
};
@@ -0,0 +1,2 @@
export * from './api/index.js';
export * from './shape.js';
@@ -0,0 +1,277 @@
import type {
BaseElementProps,
PointTestOptions,
} from '@blocksuite/block-std/gfx';
import {
field,
GfxLocalElementModel,
GfxPrimitiveElementModel,
local,
prop,
} from '@blocksuite/block-std/gfx';
import type {
Bound,
IBound,
IVec,
PointLocation,
SerializedXYWH,
} from '@blocksuite/global/utils';
import { DocCollection, type Y } from '@blocksuite/store';
import {
type Color,
DEFAULT_ROUGHNESS,
FontFamily,
FontStyle,
FontWeight,
LineColor,
ShapeFillColor,
ShapeStyle,
ShapeTextFontSize,
ShapeType,
StrokeStyle,
TextAlign,
TextResizing,
type TextStyleProps,
TextVerticalAlign,
} from '../../consts/index.js';
import { shapeMethods } from './api/index.js';
export type ShapeProps = BaseElementProps & {
shapeType: ShapeType;
radius: number;
filled: boolean;
fillColor: Color;
strokeWidth: number;
strokeColor: Color;
strokeStyle: StrokeStyle;
shapeStyle: ShapeStyle;
// https://github.com/rough-stuff/rough/wiki#roughness
roughness?: number;
text?: Y.Text;
textHorizontalAlign?: TextAlign;
textVerticalAlign?: TextVerticalAlign;
textResizing?: TextResizing;
maxWidth?: false | number;
} & Partial<TextStyleProps>;
export const SHAPE_TEXT_PADDING = 20;
export const SHAPE_TEXT_VERTICAL_PADDING = 10;
export class ShapeElementModel extends GfxPrimitiveElementModel<ShapeProps> {
/**
* The bound of the text content.
*/
textBound: IBound | null = null;
get type() {
return 'shape';
}
static override propsToY(props: ShapeProps) {
if (props.text && !(props.text instanceof DocCollection.Y.Text)) {
props.text = new DocCollection.Y.Text(props.text);
}
return props;
}
override containsBound(bounds: Bound) {
return shapeMethods[this.shapeType].containsBound(bounds, this);
}
override getLineIntersections(start: IVec, end: IVec) {
return shapeMethods[this.shapeType].getLineIntersections(start, end, this);
}
override getNearestPoint(point: IVec): IVec {
return shapeMethods[this.shapeType].getNearestPoint(point, this) as IVec;
}
override getRelativePointLocation(point: IVec): PointLocation {
return shapeMethods[this.shapeType].getRelativePointLocation(point, this);
}
override includesPoint(x: number, y: number, options: PointTestOptions) {
return shapeMethods[this.shapeType].includesPoint.call(this, x, y, {
...options,
ignoreTransparent: options.ignoreTransparent ?? true,
});
}
@field('#000000' as Color)
accessor color!: Color;
@field()
accessor fillColor: Color = ShapeFillColor.Yellow;
@field()
accessor filled: boolean = false;
@field(FontFamily.Inter as string)
accessor fontFamily!: string;
@field(ShapeTextFontSize.MEDIUM)
accessor fontSize!: number;
@field(FontStyle.Normal as FontStyle)
accessor fontStyle!: FontStyle;
@field(FontWeight.Regular as FontWeight)
accessor fontWeight!: FontWeight;
@field(false as false | number)
accessor maxWidth: false | number = false;
@field([SHAPE_TEXT_VERTICAL_PADDING, SHAPE_TEXT_PADDING])
accessor padding: [number, number] = [
SHAPE_TEXT_VERTICAL_PADDING,
SHAPE_TEXT_PADDING,
];
@field()
accessor radius: number = 0;
@field(0)
accessor rotate: number = 0;
@field(DEFAULT_ROUGHNESS)
accessor roughness: number = DEFAULT_ROUGHNESS;
@field()
accessor shadow: {
/**
* @deprecated Since the shadow blur will reduce the performance of canvas rendering,
* we already disable the shadow blur rendering by default, so set this field will not take effect.
* You can enable it by setting the flag `enable_shape_shadow_blur` in the awareness store.
* https://web.dev/articles/canvas-performance#avoid_shadowblur
*/
blur: number;
offsetX: number;
offsetY: number;
color: string;
} | null = null;
@field()
accessor shapeStyle: ShapeStyle = ShapeStyle.General;
@field()
accessor shapeType: ShapeType = ShapeType.Rect;
@field()
accessor strokeColor: Color = LineColor.Yellow;
@field()
accessor strokeStyle: StrokeStyle = StrokeStyle.Solid;
@field()
accessor strokeWidth: number = 4;
@field()
accessor text: Y.Text | undefined = undefined;
@field(TextAlign.Center as TextAlign)
accessor textAlign!: TextAlign;
@local()
accessor textDisplay: boolean = true;
@field(TextAlign.Center as TextAlign)
accessor textHorizontalAlign!: TextAlign;
@field(TextResizing.AUTO_HEIGHT as TextResizing)
accessor textResizing: TextResizing = TextResizing.AUTO_HEIGHT;
@field(TextVerticalAlign.Center as TextVerticalAlign)
accessor textVerticalAlign!: TextVerticalAlign;
@field()
accessor xywh: SerializedXYWH = '[0,0,100,100]';
}
export class LocalShapeElementModel extends GfxLocalElementModel {
roughness: number = DEFAULT_ROUGHNESS;
textBound: Bound | null = null;
textDisplay: boolean = true;
get type() {
return 'shape';
}
@prop()
accessor color: Color = '#000000';
@prop()
accessor fillColor: Color = ShapeFillColor.Yellow;
@prop()
accessor filled: boolean = false;
@prop()
accessor fontFamily: string = FontFamily.Inter;
@prop()
accessor fontSize: number = 16;
@prop()
accessor fontStyle: FontStyle = FontStyle.Normal;
@prop()
accessor fontWeight: FontWeight = FontWeight.Regular;
@prop()
accessor padding: [number, number] = [
SHAPE_TEXT_VERTICAL_PADDING,
SHAPE_TEXT_PADDING,
];
@prop()
accessor radius: number = 0;
@prop()
accessor shadow: {
blur: number;
offsetX: number;
offsetY: number;
color: string;
} | null = null;
@prop()
accessor shapeStyle: ShapeStyle = ShapeStyle.General;
@prop()
accessor shapeType: ShapeType = ShapeType.Rect;
@prop()
accessor strokeColor: Color = LineColor.Yellow;
@prop()
accessor strokeStyle: StrokeStyle = StrokeStyle.Solid;
@prop()
accessor strokeWidth: number = 4;
@prop()
accessor text: string = '';
@prop()
accessor textAlign: TextAlign = TextAlign.Center;
@prop()
accessor textVerticalAlign: TextVerticalAlign = TextVerticalAlign.Center;
}
declare global {
namespace BlockSuite {
interface SurfaceElementModelMap {
shape: ShapeElementModel;
}
interface EdgelessTextModelMap {
shape: ShapeElementModel;
}
}
}
@@ -0,0 +1 @@
export * from './text.js';
@@ -0,0 +1,104 @@
import type { BaseElementProps } from '@blocksuite/block-std/gfx';
import { field, GfxPrimitiveElementModel } from '@blocksuite/block-std/gfx';
import type { IVec, SerializedXYWH } from '@blocksuite/global/utils';
import {
Bound,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
polygonNearestPoint,
} from '@blocksuite/global/utils';
import { DocCollection, type Y } from '@blocksuite/store';
import {
type Color,
FontFamily,
FontStyle,
FontWeight,
TextAlign,
type TextStyleProps,
} from '../../consts/index.js';
export type TextElementProps = BaseElementProps & {
text: Y.Text;
hasMaxWidth?: boolean;
} & Omit<TextStyleProps, 'fontWeight' | 'fontStyle'> &
Partial<Pick<TextStyleProps, 'fontWeight' | 'fontStyle'>>;
export class TextElementModel extends GfxPrimitiveElementModel<TextElementProps> {
get type() {
return 'text';
}
static override propsToY(props: Record<string, unknown>) {
if (props.text && !(props.text instanceof DocCollection.Y.Text)) {
props.text = new DocCollection.Y.Text(props.text as string);
}
return props;
}
override containsBound(bounds: Bound): boolean {
const points = getPointsFromBoundWithRotation(this);
return points.some(point => bounds.containsPoint(point));
}
override getLineIntersections(start: IVec, end: IVec) {
const points = getPointsFromBoundWithRotation(this);
return linePolygonIntersects(start, end, points);
}
override getNearestPoint(point: IVec): IVec {
return polygonNearestPoint(
Bound.deserialize(this.xywh).points,
point
) as IVec;
}
override includesPoint(x: number, y: number): boolean {
const points = getPointsFromBoundWithRotation(this);
return pointInPolygon([x, y], points);
}
@field()
accessor color: Color = '#000000';
@field()
accessor fontFamily: FontFamily = FontFamily.Inter;
@field()
accessor fontSize: number = 16;
@field(FontStyle.Normal as FontStyle)
accessor fontStyle: FontStyle = FontStyle.Normal;
@field(FontWeight.Regular as FontWeight)
accessor fontWeight: FontWeight = FontWeight.Regular;
@field(false)
accessor hasMaxWidth: boolean = false;
@field(0)
accessor rotate: number = 0;
@field()
accessor text: Y.Text = new DocCollection.Y.Text();
@field()
accessor textAlign: TextAlign = TextAlign.Center;
@field()
accessor xywh: SerializedXYWH = '[0,0,16,16]';
}
declare global {
namespace BlockSuite {
interface SurfaceElementModelMap {
text: TextElementModel;
}
interface EdgelessTextModelMap {
text: TextElementModel;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './blocks/index.js';
export * from './consts/index.js';
export * from './elements/index.js';
export * from './utils/index.js';
+11
View File
@@ -0,0 +1,11 @@
export function createEnumMap<T extends Record<string, string | number>>(
EnumObject: T
) {
return Object.entries(EnumObject).reduce(
(acc, [key, value]) => {
acc[value as T[keyof T]] = key as keyof T;
return acc;
},
{} as Record<T[keyof T], keyof T>
);
}
@@ -0,0 +1,49 @@
import type {
GfxBlockElementModel,
GfxGroupLikeElementModel,
GfxLocalElementModel,
GfxModel,
GfxPrimitiveElementModel,
} from '@blocksuite/block-std/gfx';
declare global {
namespace BlockSuite {
interface EdgelessBlockModelMap {}
type EdgelessBlockModelKeyType = keyof EdgelessBlockModelMap;
type EdgelessBlockModelType =
| EdgelessBlockModelMap[EdgelessBlockModelKeyType]
| GfxBlockElementModel;
type EdgelessModel = GfxModel;
interface EdgelessTextModelMap {}
type EdgelessTextModelKeyType = keyof EdgelessTextModelMap;
type EdgelessTextModelType = EdgelessTextModelMap[EdgelessTextModelKeyType];
interface SurfaceElementModelMap {}
type SurfaceElementModelKeys = keyof SurfaceElementModelMap;
type SurfaceElementModel =
| SurfaceElementModelMap[SurfaceElementModelKeys]
| GfxPrimitiveElementModel;
interface SurfaceGroupLikeModelMap {}
type SurfaceGroupLikeModelKeys = keyof SurfaceGroupLikeModelMap;
type SurfaceGroupLikeModel =
| SurfaceGroupLikeModelMap[SurfaceGroupLikeModelKeys]
| GfxGroupLikeElementModel;
interface SurfaceLocalModelMap {}
type SurfaceLocalModelKeys = keyof SurfaceLocalModelMap;
type SurfaceLocalModel =
| SurfaceLocalModelMap[SurfaceLocalModelKeys]
| GfxLocalElementModel;
// not include local model
type SurfaceModel = SurfaceElementModel | SurfaceGroupLikeModel;
type SurfaceModelKeyType =
| SurfaceElementModelKeys
| SurfaceGroupLikeModelKeys;
type EdgelessModelKeys = EdgelessBlockModelKeyType | SurfaceModelKeyType;
}
}
@@ -0,0 +1,63 @@
import type { GfxCompatibleProps } from '@blocksuite/block-std/gfx';
import { GfxCompatible } from '@blocksuite/block-std/gfx';
import type { Constructor } from '@blocksuite/global/utils';
import {
type BaseBlockTransformer,
type BlockModel,
defineBlockSchema,
type InternalPrimitives,
} from '@blocksuite/store';
export function defineEmbedModel<
Props extends object,
T extends Constructor<BlockModel<Props>> = Constructor<BlockModel<Props>>,
>(BlockModelSuperClass: T) {
return GfxCompatible<Props & GfxCompatibleProps>(
BlockModelSuperClass as Constructor<BlockModel<Props & GfxCompatibleProps>>
);
}
export type EmbedProps<Props = object> = Props & GfxCompatibleProps;
export type EmbedBlockModel<Props = object> = BlockModel<EmbedProps<Props>>;
export function createEmbedBlockSchema<
Props extends object,
Model extends EmbedBlockModel<Props>,
Transformer extends BaseBlockTransformer<
EmbedProps<Props>
> = BaseBlockTransformer<EmbedProps<Props>>,
>({
name,
version,
toModel,
props,
transformer,
}: {
name: string;
version: number;
toModel: () => Model;
props?: (internalPrimitives: InternalPrimitives) => Props;
transformer?: () => Transformer;
}) {
return defineBlockSchema({
flavour: `affine:embed-${name}`,
props: internalPrimitives => {
const userProps = props?.(internalPrimitives);
return {
index: 'a0',
xywh: '[0,0,0,0]',
lockedBySelf: false,
rotate: 0,
...userProps,
} as unknown as EmbedProps<Props>;
},
metadata: {
version,
role: 'content',
},
toModel,
transformer,
});
}
@@ -0,0 +1,4 @@
export * from './enum.js';
export * from './global.js';
export * from './helper.js';
export * from './types.js';
@@ -0,0 +1,19 @@
export type EmbedCardStyle =
| 'horizontal'
| 'horizontalThin'
| 'list'
| 'vertical'
| 'cube'
| 'cubeThick'
| 'video'
| 'figma'
| 'html'
| 'syncedDoc'
| 'pdf';
export type LinkPreviewData = {
description: string | null;
icon: string | null;
image: string | null;
title: string | null;
};