snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
{
"name": "@blocksuite/affine-model",
"description": "Models for BlockSuite in Affine.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lodash-es": "^4.17.23",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,112 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/std/gfx';
import { GfxCompatible } from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import type { BlockMeta, 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 = [
'cubeThick',
'horizontalThin',
'pdf',
'citation',
] as const satisfies EmbedCardStyle[];
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];
footnoteIdentifier: string | null;
comments?: Record<string, boolean>;
} & Omit<GfxCommonBlockProps, 'scale'> &
BlockMeta;
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,
'meta:createdAt': undefined,
'meta:updatedAt': undefined,
'meta:createdBy': undefined,
'meta:updatedBy': undefined,
footnoteIdentifier: null,
comments: undefined,
};
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',
],
children: ['@attachment-viewer'],
},
transformer: transformerConfigs =>
new AttachmentBlockTransformer(transformerConfigs),
toModel: () => new AttachmentBlockModel(),
});
export const AttachmentBlockSchemaExtension = BlockSchemaExtension(
AttachmentBlockSchema
);
export class AttachmentBlockModel
extends GfxCompatible<AttachmentBlockProps>(BlockModel)
implements GfxElementGeometry {}

View File

@@ -0,0 +1,34 @@
import type {
BlockSnapshotLeaf,
FromSnapshotPayload,
SnapshotNode,
ToSnapshotPayload,
} 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;
}
override toSnapshot(
snapshot: ToSnapshotPayload<AttachmentBlockProps>
): BlockSnapshotLeaf {
const snapshotRet = super.toSnapshot(snapshot);
const sourceId = snapshot.model.props.sourceId;
if (sourceId) {
const pathBlobIdMap = snapshot.assets.getPathBlobIdMap();
pathBlobIdMap.set(snapshot.model.id, sourceId);
}
return snapshotRet;
}
}

View File

@@ -0,0 +1,2 @@
export * from './attachment-model.js';
export * from './attachment-transformer.js';

View File

@@ -0,0 +1,81 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/std/gfx';
import { GfxCompatible } from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import type {
BlockMeta,
EmbedCardStyle,
LinkPreviewData,
} from '../../utils/index.js';
export const BookmarkStyles = [
'vertical',
'horizontal',
'list',
'cube',
'citation',
] as const satisfies EmbedCardStyle[];
export type BookmarkBlockProps = {
style: (typeof BookmarkStyles)[number];
url: string;
caption: string | null;
footnoteIdentifier: string | null;
comments?: Record<string, boolean>;
} & LinkPreviewData &
Omit<GfxCommonBlockProps, 'scale'> &
BlockMeta;
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,
'meta:createdAt': undefined,
'meta:updatedAt': undefined,
'meta:createdBy': undefined,
'meta:updatedBy': undefined,
footnoteIdentifier: null,
comments: undefined,
};
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 const BookmarkBlockSchemaExtension =
BlockSchemaExtension(BookmarkBlockSchema);
export class BookmarkBlockModel
extends GfxCompatible<BookmarkBlockProps>(BlockModel)
implements GfxElementGeometry {}

View File

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

View File

@@ -0,0 +1,47 @@
import type { IconData } from '@blocksuite/affine-shared/services';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
type Text,
} from '@blocksuite/store';
import type { BlockMeta } from '../../utils/types';
export type CalloutProps = {
icon?: IconData;
text: Text;
backgroundColorName?: string;
} & BlockMeta;
export const CalloutBlockSchema = defineBlockSchema({
flavour: 'affine:callout',
props: (internal): CalloutProps => ({
icon: { type: 'emoji', unicode: '💡' } as IconData,
text: internal.Text(),
backgroundColorName: 'grey',
'meta:createdAt': undefined,
'meta:updatedAt': undefined,
'meta:createdBy': undefined,
'meta:updatedBy': undefined,
}),
metadata: {
version: 1,
role: 'hub',
parent: [
'affine:note',
'affine:database',
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
'affine:transcription',
],
children: ['affine:paragraph', 'affine:list'],
},
toModel: () => new CalloutBlockModel(),
});
export class CalloutBlockModel extends BlockModel<CalloutProps> {}
export const CalloutBlockSchemaExtension =
BlockSchemaExtension(CalloutBlockSchema);

View File

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

View File

@@ -0,0 +1,54 @@
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
type Text,
} from '@blocksuite/store';
import type { BlockMeta } from '../../utils/types';
type CodeBlockProps = {
text: Text;
language: string | null;
wrap: boolean;
caption: string;
preview?: boolean;
lineNumber?: boolean;
collapsed?: boolean;
comments?: Record<string, boolean>;
} & BlockMeta;
export const CodeBlockSchema = defineBlockSchema({
flavour: 'affine:code',
props: internal =>
({
text: internal.Text(),
language: null,
wrap: false,
caption: '',
preview: undefined,
lineNumber: undefined,
collapsed: undefined,
comments: undefined,
'meta:createdAt': undefined,
'meta:createdBy': undefined,
'meta:updatedAt': undefined,
'meta:updatedBy': undefined,
}) as CodeBlockProps,
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
],
children: [],
},
toModel: () => new CodeBlockModel(),
});
export const CodeBlockSchemaExtension = BlockSchemaExtension(CodeBlockSchema);
export class CodeBlockModel extends BlockModel<CodeBlockProps> {}

View File

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

View File

@@ -0,0 +1,43 @@
import type { Text } from '@blocksuite/store';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import type {
ColumnDataType,
SerializedCells,
ViewBasicDataType,
} from './types.js';
export type DatabaseBlockProps = {
views: ViewBasicDataType[];
title: Text;
cells: SerializedCells;
columns: Array<ColumnDataType>;
comments?: Record<string, boolean>;
};
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: [],
comments: undefined,
}),
metadata: {
role: 'hub',
version: 3,
parent: ['affine:note'],
children: ['affine:paragraph', 'affine:list'],
},
toModel: () => new DatabaseBlockModel(),
});
export const DatabaseBlockSchemaExtension =
BlockSchemaExtension(DatabaseBlockSchema);

View File

@@ -0,0 +1,2 @@
export * from './database-model.js';
export * from './types.js';

View File

@@ -0,0 +1,23 @@
export interface ColumnDataType<
Data extends Record<string, unknown> = Record<string, unknown>,
> {
id: string;
type: string;
name: string;
data: Data;
}
export type ColumnUpdater<T extends ColumnDataType = ColumnDataType> = (
data: T
) => Partial<T>;
export type CellDataType<ValueType = unknown> = {
columnId: ColumnDataType['id'];
value: ValueType;
};
export type SerializedCells = Record<string, Record<string, CellDataType>>;
export type ViewBasicDataType = {
id: string;
name: string;
mode: string;
};

View File

@@ -0,0 +1,24 @@
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
export const DividerBlockSchema = defineBlockSchema({
flavour: 'affine:divider',
metadata: {
version: 1,
role: 'content',
children: [],
},
toModel: () => new DividerBlockModel(),
});
type Props = {
text: string;
};
export class DividerBlockModel extends BlockModel<Props> {}
export const DividerBlockSchemaExtension =
BlockSchemaExtension(DividerBlockSchema);

View File

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

View File

@@ -0,0 +1,94 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/std/gfx';
import { GfxCompatible } from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import { z } from 'zod';
import {
FontFamily,
FontFamilySchema,
FontStyle,
FontStyleSchema,
FontWeight,
FontWeightSchema,
TextAlign,
TextAlignSchema,
type TextStyleProps,
} from '../../consts/index';
import { ColorSchema } from '../../themes/color';
import { DefaultTheme } from '../../themes/default';
type EdgelessTextProps = {
hasMaxWidth: boolean;
comments?: Record<string, boolean>;
} & Omit<TextStyleProps, 'fontSize'> &
GfxCommonBlockProps;
export const EdgelessTextZodSchema = z
.object({
color: ColorSchema,
fontFamily: FontFamilySchema,
fontStyle: FontStyleSchema,
fontWeight: FontWeightSchema,
textAlign: TextAlignSchema,
})
.default({
color: DefaultTheme.textColor,
fontFamily: FontFamily.Inter,
fontStyle: FontStyle.Normal,
fontWeight: FontWeight.Regular,
textAlign: TextAlign.Left,
});
export const EdgelessTextBlockSchema = defineBlockSchema({
flavour: 'affine:edgeless-text',
props: (): EdgelessTextProps => ({
xywh: '[0,0,16,16]',
index: 'a0',
lockedBySelf: false,
scale: 1,
rotate: 0,
hasMaxWidth: false,
comments: undefined,
...EdgelessTextZodSchema.parse(undefined),
}),
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: () => new EdgelessTextBlockModel(),
});
export const EdgelessTextBlockSchemaExtension = BlockSchemaExtension(
EdgelessTextBlockSchema
);
export class EdgelessTextBlockModel
extends GfxCompatible<EdgelessTextProps>(BlockModel)
implements GfxElementGeometry
{
get color() {
return this.props.color;
}
set color(color: EdgelessTextProps['color']) {
this.props.color = color;
}
}

