mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 02:20:19 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './blocks/index.js';
|
||||
export * from './consts/index.js';
|
||||
export * from './elements/index.js';
|
||||
export * from './utils/index.js';
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user