View File

@@ -0,0 +1 @@
export * from './edgeless-text-model.js';

View File

@@ -0,0 +1,21 @@
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 = ['figma'] as const satisfies EmbedCardStyle[];
export type EmbedFigmaBlockProps = {
style: (typeof EmbedFigmaStyles)[number];
url: string;
caption: string | null;
} & EmbedFigmaBlockUrlData;
export class EmbedFigmaModel extends defineEmbedModel<EmbedFigmaBlockProps>(
BlockModel
) {}

View File

@@ -0,0 +1,28 @@
import { BlockSchemaExtension } from '@blocksuite/store';
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,
});
export const EmbedFigmaBlockSchemaExtension = BlockSchemaExtension(
EmbedFigmaBlockSchema
);

View File

@@ -0,0 +1,2 @@
export * from './figma-model.js';
export * from './figma-schema.js';

View File

@@ -0,0 +1,35 @@
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 = [
'vertical',
'horizontal',
'list',
'cube',
] as const satisfies EmbedCardStyle[];
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
) {}

View File

@@ -0,0 +1,37 @@
import { BlockSchemaExtension } from '@blocksuite/store';
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,
});
export const EmbedGithubBlockSchemaExtension = BlockSchemaExtension(
EmbedGithubBlockSchema
);

View File

@@ -0,0 +1,2 @@
export * from './github-model.js';
export * from './github-schema.js';

View File

@@ -0,0 +1,17 @@
import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedHtmlStyles = ['html'] as const satisfies EmbedCardStyle[];
export type EmbedHtmlBlockProps = {
style: (typeof EmbedHtmlStyles)[number];
caption: string | null;
html?: string;
design?: string;
};
export class EmbedHtmlModel extends defineEmbedModel<EmbedHtmlBlockProps>(
BlockModel
) {}

View File

@@ -0,0 +1,25 @@
import { BlockSchemaExtension } from '@blocksuite/store';
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,
});
export const EmbedHtmlBlockSchemaExtension =
BlockSchemaExtension(EmbedHtmlBlockSchema);

View File

@@ -0,0 +1,2 @@
export * from './html-model.js';
export * from './html-schema.js';

View File

@@ -0,0 +1,38 @@
import {
type GfxCommonBlockProps,
GfxCompatible,
type GfxElementGeometry,
} from '@blocksuite/std/gfx';
import { BlockModel } from '@blocksuite/store';
import { type EmbedCardStyle } from '../../../utils/index.js';
export const EmbedIframeStyles = ['figma'] as const satisfies EmbedCardStyle[];
export type EmbedIframeBlockProps = {
url: string; // the original url that user input
iframeUrl?: string; // the url that will be used to iframe src
width?: number;
height?: number;
caption: string | null;
title: string | null;
description: string | null;
} & Omit<GfxCommonBlockProps, 'rotate'>;
export const defaultEmbedIframeProps: EmbedIframeBlockProps = {
url: '',
iframeUrl: '',
width: undefined,
height: undefined,
caption: null,
title: null,
description: null,
xywh: '[0,0,0,0]',
index: 'a0',
lockedBySelf: false,
scale: 1,
};
export class EmbedIframeBlockModel
extends GfxCompatible<EmbedIframeBlockProps>(BlockModel)
implements GfxElementGeometry {}

View File

@@ -0,0 +1,17 @@
import { BlockSchemaExtension, defineBlockSchema } from '@blocksuite/store';
import { defaultEmbedIframeProps, EmbedIframeBlockModel } from './iframe-model';
export const EmbedIframeBlockSchema = defineBlockSchema({
flavour: 'affine:embed-iframe',
props: () => defaultEmbedIframeProps,
metadata: {
version: 1,
role: 'content',
},
toModel: () => new EmbedIframeBlockModel(),
});
export const EmbedIframeBlockSchemaExtension = BlockSchemaExtension(
EmbedIframeBlockSchema
);

View File

@@ -0,0 +1,2 @@
export * from './iframe-model';
export * from './iframe-schema';

View File

@@ -0,0 +1,9 @@
export * from './figma/index';
export * from './github/index';
export * from './html/index';
export * from './iframe/index';
export * from './linked-doc/index';
export * from './loom/index';
export * from './synced-doc/index';
export * from './types';
export * from './youtube/index';

View File

@@ -0,0 +1,2 @@
export * from './linked-doc-model.js';
export * from './linked-doc-schema.js';

View File

@@ -0,0 +1,24 @@
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 = [
'vertical',
'horizontal',
'list',
'cube',
'horizontalThin',
'citation',
] as const satisfies EmbedCardStyle[];
export type EmbedLinkedDocBlockProps = {
style: (typeof EmbedLinkedDocStyles)[number];
caption: string | null;
footnoteIdentifier: string | null;
} & ReferenceInfo;
export class EmbedLinkedDocModel extends defineEmbedModel<EmbedLinkedDocBlockProps>(
BlockModel
) {}

View File

@@ -0,0 +1,30 @@
import { BlockSchemaExtension } from '@blocksuite/store';
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,
footnoteIdentifier: null,
};
export const EmbedLinkedDocBlockSchema = createEmbedBlockSchema({
name: 'linked-doc',
version: 1,
toModel: () => new EmbedLinkedDocModel(),
props: (): EmbedLinkedDocBlockProps => defaultEmbedLinkedDocBlockProps,
});
export const EmbedLinkedDocBlockSchemaExtension = BlockSchemaExtension(
EmbedLinkedDocBlockSchema
);

View File

@@ -0,0 +1,2 @@
export * from './loom-model.js';
export * from './loom-schema.js';

View File

@@ -0,0 +1,23 @@
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 = ['video'] as const satisfies EmbedCardStyle[];
export type EmbedLoomBlockProps = {
style: (typeof EmbedLoomStyles)[number];
url: string;
caption: string | null;
} & EmbedLoomBlockUrlData;
export class EmbedLoomModel extends defineEmbedModel<EmbedLoomBlockProps>(
BlockModel
) {}

View File

@@ -0,0 +1,29 @@
import { BlockSchemaExtension } from '@blocksuite/store';
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,
});
export const EmbedLoomBlockSchemaExtension =
BlockSchemaExtension(EmbedLoomBlockSchema);

View File

@@ -0,0 +1,2 @@
export * from './synced-doc-model.js';
export * from './synced-doc-schema.js';

View File

@@ -0,0 +1,30 @@
import type { GfxCompatibleProps } from '@blocksuite/std/gfx';
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 = [
'syncedDoc',
] as const satisfies EmbedCardStyle[];
export type EmbedSyncedDocBlockProps = {
style: EmbedCardStyle;
caption?: string | null;
scale?: number;
/**
* Record the scaled height of the synced doc block when it is folded,
* a.k.a the fourth number of the `xywh`
*/
preFoldHeight?: number;
} & ReferenceInfo &
GfxCompatibleProps;
export class EmbedSyncedDocModel extends defineEmbedModel<EmbedSyncedDocBlockProps>(
BlockModel
) {
get isFolded() {
return !!this.props.preFoldHeight$.value;
}
}

View File

@@ -0,0 +1,39 @@
import { BlockSchemaExtension } from '@blocksuite/store';
import { createEmbedBlockSchema } from '../../../utils/index.js';
import {
type EmbedSyncedDocBlockProps,
EmbedSyncedDocModel,
EmbedSyncedDocStyles,
} from './synced-doc-model.js';
export const SYNCED_MIN_WIDTH = 370;
export const SYNCED_MIN_HEIGHT = 48;
export const SYNCED_DEFAULT_WIDTH = 800;
// the default max height of embed doc, user can adjust height by selected rect over this value
export const SYNCED_DEFAULT_MAX_HEIGHT = 800;
export const defaultEmbedSyncedDocBlockProps: EmbedSyncedDocBlockProps = {
pageId: '',
style: EmbedSyncedDocStyles[0],
caption: undefined,
scale: undefined,
preFoldHeight: undefined,
// title & description aliases
title: undefined,
description: undefined,
index: 'a0',
xywh: `[0,0,${SYNCED_DEFAULT_WIDTH},100]`,
lockedBySelf: undefined,
};
export const EmbedSyncedDocBlockSchema = createEmbedBlockSchema({
name: 'synced-doc',
version: 1,
toModel: () => new EmbedSyncedDocModel(),
props: (): EmbedSyncedDocBlockProps => defaultEmbedSyncedDocBlockProps,
});
export const EmbedSyncedDocBlockSchemaExtension = BlockSchemaExtension(
EmbedSyncedDocBlockSchema
);

View File

@@ -0,0 +1,58 @@
import type { GfxModel } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { BookmarkBlockModel } from '../bookmark';
import { EmbedFigmaModel } from './figma';
import { EmbedGithubModel } from './github';
import type { EmbedHtmlModel } from './html';
import type { EmbedIframeBlockModel } from './iframe/';
import { EmbedLinkedDocModel } from './linked-doc';
import { EmbedLoomModel } from './loom';
import { EmbedSyncedDocModel } from './synced-doc';
import { EmbedYoutubeModel } from './youtube';
export const ExternalEmbedModels = [
EmbedFigmaModel,
EmbedGithubModel,
EmbedLoomModel,
EmbedYoutubeModel,
] as const;
export const InternalEmbedModels = [
EmbedLinkedDocModel,
EmbedSyncedDocModel,
] as const;
export type ExternalEmbedModel = (typeof ExternalEmbedModels)[number];
export type InternalEmbedModel = (typeof InternalEmbedModels)[number];
export type EmbedCardModel = InstanceType<
ExternalEmbedModel | InternalEmbedModel
>;
export type LinkableEmbedModel =
| EmbedCardModel
| EmbedIframeBlockModel
| BookmarkBlockModel;
export type BuiltInEmbedModel = EmbedCardModel | EmbedHtmlModel;
export function isExternalEmbedModel(
model: GfxModel | BlockModel
): model is InstanceType<ExternalEmbedModel> {
return (
model instanceof EmbedFigmaModel ||
model instanceof EmbedGithubModel ||
model instanceof EmbedLoomModel ||
model instanceof EmbedYoutubeModel
);
}
export function isInternalEmbedModel(
model: GfxModel | BlockModel
): model is InstanceType<InternalEmbedModel> {
return (
model instanceof EmbedLinkedDocModel || model instanceof EmbedSyncedDocModel
);
}

View File

@@ -0,0 +1,2 @@
export * from './youtube-model.js';
export * from './youtube-schema.js';

View File

@@ -0,0 +1,50 @@
import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const youtubeUrlRegex: RegExp =
/(?:https?:\/\/)?(?:(?:www|m)\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([\w-_]+)/;
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 = ['video'] as const satisfies EmbedCardStyle[];
export type EmbedYoutubeBlockProps = {
style: (typeof EmbedYoutubeStyles)[number];
url: string;
caption: string | null;
} & EmbedYoutubeBlockUrlData;
export class EmbedYoutubeModel extends defineEmbedModel<EmbedYoutubeBlockProps>(
BlockModel
) {
constructor() {
super();
const createdSubscription = this.created.subscribe(() => {
createdSubscription.unsubscribe();
this.syncVideoId();
this.propsUpdated.subscribe(({ key }) => {
if (key === 'url') this.syncVideoId();
});
});
}
private syncVideoId() {
if (this.store.readonly) return;
const videoId = this.props.url.match(youtubeUrlRegex)?.[1] ?? null;
if (this.props.videoId !== videoId) {
this.store.withoutTransact(() =>
this.store.updateBlock(this, { videoId })
);
}
}
}

View File

@@ -0,0 +1,33 @@
import { BlockSchemaExtension } from '@blocksuite/store';
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,
});
export const EmbedYoutubeBlockSchemaExtension = BlockSchemaExtension(
EmbedYoutubeBlockSchema
);

View File

@@ -0,0 +1,176 @@
import { Bound } from '@blocksuite/global/gfx';
import type {
GfxBlockElementModel,
GfxCompatibleProps,
GfxElementGeometry,
GfxGroupCompatibleInterface,
GfxModel,
PointTestOptions,
} from '@blocksuite/std/gfx';
import {
canSafeAddToContainer,
descendantElementsImpl,
generateKeyBetweenV2,
GfxCompatible,
gfxGroupCompatibleSymbol,
hasDescendantElementImpl,
} from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
type Text,
} from '@blocksuite/store';
import { z } from 'zod';
import { type Color, ColorSchema, DefaultTheme } from '../../themes/index.js';
export type FrameBlockProps = {
title: Text;
background: Color;
childElementIds?: Record<string, boolean>;
presentationIndex?: string;
comments?: Record<string, boolean>;
} & GfxCompatibleProps;
export const FrameZodSchema = z
.object({
background: ColorSchema,
})
.default({
background: DefaultTheme.transparent,
});
export const FrameBlockSchema = defineBlockSchema({
flavour: 'affine:frame',
props: (internal): FrameBlockProps => ({
title: internal.Text(),
background: 'transparent',
xywh: `[0,0,100,100]`,
index: 'a0',
childElementIds: Object.create(null),
presentationIndex: generateKeyBetweenV2(null, null),
lockedBySelf: false,
comments: undefined,
}),
metadata: {
version: 1,
role: 'content',
parent: ['affine:surface'],
children: [],
},
toModel: () => {
return new FrameBlockModel();
},
});
export const FrameBlockSchemaExtension = BlockSchemaExtension(FrameBlockSchema);
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.store.getModelById(key) as GfxBlockElementModel);
element && elements.push(element);
}
return elements;
}
get childIds() {
return this.props.childElementIds
? Object.keys(this.props.childElementIds)
: [];
}
get descendantElements(): GfxModel[] {
return descendantElementsImpl(this);
}
addChild(element: GfxModel) {
if (!canSafeAddToContainer(this, element)) return;
this.store.transact(() => {
this.props.childElementIds = {
...this.props.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.store.transact(() => {
this.props.childElementIds = {
...this.props.childElementIds,
...newChildren,
};
});
}
override containsBound(bound: Bound): boolean {
return this.elementBound.contains(bound);
}
hasChild(element: GfxModel): boolean {
return this.props.childElementIds
? element.id in this.props.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.removeChildren([element]);
}
removeChildren(elements: GfxModel[]): void {
const childIds = [...new Set(elements.map(element => element.id))];
if (!this.props.childElementIds || childIds.length === 0) {
return;
}
this.store.transact(() => {
const childElementIds = this.props.childElementIds;
if (!childElementIds) return;
childIds.forEach(childId => {
delete childElementIds[childId];
});
});
}
}

View File

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

View File

@@ -0,0 +1,62 @@
import type {
GfxCommonBlockProps,
GfxElementGeometry,
} from '@blocksuite/std/gfx';
import { GfxCompatible } from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import type { TextAlign } from '../../consts';
import type { BlockMeta } from '../../utils/types.js';
import { ImageBlockTransformer } from './image-transformer.js';
export type ImageBlockProps = {
caption?: string;
sourceId?: string;
width?: number;
height?: number;
rotate: number;
size?: number;
comments?: Record<string, boolean>;
textAlign?: TextAlign;
} & Omit<GfxCommonBlockProps, 'scale'> &
BlockMeta;
const defaultImageProps: ImageBlockProps = {
caption: '',
sourceId: '',
width: 0,
height: 0,
index: 'a0',
xywh: '[0,0,0,0]',
lockedBySelf: false,
rotate: 0,
size: -1,
comments: undefined,
textAlign: undefined,
'meta:createdAt': undefined,
'meta:createdBy': undefined,
'meta:updatedAt': undefined,
'meta:updatedBy': undefined,
};
export const ImageBlockSchema = defineBlockSchema({
flavour: 'affine:image',
props: () => defaultImageProps,
metadata: {
version: 1,
role: 'content',
},
transformer: transformerConfigs =>
new ImageBlockTransformer(transformerConfigs),
toModel: () => new ImageBlockModel(),
});
export const ImageBlockSchemaExtension = BlockSchemaExtension(ImageBlockSchema);
export class ImageBlockModel
extends GfxCompatible<ImageBlockProps>(BlockModel)
implements GfxElementGeometry {}

View File

@@ -0,0 +1,34 @@
import type {
BlockSnapshotLeaf,
FromSnapshotPayload,
SnapshotNode,
ToSnapshotPayload,
} 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;
}
override toSnapshot(
snapshot: ToSnapshotPayload<ImageBlockProps>
): BlockSnapshotLeaf {
const snapshotRet = super.toSnapshot(snapshot);
const sourceId = snapshot.model.props.sourceId;
if (sourceId) {
const pathBlobIdMap = snapshot.assets.getPathBlobIdMap();
pathBlobIdMap.set(snapshot.model.id, sourceId);
}
return snapshotRet;
}
}

View File

@@ -0,0 +1,2 @@
export * from './image-model.js';
export * from './image-transformer.js';

View File

@@ -0,0 +1,17 @@
export * from './attachment/index.js';
export * from './bookmark/index.js';
export * from './callout/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';
export * from './table';

View File

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

View File

@@ -0,0 +1,47 @@
import {
type GfxCommonBlockProps,
GfxCompatible,
type GfxElementGeometry,
} from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
export type LatexProps = {
latex: string;
comments?: Record<string, boolean>;
} & GfxCommonBlockProps;
export const LatexBlockSchema = defineBlockSchema({
flavour: 'affine:latex',
props: (): LatexProps => ({
xywh: '[0,0,16,16]',
index: 'a0',
lockedBySelf: false,
scale: 1,
rotate: 0,
latex: '',
comments: undefined,
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:edgeless-text',
'affine:paragraph',
'affine:list',
],
},
toModel: () => {
return new LatexBlockModel();
},
});
export const LatexBlockSchemaExtension = BlockSchemaExtension(LatexBlockSchema);
export class LatexBlockModel
extends GfxCompatible<LatexProps>(BlockModel)
implements GfxElementGeometry {}

View File

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

View File

@@ -0,0 +1,59 @@
import type { Text } from '@blocksuite/store';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import type { TextAlign } from '../../consts';
import type { BlockMeta } from '../../utils/types';
// `toggle` type has been deprecated, do not use it
export type ListType = 'bulleted' | 'numbered' | 'todo' | 'toggle';
export type ListProps = {
type: ListType;
text: Text;
textAlign?: TextAlign;
checked: boolean;
collapsed: boolean;
order: number | null;
comments?: Record<string, boolean>;
} & BlockMeta;
export const ListBlockSchema = defineBlockSchema({
flavour: 'affine:list',
props: internal =>
({
type: 'bulleted',
text: internal.Text(),
textAlign: undefined,
checked: false,
collapsed: false,
// number type only for numbered list
order: null,
comments: undefined,
'meta:createdAt': undefined,
'meta:createdBy': undefined,
'meta:updatedAt': undefined,
'meta:updatedBy': undefined,
}) as ListProps,
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:list',
'affine:paragraph',
'affine:edgeless-text',
'affine:callout',
],
},
toModel: () => new ListBlockModel(),
});
export const ListBlockSchemaExtension = BlockSchemaExtension(ListBlockSchema);
export class ListBlockModel extends BlockModel<ListProps> {}

View File

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

View File

@@ -0,0 +1,167 @@
import { Bound } from '@blocksuite/global/gfx';
import type {
GfxCompatibleProps,
GfxElementGeometry,
} from '@blocksuite/std/gfx';
import { GfxCompatible } from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import { z } from 'zod';
import {
DEFAULT_NOTE_BORDER_SIZE,
DEFAULT_NOTE_BORDER_STYLE,
DEFAULT_NOTE_CORNER,
DEFAULT_NOTE_HEIGHT,
DEFAULT_NOTE_SHADOW,
DEFAULT_NOTE_WIDTH,
NoteDisplayMode,
NoteDisplayModeSchema,
NoteShadowsSchema,
type StrokeStyle,
StrokeStyleSchema,
} from '../../consts/note';
import { type Color, ColorSchema, DefaultTheme } from '../../themes';
export const NoteZodSchema = z
.object({
background: ColorSchema,
displayMode: NoteDisplayModeSchema,
edgeless: z.object({
style: z.object({
borderRadius: z.number(),
borderSize: z.number(),
borderStyle: StrokeStyleSchema,
shadowType: NoteShadowsSchema,
}),
}),
})
.default({
background: DefaultTheme.noteBackgrounColor,
displayMode: NoteDisplayMode.EdgelessOnly,
edgeless: {
style: {
borderRadius: DEFAULT_NOTE_CORNER,
borderSize: DEFAULT_NOTE_BORDER_SIZE,
borderStyle: DEFAULT_NOTE_BORDER_STYLE,
shadowType: DEFAULT_NOTE_SHADOW,
},
},
});
export const NoteBlockSchema = defineBlockSchema({
flavour: 'affine:note',
props: (): NoteProps => ({
xywh: `[0,0,${DEFAULT_NOTE_WIDTH},${DEFAULT_NOTE_HEIGHT}]`,
background: DefaultTheme.noteBackgrounColor,
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,
},
},
comments: undefined,
}),
metadata: {
version: 1,
role: 'hub',
parent: ['@root'],
children: [
'@content',
'affine:database',
'affine:data-view',
'affine:callout',
],
},
toModel: () => {
return new NoteBlockModel();
},
});
export const NoteBlockSchemaExtension = BlockSchemaExtension(NoteBlockSchema);
export type NoteProps = {
background: Color;
displayMode: NoteDisplayMode;
edgeless: NoteEdgelessProps;
comments?: Record<string, boolean>;
/**
* @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.props.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);
}
override isEmpty(): boolean {
if (this.children.length === 0) return true;
if (this.children.length === 1) {
const firstChild = this.children[0];
if (firstChild.flavour === 'affine:paragraph') {
return firstChild.isEmpty();
}
}
return false;
}
/**
* We define a note block as a page block if it is the first visible note
*/
isPageBlock() {
return (
this.parent?.children.find(
child =>
child instanceof NoteBlockModel &&
child.props.displayMode !== NoteDisplayMode.EdgelessOnly
) === this
);
}
}

View File

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

View File

@@ -0,0 +1,65 @@
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
type Text,
} from '@blocksuite/store';
import type { TextAlign } from '../../consts';
import type { BlockMeta } from '../../utils/types';
export type ParagraphType =
| 'text'
| 'quote'
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6';
export type ParagraphProps = {
type: ParagraphType;
textAlign?: TextAlign;
text: Text;
collapsed: boolean;
comments?: Record<string, boolean>;
} & BlockMeta;
export const ParagraphBlockSchema = defineBlockSchema({
flavour: 'affine:paragraph',
props: (internal): ParagraphProps => ({
type: 'text',
text: internal.Text(),
textAlign: undefined,
collapsed: false,
comments: undefined,
'meta:createdAt': undefined,
'meta:createdBy': undefined,
'meta:updatedAt': undefined,
'meta:updatedBy': undefined,
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
'affine:callout',
'affine:transcription',
],
},
toModel: () => new ParagraphBlockModel(),
});
export const ParagraphBlockSchemaExtension =
BlockSchemaExtension(ParagraphBlockSchema);
export class ParagraphBlockModel extends BlockModel<ParagraphProps> {
override isEmpty(): boolean {
return this.props.text$.value.length === 0 && this.children.length === 0;
}
}

View File

@@ -0,0 +1 @@
export * from './root-block-model.js';

View File

@@ -0,0 +1,65 @@
import type { Text } from '@blocksuite/store';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
export type RootBlockProps = {
title: Text;
};
export class RootBlockModel extends BlockModel<RootBlockProps> {
constructor() {
super();
const createdSubscription = this.created.subscribe(() => {
createdSubscription.unsubscribe();
this.store.slots.rootAdded.subscribe(id => {
const model = this.store.getModelById(id);
if (model instanceof RootBlockModel) {
const newDocMeta = this.store.workspace.meta.getDocMeta(
model.store.id
);
if (
!newDocMeta ||
newDocMeta.title !== model.props.title.toString()
) {
this.store.workspace.meta.setDocMeta(model.store.id, {
title: model.props.title.toString(),
});
}
}
});
});
}
/**
* A page is empty if it only contains one empty note and the canvas is empty
*/
override isEmpty() {
let numNotes = 0;
let empty = true;
for (const child of this.children) {
empty = empty && child.isEmpty();
if (child.flavour === 'affine:note') numNotes++;
if (numNotes > 1) return false;
}
return empty;
}
}
export const RootBlockSchema = defineBlockSchema({
flavour: 'affine:page',
props: (internal): RootBlockProps => ({
title: internal.Text(),
}),
metadata: {
version: 2,
role: 'root',
},
toModel: () => new RootBlockModel(),
});
export const RootBlockSchemaExtension = BlockSchemaExtension(RootBlockSchema);

View File

@@ -0,0 +1 @@
export * from './surface-ref-model.js';

View File

@@ -0,0 +1,34 @@
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
export type SurfaceRefProps = {
reference: string;
caption: string;
refFlavour: string;
comments?: Record<string, boolean>;
};
export const SurfaceRefBlockSchema = defineBlockSchema({
flavour: 'affine:surface-ref',
props: (): SurfaceRefProps => ({
reference: '',
caption: '',
refFlavour: '',
comments: undefined,
}),
metadata: {
version: 1,
role: 'content',
parent: ['affine:note', 'affine:paragraph', 'affine:list'],
},
toModel: () => new SurfaceRefBlockModel(),
});
export const SurfaceRefBlockSchemaExtension = BlockSchemaExtension(
SurfaceRefBlockSchema
);
export class SurfaceRefBlockModel extends BlockModel<SurfaceRefProps> {}

View File

@@ -0,0 +1 @@
export * from './table-model';

View File

@@ -0,0 +1,74 @@
import type { DeltaInsert, Text } from '@blocksuite/store';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import type { TextAlign } from '../../consts';
import type { BlockMeta } from '../../utils/types';
export type TableCell = {
text: Text;
};
export interface TableRow {
rowId: string;
order: string;
backgroundColor?: string;
}
export interface TableColumn {
columnId: string;
order: string;
backgroundColor?: string;
width?: number;
}
export interface TableBlockProps extends BlockMeta {
rows: Record<string, TableRow>;
columns: Record<string, TableColumn>;
// key = `${rowId}:${columnId}`
cells: Record<string, TableCell>;
comments?: Record<string, boolean>;
textAlign?: TextAlign;
}
export interface TableCellSerialized {
text: {
delta: DeltaInsert[];
};
}
export interface TableBlockPropsSerialized {
rows: Record<string, TableRow>;
columns: Record<string, TableColumn>;
cells: Record<string, TableCellSerialized>;
}
export class TableBlockModel extends BlockModel<TableBlockProps> {}
export const TableModelFlavour = 'affine:table';
export const TableBlockSchema = defineBlockSchema({
flavour: TableModelFlavour,
props: (): TableBlockProps => ({
rows: {},
columns: {},
cells: {},
comments: undefined,
textAlign: undefined,
'meta:createdAt': undefined,
'meta:createdBy': undefined,
'meta:updatedAt': undefined,
'meta:updatedBy': undefined,
}),
metadata: {
isFlatData: true,
role: 'content',
version: 1,
parent: ['affine:note'],
children: [],
},
toModel: () => new TableBlockModel(),
});
export const TableBlockSchemaExtension = BlockSchemaExtension(TableBlockSchema);

View File

@@ -0,0 +1,36 @@
import { createEnumMap } from '../utils/enum.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_FRONT_ENDPOINT_STYLE = PointStyle.None;
export const DEFAULT_REAR_ENDPOINT_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,
}
export const DEFAULT_CONNECTOR_MODE = ConnectorMode.Curve;

View File

@@ -0,0 +1,96 @@
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import { z } from 'zod';
export type DocMode = 'edgeless' | 'page';
export const DocModes = ['edgeless', 'page'] as const;
export type FootNoteReferenceType = 'doc' | 'attachment' | 'url';
export const FootNoteReferenceTypes = ['doc', 'attachment', 'url'] 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 SerializedXYWHSchema = z.custom<SerializedXYWH>(val => {
if (typeof val !== 'string') return false;
const match = val.match(/^\[(\d+),(\d+),(\d+),(\d+)\]$/);
if (!match) return false;
// Ensure all numbers are valid
return match.slice(1).every(num => !isNaN(Number(num)));
}, 'Invalid XYWH format. Expected [number,number,number,number]');
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(),
xywh: SerializedXYWHSchema.optional(),
commentId: 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>;
/**
* FootNoteReferenceParamsSchema is used to define the parameters for a footnote reference.
* It supports the following types:
* 1. docId: string - the id of the doc
* 2. blobId: string - the id of the attachment
* 3. artifactId: string - the id of a Copilot artifact
* 4. url: string - the url of the reference
* 5. fileName: string - the name of the attachment
* 6. fileType: string - the type of the attachment
* 7. favicon: string - the favicon of the url reference
* 8. title: string - the title of the url reference
* 9. description: string - the description of the url reference
*/
export const FootNoteReferenceParamsSchema = z.object({
type: z.enum(FootNoteReferenceTypes),
docId: z.string().optional(),
blobId: z.string().optional(),
artifactId: z.string().optional(),
fileName: z.string().optional(),
fileType: z.string().optional(),
url: z.string().optional(),
favicon: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
});
export type FootNoteReferenceParams = z.infer<
typeof FootNoteReferenceParamsSchema
>;
export const FootNoteSchema = z.object({
label: z.string(),
reference: FootNoteReferenceParamsSchema,
});
export type FootNote = z.infer<typeof FootNoteSchema>;

View File

@@ -0,0 +1,2 @@
export const EDGELESS_TEXT_BLOCK_MIN_WIDTH = 50;
export const EDGELESS_TEXT_BLOCK_MIN_HEIGHT = 50;

View File

@@ -0,0 +1 @@
export const MAX_IMAGE_WIDTH = 460;

View File

@@ -0,0 +1,9 @@
export * from './connector.js';
export * from './doc.js';
export * from './edgeless-text.js';
export * from './image.js';
export * from './line.js';
export * from './mindmap.js';
export * from './note.js';
export * from './shape.js';
export * from './text.js';

View File

@@ -0,0 +1,69 @@
import { z } from 'zod';
import { createEnumMap } from '../utils/enum.js';
export enum LineWidth {
Two = 2,
// Thin
Four = 4,
Six = 6,
Eight = 8,
// Thick
Ten = 10,
Twelve = 12,
}
export const BRUSH_LINE_WIDTHS = [
LineWidth.Two,
LineWidth.Four,
LineWidth.Six,
LineWidth.Eight,
LineWidth.Ten,
LineWidth.Twelve,
];
export const HIGHLIGHTER_LINE_WIDTHS = [10, 14, 18, 22, 26, 30];
export const DEFAULT_HIGHLIGHTER_LINE_WIDTH = 22;
/**
* Use `DefaultTheme.StrokeColorShortMap` instead.
*
* @deprecated
*/
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);
/**
* Use `DefaultTheme.StrokeColorShortPalettes` instead.
*
* @deprecated
*/
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);

View File

@@ -0,0 +1,12 @@
export enum LayoutType {
BALANCE = 2,
LEFT = 1,
RIGHT = 0,
}
export enum MindmapStyle {
FOUR = 4,
ONE = 1,
THREE = 3,
TWO = 2,
}

View File

@@ -0,0 +1,80 @@
import { z } from 'zod';
import { createEnumMap } from '../utils/enum.js';
export const NOTE_MIN_WIDTH = 170 + 24 * 2;
export const NOTE_MIN_HEIGHT = 92;
export const DEFAULT_NOTE_WIDTH = 450 + 24 * 2;
export const DEFAULT_NOTE_HEIGHT = NOTE_MIN_HEIGHT;
export const DEFAULT_PAGE_BLOCK_WIDTH = 800;
export const DEFAULT_PAGE_BLOCK_HEIGHT = DEFAULT_NOTE_HEIGHT;
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 StrokeStyleSchema = z.nativeEnum(StrokeStyle);
export const NoteDisplayModeSchema = z.nativeEnum(NoteDisplayMode);
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;

View File

@@ -0,0 +1,46 @@
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 {
Rect = 'rect',
Ellipse = 'ellipse',
Diamond = 'diamond',
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',
}

View File

@@ -0,0 +1,68 @@
import { z } from 'zod';
import type { Color } from '../themes/color.js';
import { createEnumMap } from '../utils/enum.js';
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 FontFamilySchema = z.nativeEnum(FontFamily);
export const FontWeightSchema = z.nativeEnum(FontWeight);
export const FontStyleSchema = z.nativeEnum(FontStyle);
export const TextAlignSchema = z.nativeEnum(TextAlign);

View File

@@ -0,0 +1,219 @@
import {
Bound,
getBoundFromPoints,
getPointsFromBoundWithRotation,
getQuadBoundWithRotation,
getSolidStrokePoints,
getSvgPathFromStroke,
inflateBound,
isPointOnlines,
type IVec,
type IVec3,
lineIntersects,
PointLocation,
polyLineNearestPoint,
type SerializedXYWH,
transformPointsToNewBound,
Vec,
} from '@blocksuite/global/gfx';
import type { BaseElementProps, PointTestOptions } from '@blocksuite/std/gfx';
import {
convert,
derive,
field,
GfxPrimitiveElementModel,
watch,
} from '@blocksuite/std/gfx';
import { type Color, DefaultTheme } from '../../themes/index';
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';
}
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 = DefaultTheme.black;
@watch((_, instance) => {
instance['_local'].delete('commands');
})
@derive((lineWidth: number, instance: Instance) => {
const oldBound = Bound.fromXYWH(instance.deserializedXYWH);
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;

View File

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

View File

@@ -0,0 +1,530 @@
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
import {
Bound,
curveIntersects,
getBezierNearestPoint,
getBezierNearestTime,
getBezierParameters,
getBezierPoint,
linePolylineIntersects,
PointLocation,
Polyline,
polyLineNearestPoint,
Vec,
} from '@blocksuite/global/gfx';
import type {
BaseElementProps,
PointTestOptions,
SerializedElement,
} from '@blocksuite/std/gfx';
import {
derive,
field,
GfxPrimitiveElementModel,
local,
} from '@blocksuite/std/gfx';
import * as Y from 'yjs';
import {
CONNECTOR_LABEL_MAX_WIDTH,
ConnectorLabelOffsetAnchor,
ConnectorMode,
DEFAULT_CONNECTOR_MODE,
DEFAULT_ROUGHNESS,
FontFamily,
FontStyle,
FontWeight,
type PointStyle,
StrokeStyle,
TextAlign,
type TextStyleProps,
} from '../../consts/index';
import { type Color, DefaultTheme } from '../../themes/index';
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;
/**
* Connectors should always render, even during zoom.
*/
forceFullRender = true;
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 propsToY(props: ConnectorElementProps) {
if (typeof props.text === 'string') {
props.text = new 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 (path.length === 0) {
const { x, y } = this;
return [x, y];
}
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;
}
if (path.length === 0) {
return 0.5;
}
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 (path.length === 0) {
return [x + w / 2, y + h / 2];
}
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.labelEditing &&
this.labelDisplay &&
this.labelXYWH &&
this.text &&
this.text.length
);
}
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;
if (path.length === 0) {
return false;
}
const point =
mode === ConnectorMode.Curve
? getBezierNearestPoint(getBezierParameters(path), currentPoint)
: polyLineNearestPoint(path, currentPoint);
if (!point) {
return false;
}
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(originalPath: PointLocation[], matrix: DOMMatrix) {
this.updatingPath = false;
const path = this.resizePath(originalPath, matrix);
const props: {
source?: Connection;
target?: Connection;
} = {};
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: DefaultTheme.black,
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 labelEditing: boolean = false;
@field()
accessor mode: ConnectorMode = DEFAULT_CONNECTOR_MODE;
@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 = DefaultTheme.connectorColor;
@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]';
}

View File

@@ -0,0 +1,2 @@
export * from './connector.js';
export * from './local-connector.js';

View File

@@ -0,0 +1,58 @@
import type { PointLocation } from '@blocksuite/global/gfx';
import { GfxLocalElementModel } from '@blocksuite/std/gfx';
import {
ConnectorMode,
DEFAULT_ROUGHNESS,
type PointStyle,
StrokeStyle,
} from '../../consts/index';
import { type Color, DefaultTheme } from '../../themes/index';
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 = DefaultTheme.connectorColor;
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';
}
}

View File

@@ -0,0 +1,141 @@
import type { IVec, PointLocation } from '@blocksuite/global/gfx';
import { Bound, linePolygonIntersects } from '@blocksuite/global/gfx';
import type {
BaseElementProps,
GfxModel,
SerializedElement,
} from '@blocksuite/std/gfx';
import {
canSafeAddToContainer,
field,
GfxGroupLikeElementModel,
local,
observe,
} from '@blocksuite/std/gfx';
import * as Y from 'yjs';
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 propsToY(props: Record<string, unknown>) {
if (typeof props.title === 'string') {
props.title = new Y.Text(props.title as string);
}
if (props.children && !(props.children instanceof Y.Map)) {
const children = new Y.Map() as Y.Map<boolean>;
Object.keys(props.children).forEach(key => {
children.set(key as string, true);
});
props.children = children;
}
return props as GroupElementProps;
}
override addChild(element: GfxModel) {
this.addChildren([element]);
}
addChildren(elements: GfxModel[]) {
elements = [...new Set(elements)].filter(element =>
canSafeAddToContainer(this, element)
);
if (elements.length === 0) {
return;
}
this.surface.store.transact(() => {
elements.forEach(element => {
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) {
this.removeChildren([element]);
}
removeChildren(elements: GfxModel[]) {
if (!this.children) {
return;
}
const childIds = [...new Set(elements.map(element => element.id))];
if (childIds.length === 0) {
return;
}
this.surface.store.transact(() => {
childIds.forEach(childId => {
this.children.delete(childId);
});
});
}
override serialize() {
const result = super.serialize();
return result as SerializedGroupElement;
}
override lock(): void {
super.lock();
this.showTitle = false;
}
override unlock(): void {
super.unlock();
this.showTitle = true;
}
@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 Y.Map<boolean>();
@local()
accessor showTitle: boolean = true;
@field()
accessor title: Y.Text = new Y.Text();
}

View File

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

View File

@@ -0,0 +1,220 @@
import {
Bound,
getBoundFromPoints,
getPointsFromBoundWithRotation,
getQuadBoundWithRotation,
getSolidStrokePoints,
getSvgPathFromStroke,
inflateBound,
isPointOnlines,
type IVec,
type IVec3,
lineIntersects,
PointLocation,
polyLineNearestPoint,
type SerializedXYWH,
transformPointsToNewBound,
Vec,
} from '@blocksuite/global/gfx';
import type { BaseElementProps, PointTestOptions } from '@blocksuite/std/gfx';
import {
convert,
derive,
field,
GfxPrimitiveElementModel,
watch,
} from '@blocksuite/std/gfx';
import { DEFAULT_HIGHLIGHTER_LINE_WIDTH } from '../../consts';
import { type Color, DefaultTheme } from '../../themes/index';
export type HighlighterProps = 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 HighlighterElementModel extends GfxPrimitiveElementModel<HighlighterProps> {
/**
* 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 'highlighter';
}
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 = DefaultTheme.hightlighterColor;
@watch((_, instance) => {
instance['_local'].delete('commands');
})
@derive((lineWidth: number, instance: Instance) => {
const oldBound = Bound.fromXYWH(instance.deserializedXYWH);
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 = DEFAULT_HIGHLIGHTER_LINE_WIDTH;
@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<HighlighterProps> & HighlighterProps;

View File

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

View File

@@ -0,0 +1,35 @@
import type { EdgelessTextBlockModel } from '../blocks/edgeless-text/edgeless-text-model.js';
import type { BrushElementModel } from './brush/index.js';
import type { ConnectorElementModel } from './connector/index.js';
import type { GroupElementModel } from './group/index.js';
import type { HighlighterElementModel } from './highlighter/index.js';
import type { MindmapElementModel } from './mindmap/index.js';
import type { ShapeElementModel } from './shape/index.js';
import type { TextElementModel } from './text/index.js';
export * from './brush/index.js';
export * from './connector/index.js';
export * from './group/index.js';
export * from './highlighter/index.js';
export * from './mindmap/index.js';
export * from './shape/index.js';
export * from './text/index.js';
export type SurfaceElementModelMap = {
brush: BrushElementModel;
highlighter: HighlighterElementModel;
connector: ConnectorElementModel;
group: GroupElementModel;
mindmap: MindmapElementModel;
shape: ShapeElementModel;
text: TextElementModel;
};
export type SurfaceTextModelMap = {
text: TextElementModel;
connector: ConnectorElementModel;
shape: ShapeElementModel;
'edgeless-text': EdgelessTextBlockModel;
};
export type SurfaceTextModel = SurfaceTextModelMap[keyof SurfaceTextModelMap];

View File

@@ -0,0 +1,3 @@
export * from './mindmap';
export * from './snapshot';
export * from './style';

View File

@@ -0,0 +1,979 @@
import type { Bound, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
import { deserializeXYWH } from '@blocksuite/global/gfx';
import { assertType, noop } from '@blocksuite/global/utils';
import type {
BaseElementProps,
GfxModel,
GfxPrimitiveElementModel,
PointTestOptions,
SerializedElement,
} from '@blocksuite/std/gfx';
import {
convert,
field,
GfxGroupLikeElementModel,
observe,
watch,
} from '@blocksuite/std/gfx';
import { generateKeyBetween } from 'fractional-indexing';
import last from 'lodash-es/last';
import pick from 'lodash-es/pick';
import * as Y from 'yjs';
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: GfxPrimitiveElementModel;
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.store.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 readonly _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 propsToY(props: Record<string, unknown>) {
if (
props.children &&
!isNodeType(props.children as Record<string, unknown>) &&
!(props.children instanceof Y.Map)
) {
const children: Y.Map<NodeDetail> = new Y.Map();
Object.entries(props.children).forEach(([key, value]) => {
const detail = pick<Record<string, unknown>, keyof NodeDetail>(value, [
'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 hasFromTo = 'to' in options;
const { connector, from, layout } = options;
if (!from?.element) {
return { outdated: true, cacheKey: '' };
}
if (hasFromTo && !options.to?.element) {
return { outdated: true, cacheKey: '' };
}
const cacheKey = !hasFromTo
? `${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 Y.Text((props['text'] as string) ?? 'New node');
const type = (props.type as string) ?? 'shape';
let id: string;
this.surface.store.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]',
...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]',
...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.store.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;
}
getNodeByPath(path: number[]): MindmapNode | null {
let node: MindmapNode | null = this._tree;
for (let i = 1; i < path.length; i++) {
node = node?.children[path[i]];
if (!node) return null;
}
return node;
}
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;
this.surface.store.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.store.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<GfxPrimitiveElementModel>();
const stashedNodeIds = new Set<string>();
const traverse = (node: MindmapNode) => {
if (this._stashedNode.has(node.id)) return;
this._stashedNode.add(node.id);
stashedNodeIds.add(node.id);
node.element.stash('xywh');
stashed.add(node.element);
if (node.children.length) {
node.children.forEach(child => traverse(child));
}
};
traverse(mindNode);
return () => {
stashedNodeIds.forEach(id => {
this._stashedNode.delete(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.store.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 Y.Map)) {
nodeSchema.parse(initialValue);
assertType<NodeType>(initialValue);
const map: Y.Map<NodeDetail> = new Y.Map();
const surface = instance.surface;
const doc = surface.store;
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 Y.Map();
@watch(watchLayoutType)
@field()
accessor layoutType: LayoutType = LayoutType.RIGHT;
@watch(watchStyle)
@field()
accessor style: MindmapStyle = MindmapStyle.ONE;
}

View File

@@ -0,0 +1,2 @@
export * from './types';
export * from './utils';

View File

@@ -0,0 +1,25 @@
export interface MindMapTreeNode {
id: string;
index: string;
children: MindMapTreeNode[];
}
export interface MindMapNode {
index: string;
parent?: string;
}
export type MindMapJson = Record<string, MindMapNode>;
export interface MindMapElement {
index: string;
seed: number;
children: {
'affine:surface:ymap': boolean;
json: MindMapJson;
};
layoutType: number;
style: number;
type: 'mindmap';
id: string;
}

View File

@@ -0,0 +1,70 @@
import type { MindMapElement, MindMapJson, MindMapTreeNode } from './types';
function isMindMapElement(element: unknown): element is MindMapElement {
return (
typeof element === 'object' &&
element !== null &&
'type' in element &&
(element as MindMapElement).type === 'mindmap' &&
'children' in element &&
typeof (element as MindMapElement).children === 'object' &&
'json' in (element as MindMapElement).children
);
}
function getMindMapChildrenJson(
element: Record<string, unknown>
): MindMapJson | null {
if (!isMindMapElement(element)) {
return null;
}
return element.children.json;
}
export function getMindMapNodeMap(
element: Record<string, unknown>
): Map<string, MindMapTreeNode> {
const nodeMap = new Map<string, MindMapTreeNode>();
const childrenJson = getMindMapChildrenJson(element);
if (!childrenJson) {
return nodeMap;
}
for (const [id, info] of Object.entries(childrenJson)) {
nodeMap.set(id, {
id,
index: info.index,
children: [],
});
}
return nodeMap;
}
export function buildMindMapTree(element: Record<string, unknown>) {
let root: MindMapTreeNode | null = null;
// First traverse to get node map
const nodeMap = getMindMapNodeMap(element);
const childrenJson = getMindMapChildrenJson(element);
if (!childrenJson) {
return root;
}
// Second traverse to build tree
for (const [id, info] of Object.entries(childrenJson)) {
const node = nodeMap.get(id)!;
if (info.parent) {
const parentNode = nodeMap.get(info.parent);
if (parentNode) {
parentNode.children.push(node);
}
} else {
root = node;
}
}
return root;
}

View File

@@ -0,0 +1,518 @@
import isEqual from 'lodash-es/isEqual';
import last from 'lodash-es/last';
import { ConnectorMode } from '../../consts/connector.js';
import { MindmapStyle } from '../../consts/mindmap.js';
import { StrokeStyle } from '../../consts/note.js';
import { FontFamily, FontWeight, TextResizing } from '../../consts/text.js';
import { type Color, DefaultTheme } from '../../themes/index.js';
import type { MindmapNode } from './mindmap.js';
export type CollapseButton = {
width: number;
height: number;
radius: number;
filled: boolean;
fillColor: Color;
strokeColor: Color;
strokeWidth: number;
};
export type ExpandButton = CollapseButton & {
fontFamily: FontFamily;
fontSize: number;
fontWeight: FontWeight;
color: Color;
};
export type NodeStyle = {
radius: number;
strokeWidth: number;
strokeColor: Color;
textResizing: TextResizing;
maxWidth: false | number;
fontSize: number;
fontFamily: string;
fontWeight: FontWeight;
color: Color;
filled: boolean;
fillColor: Color;
padding: [number, number];
shadow?: {
blur: number;
offsetX: number;
offsetY: number;
color: Color;
};
};
export type ConnectorStyle = {
strokeStyle: StrokeStyle;
stroke: Color;
strokeWidth: number;
mode: ConnectorMode;
};
export const MINDMAP_NODE_MAX_WIDTH = 512;
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 readonly _colorOrders = [
DefaultTheme.StrokeColorShortMap.Purple,
DefaultTheme.StrokeColorShortMap.Magenta,
DefaultTheme.StrokeColorShortMap.Orange,
DefaultTheme.StrokeColorShortMap.Yellow,
DefaultTheme.StrokeColorShortMap.Green,
'#7ae2d5',
];
readonly root = {
radius: 8,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 4,
strokeColor: '#53b2ef',
fontFamily: FontFamily.Poppins,
fontSize: 20,
fontWeight: FontWeight.SemiBold,
color: DefaultTheme.pureBlack,
filled: true,
fillColor: DefaultTheme.pureWhite,
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: DefaultTheme.pureWhite,
strokeColor: color,
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 8,
filled: true,
fillColor: color,
strokeColor: color,
strokeWidth: 0,
padding: [4, 0],
color: DefaultTheme.pureWhite,
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Bold,
fontSize: 15,
},
node: {
radius: 8,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 3,
strokeColor: color,
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.Medium,
color: DefaultTheme.pureBlack,
filled: true,
fillColor: DefaultTheme.pureWhite,
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 readonly _colorOrders = [
DefaultTheme.StrokeColorShortMap.Blue,
'#7ae2d5',
DefaultTheme.StrokeColorShortMap.Yellow,
];
readonly root = {
radius: 3,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 3,
strokeColor: DefaultTheme.black,
fontFamily: FontFamily.Poppins,
fontSize: 18,
fontWeight: FontWeight.SemiBold,
color: DefaultTheme.pureBlack,
filled: true,
fillColor: DefaultTheme.StrokeColorShortMap.Yellow,
padding: [11, 22] as [number, number],
shadow: {
blur: 0,
offsetX: 3,
offsetY: 3,
color: DefaultTheme.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: DefaultTheme.black,
strokeWidth: 3,
mode: ConnectorMode.Orthogonal,
},
collapseButton: {
width: 16,
height: 16,
radius: 0.5,
filled: true,
fillColor: DefaultTheme.white,
strokeColor: DefaultTheme.black,
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 2,
filled: true,
fillColor: DefaultTheme.black,
padding: [4, 0],
strokeColor: DefaultTheme.black,
strokeWidth: 0,
color: DefaultTheme.white,
fontFamily: FontFamily.Inter,
fontWeight: FontWeight.Bold,
fontSize: 15,
},
node: {
radius: 3,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 3,
strokeColor: DefaultTheme.black,
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.SemiBold,
color: DefaultTheme.pureBlack,
filled: true,
fillColor: color,
padding: [6, 22] as [number, number],
shadow: {
blur: 0,
offsetX: 3,
offsetY: 3,
color: DefaultTheme.black,
},
},
};
}
}
export const styleTwo = new StyleTwo();
export class StyleThree extends MindmapStyleGetter {
private readonly _strokeColor = [
DefaultTheme.StrokeColorShortMap.Yellow,
DefaultTheme.StrokeColorShortMap.Green,
'#5cc7ba',
];
readonly root = {
radius: 10,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 0,
strokeColor: 'transparent',
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.Medium,
color: DefaultTheme.pureBlack,
filled: true,
fillColor: DefaultTheme.StrokeColorShortMap.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);
const dotColor = this._getColor(path.length - 1);
return {
node: {
radius: 10,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 2,
strokeColor,
fontFamily: FontFamily.Poppins,
fontSize: 16,
fontWeight: FontWeight.Medium,
color: DefaultTheme.pureBlack,
filled: true,
fillColor: DefaultTheme.pureWhite,
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: DefaultTheme.pureWhite,
strokeColor: dotColor,
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 8,
filled: true,
fillColor: dotColor,
padding: [4, 0],
strokeColor: dotColor,
strokeWidth: 0,
color: DefaultTheme.pureWhite,
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 readonly _colors = [
DefaultTheme.StrokeColorShortMap.Purple,
DefaultTheme.StrokeColorShortMap.Magenta,
DefaultTheme.StrokeColorShortMap.Orange,
DefaultTheme.StrokeColorShortMap.Yellow,
DefaultTheme.StrokeColorShortMap.Green,
DefaultTheme.StrokeColorShortMap.Blue,
];
readonly root = {
radius: 0,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 0,
strokeColor: 'transparent',
fontFamily: FontFamily.Kalam,
fontSize: 22,
fontWeight: FontWeight.Bold,
color: DefaultTheme.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: DefaultTheme.pureWhite,
strokeColor: stroke,
strokeWidth: 3,
},
expandButton: {
width: 24,
height: 24,
radius: 8,
filled: true,
fillColor: stroke,
padding: [4, 0],
strokeColor: stroke,
strokeWidth: 0,
color: DefaultTheme.pureWhite,
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]) => {
const element = node.element as unknown as Record<string, unknown>;
if (!isEqual(element[key], value)) {
element[key] = value;
}
});
};

View File

@@ -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;
}

View File

@@ -0,0 +1,120 @@
import type { IBound, IVec } from '@blocksuite/global/gfx';
import {
Bound,
getCenterAreaBounds,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
PointLocation,
pointOnPolygonStoke,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/gfx';
import type { PointTestOptions } from '@blocksuite/std/gfx';
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);
},
};

View File

@@ -0,0 +1,202 @@
import {
Bound,
clamp,
getPointsFromBoundWithRotation,
type IBound,
type IVec,
lineEllipseIntersects,
pointInEllipse,
pointInPolygon,
PointLocation,
rotatePoints,
toRadian,
Vec,
} from '@blocksuite/global/gfx';
import type { PointTestOptions } from '@blocksuite/std/gfx';
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);
},
};

View File

@@ -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,
};

View File

@@ -0,0 +1,119 @@
import type { IBound, IVec } from '@blocksuite/global/gfx';
import {
Bound,
getCenterAreaBounds,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
PointLocation,
pointOnPolygonStoke,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/gfx';
import type { PointTestOptions } from '@blocksuite/std/gfx';
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);
},
};

View File

@@ -0,0 +1,116 @@
import type { IBound, IVec } from '@blocksuite/global/gfx';
import {
Bound,
getCenterAreaBounds,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
PointLocation,
pointOnPolygonStoke,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/gfx';
import type { PointTestOptions } from '@blocksuite/std/gfx';
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);
},
};

View File

@@ -0,0 +1,2 @@
export * from './api/index.js';
export * from './shape.js';

View File

@@ -0,0 +1,260 @@
import type {
Bound,
IBound,
IVec,
PointLocation,
SerializedXYWH,
} from '@blocksuite/global/gfx';
import type { BaseElementProps, PointTestOptions } from '@blocksuite/std/gfx';
import {
field,
GfxLocalElementModel,
GfxPrimitiveElementModel,
local,
prop,
} from '@blocksuite/std/gfx';
import * as Y from 'yjs';
import {
DEFAULT_ROUGHNESS,
FontFamily,
FontStyle,
FontWeight,
ShapeStyle,
ShapeTextFontSize,
ShapeType,
StrokeStyle,
TextAlign,
TextResizing,
type TextStyleProps,
TextVerticalAlign,
} from '../../consts/index.js';
import { type Color, DefaultTheme } from '../../themes/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 propsToY(props: ShapeProps) {
if (typeof props.text === 'string') {
props.text = new 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(DefaultTheme.shapeTextColor)
accessor color!: Color;
@field()
accessor fillColor: Color = DefaultTheme.shapeFillColor;
@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: Color;
} | null = null;
@field()
accessor shapeStyle: ShapeStyle = ShapeStyle.General;
@field()
accessor shapeType: ShapeType = ShapeType.Rect;
@field()
accessor strokeColor: Color = DefaultTheme.shapeStrokeColor;
@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 = DefaultTheme.shapeTextColor;
@prop()
accessor fillColor: Color = DefaultTheme.shapeFillColor;
@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: Color;
} | null = null;
@prop()
accessor shapeStyle: ShapeStyle = ShapeStyle.General;
@prop()
accessor shapeType: ShapeType = ShapeType.Rect;
@prop()
accessor strokeColor: Color = DefaultTheme.shapeStrokeColor;
@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;
}

View File

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

View File

@@ -0,0 +1,92 @@
import type { IVec, SerializedXYWH } from '@blocksuite/global/gfx';
import {
Bound,
getPointsFromBoundWithRotation,
linePolygonIntersects,
pointInPolygon,
polygonNearestPoint,
} from '@blocksuite/global/gfx';
import type { BaseElementProps } from '@blocksuite/std/gfx';
import { field, GfxPrimitiveElementModel } from '@blocksuite/std/gfx';
import * as Y from 'yjs';
import {
FontFamily,
FontStyle,
FontWeight,
TextAlign,
type TextStyleProps,
} from '../../consts/index';
import { type Color, DefaultTheme } from '../../themes/index';
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 propsToY(props: Record<string, unknown>) {
if (typeof props.text === 'string') {
props.text = new Y.Text(props.text);
}
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 = DefaultTheme.black;
@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 Y.Text();
@field()
accessor textAlign: TextAlign = TextAlign.Center;
@field()
accessor xywh: SerializedXYWH = '[0,0,16,16]';
}

View File

@@ -0,0 +1,5 @@
export * from './blocks/index.js';
export * from './consts/index.js';
export * from './elements/index.js';
export * from './themes/index.js';
export * from './utils/index.js';

Some files were not shown because too many files have changed in this diff Show More