chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

View File

@@ -0,0 +1,201 @@
import type { AliasInfo, ReferenceParams } from '@blocksuite/affine-model';
import { LifeCycleWatcher, StdIdentifier } from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import type { Disposable } from '@blocksuite/global/utils';
import {
AliasIcon,
BlockLinkIcon,
DeleteIcon,
EdgelessIcon,
LinkedEdgelessIcon,
LinkedPageIcon,
PageIcon,
} from '@blocksuite/icons/lit';
import type { Doc } from '@blocksuite/store';
import { computed, type Signal, signal } from '@preact/signals-core';
import type { TemplateResult } from 'lit';
import { referenceToNode } from '../utils/reference.js';
import { DocModeProvider } from './doc-mode-service.js';
export type DocDisplayMetaParams = {
referenced?: boolean;
params?: ReferenceParams;
} & AliasInfo;
/**
* Customize document display title and icon.
*
* Supports the following blocks:
*
* * Inline View:
* `AffineReference`
* * Card View:
* `EmbedLinkedDocBlockComponent`
* `EmbedEdgelessLinkedDocBlockComponent`
* * Embed View:
* `EmbedSyncedDocBlockComponent`
* `EmbedEdgelessSyncedDocBlockComponent`
*/
export interface DocDisplayMetaExtension {
icon: (
docId: string,
referenceInfo?: DocDisplayMetaParams
) => Signal<TemplateResult>;
title: (
docId: string,
referenceInfo?: DocDisplayMetaParams
) => Signal<string>;
}
export const DocDisplayMetaProvider = createIdentifier<DocDisplayMetaExtension>(
'DocDisplayMetaService'
);
export class DocDisplayMetaService
extends LifeCycleWatcher
implements DocDisplayMetaExtension
{
static icons = {
deleted: iconBuilder(DeleteIcon),
aliased: iconBuilder(AliasIcon),
page: iconBuilder(PageIcon),
edgeless: iconBuilder(EdgelessIcon),
linkedBlock: iconBuilder(BlockLinkIcon),
linkedPage: iconBuilder(LinkedPageIcon),
linkedEdgeless: iconBuilder(LinkedEdgelessIcon),
} as const;
static override key = 'doc-display-meta';
readonly disposables: Disposable[] = [];
readonly iconMap = new WeakMap<Doc, Signal<TemplateResult>>();
readonly titleMap = new WeakMap<Doc, Signal<string>>();
static override setup(di: Container) {
di.addImpl(DocDisplayMetaProvider, this, [StdIdentifier]);
}
dispose() {
while (this.disposables.length > 0) {
this.disposables.pop()?.dispose();
}
}
icon(
pageId: string,
{ params, title, referenced }: DocDisplayMetaParams = {}
): Signal<TemplateResult> {
const doc = this.std.collection.getDoc(pageId);
if (!doc) {
return signal(DocDisplayMetaService.icons.deleted);
}
let icon$ = this.iconMap.get(doc);
if (!icon$) {
icon$ = signal(
this.std.get(DocModeProvider).getPrimaryMode(pageId) === 'edgeless'
? DocDisplayMetaService.icons.edgeless
: DocDisplayMetaService.icons.page
);
const disposable = this.std
.get(DocModeProvider)
.onPrimaryModeChange(mode => {
icon$!.value =
mode === 'edgeless'
? DocDisplayMetaService.icons.edgeless
: DocDisplayMetaService.icons.page;
}, pageId);
this.disposables.push(disposable);
this.disposables.push(
this.std.collection.slots.docRemoved
.filter(docId => docId === doc.id)
.once(() => {
const index = this.disposables.findIndex(d => d === disposable);
if (index !== -1) {
this.disposables.splice(index, 1);
disposable.dispose();
}
this.iconMap.delete(doc);
})
);
this.iconMap.set(doc, icon$);
}
return computed(() => {
if (title) {
return DocDisplayMetaService.icons.aliased;
}
if (referenceToNode({ pageId, params })) {
return DocDisplayMetaService.icons.linkedBlock;
}
if (referenced) {
const mode =
params?.mode ??
this.std.get(DocModeProvider).getPrimaryMode(pageId) ??
'page';
return mode === 'edgeless'
? DocDisplayMetaService.icons.linkedEdgeless
: DocDisplayMetaService.icons.linkedPage;
}
return icon$.value;
});
}
title(pageId: string, { title }: DocDisplayMetaParams = {}): Signal<string> {
const doc = this.std.collection.getDoc(pageId);
if (!doc) {
return signal(title || 'Deleted doc');
}
let title$ = this.titleMap.get(doc);
if (!title$) {
title$ = signal(doc.meta?.title || 'Untitled');
const disposable = this.std.collection.meta.docMetaUpdated.on(() => {
title$!.value = doc.meta?.title || 'Untitled';
});
this.disposables.push(disposable);
this.disposables.push(
this.std.collection.slots.docRemoved
.filter(docId => docId === doc.id)
.once(() => {
const index = this.disposables.findIndex(d => d === disposable);
if (index !== -1) {
this.disposables.splice(index, 1);
disposable.dispose();
}
this.titleMap.delete(doc);
})
);
this.titleMap.set(doc, title$);
}
return computed(() => {
return title || title$.value;
});
}
override unmounted() {
this.dispose();
}
}
function iconBuilder(
icon: typeof PageIcon,
size = '1.25em',
style = 'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;'
) {
return icon({ width: size, height: size, style });
}

View File

@@ -0,0 +1,108 @@
import type { DocMode } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { Extension } from '@blocksuite/block-std';
import type { Container } from '@blocksuite/global/di';
import { createIdentifier } from '@blocksuite/global/di';
import { type Disposable, noop, Slot } from '@blocksuite/global/utils';
const DEFAULT_MODE: DocMode = 'page';
export interface DocModeProvider {
/**
* Set the primary mode of the doc.
* This would not affect the current editor mode.
* If you want to switch the editor mode, use `setEditorMode` instead.
* @param mode - The mode to set.
* @param docId - The id of the doc.
*/
setPrimaryMode: (mode: DocMode, docId: string) => void;
/**
* Get the primary mode of the doc.
* Normally, it would be used to query the mode of other doc.
* @param docId - The id of the doc.
* @returns The primary mode of the document.
*/
getPrimaryMode: (docId: string) => DocMode;
/**
* Toggle the primary mode of the doc.
* @param docId - The id of the doc.
* @returns The new primary mode of the doc.
*/
togglePrimaryMode: (docId: string) => DocMode;
/**
* Subscribe to changes in the primary mode of the doc.
* For example:
* Embed-linked-doc-block will subscribe to the primary mode of the linked doc,
* and will display different UI according to the primary mode of the linked doc.
* @param handler - The handler to call when the primary mode of certain doc changes.
* @param docId - The id of the doc.
* @returns A disposable to stop the subscription.
*/
onPrimaryModeChange: (
handler: (mode: DocMode) => void,
docId: string
) => Disposable;
/**
* Set the editor mode. Normally, it would be used to set the mode of the current editor.
* When patch or override the doc mode service, can pass a callback to set the editor mode.
* @param mode - The mode to set.
*/
setEditorMode: (mode: DocMode) => void;
/**
* Get current editor mode.
* @returns The editor mode.
*/
getEditorMode: () => DocMode | null;
}
export const DocModeProvider = createIdentifier<DocModeProvider>(
'AffineDocModeService'
);
const modeMap = new Map<string, DocMode>();
const slotMap = new Map<string, Slot<DocMode>>();
export class DocModeService extends Extension implements DocModeProvider {
static override setup(di: Container) {
di.addImpl(DocModeProvider, DocModeService);
}
getEditorMode(): DocMode | null {
return null;
}
getPrimaryMode(id: string) {
return modeMap.get(id) ?? DEFAULT_MODE;
}
onPrimaryModeChange(handler: (mode: DocMode) => void, id: string) {
if (!slotMap.get(id)) {
slotMap.set(id, new Slot());
}
return slotMap.get(id)!.on(handler);
}
setEditorMode(mode: DocMode) {
noop(mode);
}
setPrimaryMode(mode: DocMode, id: string) {
modeMap.set(id, mode);
slotMap.get(id)?.emit(mode);
}
togglePrimaryMode(id: string) {
const mode = this.getPrimaryMode(id) === 'page' ? 'edgeless' : 'page';
this.setPrimaryMode(mode, id);
return mode;
}
}
export function DocModeExtension(service: DocModeProvider): ExtensionType {
return {
setup: di => {
di.override(DocModeProvider, () => service);
},
};
}

View File

@@ -0,0 +1,125 @@
import {
type BlockComponent,
type BlockStdScope,
type DndEventState,
type EditorHost,
Extension,
type ExtensionType,
StdIdentifier,
} from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import type { Point } from '@blocksuite/global/utils';
import { Job, Slice, type SliceSnapshot } from '@blocksuite/store';
export type DropType = 'before' | 'after' | 'in';
export type OnDragStartProps = {
state: DndEventState;
startDragging: (
blocks: BlockComponent[],
state: DndEventState,
dragPreview?: HTMLElement,
dragPreviewOffset?: Point
) => void;
anchorBlockId: string | null;
editorHost: EditorHost;
};
export type OnDragEndProps = {
state: DndEventState;
draggingElements: BlockComponent[];
dropBlockId: string;
dropType: DropType | null;
dragPreview: HTMLElement;
noteScale: number;
editorHost: EditorHost;
};
export type OnDragMoveProps = {
state: DndEventState;
draggingElements?: BlockComponent[];
};
export type DragHandleOption = {
flavour: string | RegExp;
edgeless?: boolean;
onDragStart?: (props: OnDragStartProps) => boolean;
onDragMove?: (props: OnDragMoveProps) => boolean;
onDragEnd?: (props: OnDragEndProps) => boolean;
};
export const DragHandleConfigIdentifier = createIdentifier<DragHandleOption>(
'AffineDragHandleIdentifier'
);
export function DragHandleConfigExtension(
option: DragHandleOption
): ExtensionType {
return {
setup: di => {
const key =
typeof option.flavour === 'string'
? option.flavour
: option.flavour.source;
di.addImpl(DragHandleConfigIdentifier(key), () => option);
},
};
}
export const DndApiExtensionIdentifier = createIdentifier<DNDAPIExtension>(
'AffineDndApiIdentifier'
);
export class DNDAPIExtension extends Extension {
mimeType = 'application/x-blocksuite-dnd';
constructor(readonly std: BlockStdScope) {
super();
}
static override setup(di: Container) {
di.add(this, [StdIdentifier]);
di.addImpl(DndApiExtensionIdentifier, provider => provider.get(this));
}
decodeSnapshot(data: string): SliceSnapshot {
return JSON.parse(decodeURIComponent(data));
}
encodeSnapshot(json: SliceSnapshot) {
const snapshot = JSON.stringify(json);
return encodeURIComponent(snapshot);
}
fromEntity(options: {
docId: string;
flavour?: string;
blockId?: string;
}): SliceSnapshot | null {
const { docId, flavour = 'affine:embed-linked-doc', blockId } = options;
const slice = Slice.fromModels(this.std.doc, []);
const job = new Job({ collection: this.std.collection });
const snapshot = job.sliceToSnapshot(slice);
if (!snapshot) {
console.error('Failed to convert slice to snapshot');
return null;
}
const props = {
...(blockId ? { blockId } : {}),
pageId: docId,
};
return {
...snapshot,
content: [
{
id: this.std.collection.idGenerator(),
type: 'block',
flavour,
props,
children: [],
},
],
};
}
}

View File

@@ -0,0 +1,211 @@
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type DeepPartial,
DisposableGroup,
Slot,
} from '@blocksuite/global/utils';
import { DocCollection } from '@blocksuite/store';
import { computed, type Signal, signal } from '@preact/signals-core';
import clonedeep from 'lodash.clonedeep';
import mergeWith from 'lodash.mergewith';
import { z } from 'zod';
import {
ColorSchema,
makeDeepOptional,
NodePropsSchema,
} from '../utils/index.js';
import { EditorSettingProvider } from './editor-setting-service.js';
const LastPropsSchema = NodePropsSchema;
const OptionalPropsSchema = makeDeepOptional(NodePropsSchema);
export type LastProps = z.infer<typeof NodePropsSchema>;
export type LastPropsKey = keyof LastProps;
const SessionPropsSchema = z.object({
viewport: z.union([
z.object({
centerX: z.number(),
centerY: z.number(),
zoom: z.number(),
}),
z.object({
xywh: z.string(),
padding: z
.tuple([z.number(), z.number(), z.number(), z.number()])
.optional(),
}),
]),
templateCache: z.string(),
remoteColor: z.string(),
showBidirectional: z.boolean(),
});
const LocalPropsSchema = z.object({
presentBlackBackground: z.boolean(),
presentFillScreen: z.boolean(),
presentHideToolbar: z.boolean(),
autoHideEmbedHTMLFullScreenToolbar: z.boolean(),
});
type SessionProps = z.infer<typeof SessionPropsSchema>;
type LocalProps = z.infer<typeof LocalPropsSchema>;
type StorageProps = SessionProps & LocalProps;
type StoragePropsKey = keyof StorageProps;
function isLocalProp(key: string): key is keyof LocalProps {
return key in LocalPropsSchema.shape;
}
function isSessionProp(key: string): key is keyof SessionProps {
return key in SessionPropsSchema.shape;
}
function customizer(_target: unknown, source: unknown) {
if (
ColorSchema.safeParse(source).success ||
source instanceof DocCollection.Y.Text ||
source instanceof DocCollection.Y.Array ||
source instanceof DocCollection.Y.Map
) {
return source;
}
return;
}
export class EditPropsStore extends LifeCycleWatcher {
static override key = 'EditPropsStore';
private _disposables = new DisposableGroup();
private innerProps$: Signal<DeepPartial<LastProps>> = signal({});
lastProps$: Signal<LastProps>;
slots = {
storageUpdated: new Slot<{
key: StoragePropsKey;
value: StorageProps[StoragePropsKey];
}>(),
};
constructor(std: BlockStdScope) {
super(std);
const initProps: LastProps = LastPropsSchema.parse(
Object.entries(LastPropsSchema.shape).reduce((value, [key, schema]) => {
return {
...value,
[key]: schema.parse(undefined),
};
}, {})
);
this.lastProps$ = computed(() => {
const editorSetting$ = this.std.getOptional(EditorSettingProvider);
const nextProps = mergeWith(
clonedeep(initProps),
editorSetting$?.value,
this.innerProps$.value,
customizer
);
return LastPropsSchema.parse(nextProps);
});
}
private _getStorage<T extends StoragePropsKey>(key: T) {
return isSessionProp(key) ? sessionStorage : localStorage;
}
private _getStorageKey<T extends StoragePropsKey>(key: T) {
const id = this.std.doc.id;
switch (key) {
case 'viewport':
return 'blocksuite:' + id + ':edgelessViewport';
case 'presentBlackBackground':
return 'blocksuite:presentation:blackBackground';
case 'presentFillScreen':
return 'blocksuite:presentation:fillScreen';
case 'presentHideToolbar':
return 'blocksuite:presentation:hideToolbar';
case 'templateCache':
return 'blocksuite:' + id + ':templateTool';
case 'remoteColor':
return 'blocksuite:remote-color';
case 'showBidirectional':
return 'blocksuite:' + id + ':showBidirectional';
case 'autoHideEmbedHTMLFullScreenToolbar':
return 'blocksuite:embedHTML:autoHideFullScreenToolbar';
default:
return key;
}
}
applyLastProps(key: LastPropsKey, props: Record<string, unknown>) {
if (['__proto__', 'constructor', 'prototype'].includes(key)) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid key: ${key}`
);
}
const lastProps = this.lastProps$.value[key];
return mergeWith(clonedeep(lastProps), props, customizer);
}
dispose() {
this._disposables.dispose();
}
getStorage<T extends StoragePropsKey>(key: T) {
try {
const storage = this._getStorage(key);
const value = storage.getItem(this._getStorageKey(key));
if (!value) return null;
if (isLocalProp(key)) {
return LocalPropsSchema.shape[key].parse(
JSON.parse(value)
) as StorageProps[T];
} else if (isSessionProp(key)) {
return SessionPropsSchema.shape[key].parse(
JSON.parse(value)
) as StorageProps[T];
} else {
return null;
}
} catch {
return null;
}
}
recordLastProps(key: LastPropsKey, props: Partial<LastProps[LastPropsKey]>) {
const schema = OptionalPropsSchema._def.innerType.shape[key];
if (!schema) return;
const overrideProps = schema.parse(props);
if (Object.keys(overrideProps).length === 0) return;
const innerProps = this.innerProps$.value;
const nextProps = mergeWith(
clonedeep(innerProps),
{ [key]: overrideProps },
customizer
);
this.innerProps$.value = OptionalPropsSchema.parse(nextProps);
}
setStorage<T extends StoragePropsKey>(key: T, value: StorageProps[T]) {
const oldValue = this.getStorage(key);
this._getStorage(key).setItem(
this._getStorageKey(key),
JSON.stringify(value)
);
if (oldValue === value) return;
this.slots.storageUpdated.emit({ key, value });
}
override unmounted() {
super.unmounted();
this.dispose();
}
}

View File

@@ -0,0 +1,25 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { DeepPartial } from '@blocksuite/global/utils';
import type { Signal } from '@preact/signals-core';
import type { z } from 'zod';
import { NodePropsSchema } from '../utils/index.js';
export const EditorSettingSchema = NodePropsSchema;
export type EditorSetting = z.infer<typeof EditorSettingSchema>;
export const EditorSettingProvider = createIdentifier<
Signal<DeepPartial<EditorSetting>>
>('AffineEditorSettingProvider');
export function EditorSettingExtension(
signal: Signal<DeepPartial<EditorSetting>>
): ExtensionType {
return {
setup: di => {
di.addImpl(EditorSettingProvider, () => signal);
},
};
}

View File

@@ -0,0 +1,44 @@
import type { EmbedCardStyle } from '@blocksuite/affine-model';
import { Extension } from '@blocksuite/block-std';
import type { Container } from '@blocksuite/global/di';
import { createIdentifier } from '@blocksuite/global/di';
export type EmbedOptions = {
flavour: string;
urlRegex: RegExp;
styles: EmbedCardStyle[];
viewType: 'card' | 'embed';
};
export interface EmbedOptionProvider {
getEmbedBlockOptions(url: string): EmbedOptions | null;
registerEmbedBlockOptions(options: EmbedOptions): void;
}
export const EmbedOptionProvider = createIdentifier<EmbedOptionProvider>(
'AffineEmbedOptionProvider'
);
export class EmbedOptionService
extends Extension
implements EmbedOptionProvider
{
private _embedBlockRegistry = new Set<EmbedOptions>();
getEmbedBlockOptions = (url: string): EmbedOptions | null => {
const entries = this._embedBlockRegistry.entries();
for (const [options] of entries) {
const regex = options.urlRegex;
if (regex.test(url)) return options;
}
return null;
};
registerEmbedBlockOptions = (options: EmbedOptions): void => {
this._embedBlockRegistry.add(options);
};
static override setup(di: Container) {
di.addImpl(EmbedOptionProvider, EmbedOptionService);
}
}

View File

@@ -0,0 +1,376 @@
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
export interface FontConfig {
font: string;
weight: string;
url: string;
style: string;
}
export const AffineCanvasTextFonts: FontConfig[] = [
// Inter, https://fonts.cdnfonts.com/css/inter?styles=29139,29134,29135,29136,29140,29141
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-Light-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-LightItalic-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Kalam, https://fonts.cdnfonts.com/css/kalam?styles=15166,170689,170687
{
font: FontFamily.Kalam,
url: 'https://cdn.affine.pro/fonts/Kalam-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://cdn.affine.pro/fonts/Kalam-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://cdn.affine.pro/fonts/Kalam-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
// Satoshi, https://fonts.cdnfonts.com/css/satoshi?styles=135009,135004,135005,135006,135002,135003
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://cdn.affine.pro/fonts/Satoshi-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Poppins, https://fonts.cdnfonts.com/css/poppins?styles=20394,20389,20390,20391,20395,20396
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Medium.woff',
weight: FontWeight.Medium,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://cdn.affine.pro/fonts/Poppins-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Lora, https://fonts.cdnfonts.com/css/lora-4?styles=50357,50356,50354,50355
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Lora,
url: 'https://cdn.affine.pro/fonts/Lora-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// BebasNeue, https://fonts.cdnfonts.com/css/bebas-neue?styles=169713,17622,17620
{
font: FontFamily.BebasNeue,
url: 'https://cdn.affine.pro/fonts/BebasNeue-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.BebasNeue,
url: 'https://cdn.affine.pro/fonts/BebasNeue-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
// OrelegaOne, https://fonts.cdnfonts.com/css/orelega-one?styles=148618
{
font: FontFamily.OrelegaOne,
url: 'https://cdn.affine.pro/fonts/OrelegaOne-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
];
export const CommunityCanvasTextFonts: FontConfig[] = [
// Inter, https://fonts.cdnfonts.com/css/inter?styles=29139,29134,29135,29136,29140,29141
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-Light-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-LightItalic-BETA.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Inter,
url: 'https://fonts.cdnfonts.com/s/19795/Inter-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Kalam, https://fonts.cdnfonts.com/css/kalam?styles=15166,170689,170687
{
font: FontFamily.Kalam,
url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Kalam,
url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
// Satoshi, https://fonts.cdnfonts.com/css/satoshi?styles=135009,135004,135005,135006,135002,135003
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Satoshi,
url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Poppins, https://fonts.cdnfonts.com/css/poppins?styles=20394,20389,20390,20391,20395,20396
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Medium.woff',
weight: FontWeight.Medium,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-SemiBold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-LightItalic.woff',
weight: FontWeight.Light,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Poppins,
url: 'https://fonts.cdnfonts.com/s/16009/Poppins-SemiBoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// Lora, https://fonts.cdnfonts.com/css/lora-4?styles=50357,50356,50354,50355
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-Bold.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Normal,
},
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-Italic.woff',
weight: FontWeight.Regular,
style: FontStyle.Italic,
},
{
font: FontFamily.Lora,
url: 'https://fonts.cdnfonts.com/s/29883/Lora-BoldItalic.woff',
weight: FontWeight.SemiBold,
style: FontStyle.Italic,
},
// BebasNeue, https://fonts.cdnfonts.com/css/bebas-neue?styles=169713,17622,17620
{
font: FontFamily.BebasNeue,
url: 'https://fonts.cdnfonts.com/s/14902/BebasNeue%20Light.woff',
weight: FontWeight.Light,
style: FontStyle.Normal,
},
{
font: FontFamily.BebasNeue,
url: 'https://fonts.cdnfonts.com/s/14902/BebasNeue-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
// OrelegaOne, https://fonts.cdnfonts.com/css/orelega-one?styles=148618
{
font: FontFamily.OrelegaOne,
url: 'https://fonts.cdnfonts.com/s/93179/OrelegaOne-Regular.woff',
weight: FontWeight.Regular,
style: FontStyle.Normal,
},
];

View File

@@ -0,0 +1,61 @@
import { type ExtensionType, LifeCycleWatcher } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import { IS_FIREFOX } from '@blocksuite/global/env';
import type { FontConfig } from './config.js';
const initFontFace = IS_FIREFOX
? ({ font, weight, url, style }: FontConfig) =>
new FontFace(`"${font}"`, `url(${url})`, {
weight,
style,
})
: ({ font, weight, url, style }: FontConfig) =>
new FontFace(font, `url(${url})`, {
weight,
style,
});
export class FontLoaderService extends LifeCycleWatcher {
static override readonly key = 'font-loader';
readonly fontFaces: FontFace[] = [];
get ready() {
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
}
load(fonts: FontConfig[]) {
this.fontFaces.push(
...fonts.map(font => {
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
return fontFace;
})
);
}
override mounted() {
const config = this.std.getOptional(FontConfigIdentifier);
if (config) {
this.load(config);
}
}
override unmounted() {
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
this.fontFaces.splice(0, this.fontFaces.length);
}
}
export const FontConfigIdentifier =
createIdentifier<FontConfig[]>('AffineFontConfig');
export const FontConfigExtension = (
fontConfig: FontConfig[]
): ExtensionType => ({
setup: di => {
di.addImpl(FontConfigIdentifier, () => fontConfig);
},
});

View File

@@ -0,0 +1,2 @@
export * from './config.js';
export * from './font-loader-service.js';

View File

@@ -0,0 +1,21 @@
import type { ReferenceParams } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
export interface GenerateDocUrlService {
generateDocUrl: (docId: string, params?: ReferenceParams) => string | void;
}
export const GenerateDocUrlProvider = createIdentifier<GenerateDocUrlService>(
'GenerateDocUrlService'
);
export function GenerateDocUrlExtension(
generateDocUrlProvider: GenerateDocUrlService
): ExtensionType {
return {
setup: di => {
di.addImpl(GenerateDocUrlProvider, generateDocUrlProvider);
},
};
}

View File

@@ -0,0 +1,13 @@
export * from './doc-display-meta-service.js';
export * from './doc-mode-service.js';
export * from './drag-handle-config.js';
export * from './edit-props-store.js';
export * from './editor-setting-service.js';
export * from './embed-option-service.js';
export * from './font-loader/index.js';
export * from './generate-url-service.js';
export * from './notification-service.js';
export * from './parse-url-service.js';
export * from './quick-search-service.js';
export * from './telemetry-service/index.js';
export * from './theme-service.js';

View File

@@ -0,0 +1,55 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { TemplateResult } from 'lit';
export interface NotificationService {
toast(
message: string,
options?: {
duration?: number;
portal?: HTMLElement;
}
): void;
confirm(options: {
title: string | TemplateResult;
message: string | TemplateResult;
confirmText?: string;
cancelText?: string;
abort?: AbortSignal;
}): Promise<boolean>;
prompt(options: {
title: string | TemplateResult;
message: string | TemplateResult;
autofill?: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
abort?: AbortSignal;
}): Promise<string | null>; // when cancel, return null
notify(options: {
title: string | TemplateResult;
message?: string | TemplateResult;
accent?: 'info' | 'success' | 'warning' | 'error';
duration?: number; // unit ms, give 0 to disable auto dismiss
abort?: AbortSignal;
action?: {
label: string | TemplateResult;
onClick: () => void;
};
onClose: () => void;
}): void;
}
export const NotificationProvider = createIdentifier<NotificationService>(
'AffineNotificationService'
);
export function NotificationExtension(
notificationService: NotificationService
): ExtensionType {
return {
setup: di => {
di.addImpl(NotificationProvider, notificationService);
},
};
}

View File

@@ -0,0 +1,22 @@
import type { ReferenceParams } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
export interface ParseDocUrlService {
parseDocUrl: (
url: string
) => ({ docId: string } & ReferenceParams) | undefined;
}
export const ParseDocUrlProvider =
createIdentifier<ParseDocUrlService>('ParseDocUrlService');
export function ParseDocUrlExtension(
parseDocUrlService: ParseDocUrlService
): ExtensionType {
return {
setup: di => {
di.addImpl(ParseDocUrlProvider, parseDocUrlService);
},
};
}

View File

@@ -0,0 +1,31 @@
import type { ReferenceParams } from '@blocksuite/affine-model';
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
export interface QuickSearchService {
openQuickSearch: () => Promise<QuickSearchResult>;
}
export type QuickSearchResult =
| {
docId: string;
params?: ReferenceParams;
}
| {
externalUrl: string;
}
| null;
export const QuickSearchProvider = createIdentifier<QuickSearchService>(
'AffineQuickSearchService'
);
export function QuickSearchExtension(
quickSearchService: QuickSearchService
): ExtensionType {
return {
setup: di => {
di.addImpl(QuickSearchProvider, quickSearchService);
},
};
}

View File

@@ -0,0 +1,56 @@
type OrderType = 'desc' | 'asc';
export type WithParams<Map, T> = { [K in keyof Map]: Map[K] & T };
export type SortParams = {
fieldId: string;
fieldType: string;
orderType: OrderType;
orderIndex: number;
};
export type ViewParams = {
viewId: string;
viewType: string;
};
export type DatabaseParams = {
blockId: string;
};
export type DatabaseViewEvents = {
DatabaseSortClear: {
rulesCount: number;
};
};
export type DatabaseEvents = {
AddDatabase: {};
};
export interface DatabaseAllSortEvents {
DatabaseSortAdd: {};
DatabaseSortRemove: {};
DatabaseSortModify: {
oldOrderType: OrderType;
oldFieldType: string;
oldFieldId: string;
};
DatabaseSortReorder: {
prevFieldType: string;
nextFieldType: string;
newOrderIndex: number;
};
}
export type DatabaseAllViewEvents = DatabaseViewEvents &
WithParams<DatabaseAllSortEvents, SortParams>;
export type DatabaseAllEvents = DatabaseEvents &
WithParams<DatabaseAllViewEvents, ViewParams>;
export type OutDatabaseAllEvents = WithParams<
DatabaseAllEvents,
DatabaseParams
>;
export type EventTraceFn<Events> = <K extends keyof Events>(
key: K,
params: Events[K]
) => void;

View File

@@ -0,0 +1,4 @@
export * from './database.js';
export * from './link.js';
export * from './telemetry-service.js';
export * from './types.js';

View File

@@ -0,0 +1,16 @@
import type { TelemetryEvent } from './types.js';
export type LinkEventType =
| 'CopiedLink'
| 'OpenedAliasPopup'
| 'SavedAlias'
| 'ResetedAlias'
| 'OpenedViewSelector'
| 'SelectedView'
| 'OpenedCaptionEditor'
| 'OpenedCardStyleSelector'
| 'SelectedCardStyle'
| 'OpenedCardScaleSelector'
| 'SelectedCardScale';
export type LinkToolbarEvents = Record<LinkEventType, TelemetryEvent>;

View File

@@ -0,0 +1,35 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { OutDatabaseAllEvents } from './database.js';
import type { LinkToolbarEvents } from './link.js';
import type {
AttachmentUploadedEvent,
DocCreatedEvent,
ElementCreationEvent,
ElementLockEvent,
MindMapCollapseEvent,
TelemetryEvent,
} from './types.js';
export type TelemetryEventMap = OutDatabaseAllEvents &
LinkToolbarEvents & {
DocCreated: DocCreatedEvent;
Link: TelemetryEvent;
LinkedDocCreated: TelemetryEvent;
SplitNote: TelemetryEvent;
CanvasElementAdded: ElementCreationEvent;
EdgelessElementLocked: ElementLockEvent;
ExpandedAndCollapsed: MindMapCollapseEvent;
AttachmentUploadedEvent: AttachmentUploadedEvent;
};
export interface TelemetryService {
track<T extends keyof TelemetryEventMap>(
eventName: T,
props: TelemetryEventMap[T]
): void;
}
export const TelemetryProvider = createIdentifier<TelemetryService>(
'AffineTelemetryService'
);

View File

@@ -0,0 +1,63 @@
export type ElementCreationSource =
| 'shortcut'
| 'toolbar:general'
| 'toolbar:dnd'
| 'canvas:drop'
| 'canvas:draw'
| 'canvas:dbclick'
| 'canvas:paste'
| 'context-menu'
| 'ai'
| 'internal'
| 'conversation'
| 'manually save';
export interface TelemetryEvent {
page?: string;
segment?: string;
module?: string;
control?: string;
type?: string;
category?: string;
other?: unknown;
}
export interface DocCreatedEvent extends TelemetryEvent {
page?: 'doc editor' | 'whiteboard editor';
segment?: 'whiteboard' | 'note' | 'doc';
module?:
| 'slash commands'
| 'format toolbar'
| 'edgeless toolbar'
| 'inline @';
category?: 'page' | 'whiteboard';
}
export interface ElementCreationEvent extends TelemetryEvent {
segment?: 'toolbar' | 'whiteboard' | 'right sidebar';
page?: 'doc editor' | 'whiteboard editor';
module?: 'toolbar' | 'canvas' | 'ai chat panel';
control?: ElementCreationSource;
}
export interface ElementLockEvent extends TelemetryEvent {
page: 'whiteboard editor';
segment: 'element toolbar';
module: 'element toolbar';
control: 'lock' | 'unlock' | 'group-lock';
}
export interface MindMapCollapseEvent extends TelemetryEvent {
page: 'whiteboard editor';
segment: 'mind map';
type: 'expand' | 'collapse';
}
export interface AttachmentUploadedEvent extends TelemetryEvent {
page: 'doc editor' | 'whiteboard editor';
segment: 'attachment';
module: 'attachment';
control: 'uploader';
type: string; // file type
category: 'success' | 'failure';
}

View File

@@ -0,0 +1,207 @@
import { type Color, ColorScheme } from '@blocksuite/affine-model';
import {
type BlockStdScope,
Extension,
type ExtensionType,
StdIdentifier,
} from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { type Signal, signal } from '@preact/signals-core';
import {
type AffineCssVariables,
combinedDarkCssVariables,
combinedLightCssVariables,
} from '@toeverything/theme';
import { isInsideEdgelessEditor } from '../utils/index.js';
const TRANSPARENT = 'transparent';
export const ThemeExtensionIdentifier = createIdentifier<ThemeExtension>(
'AffineThemeExtension'
);
export interface ThemeExtension {
getAppTheme?: () => Signal<ColorScheme>;
getEdgelessTheme?: (docId?: string) => Signal<ColorScheme>;
}
export function OverrideThemeExtension(service: ThemeExtension): ExtensionType {
return {
setup: di => {
di.override(ThemeExtensionIdentifier, () => service);
},
};
}
export const ThemeProvider = createIdentifier<ThemeService>(
'AffineThemeProvider'
);
export class ThemeService extends Extension {
app$: Signal<ColorScheme>;
edgeless$: Signal<ColorScheme>;
get appTheme() {
return this.app$.peek();
}
get edgelessTheme() {
return this.edgeless$.peek();
}
get theme() {
return isInsideEdgelessEditor(this.std.host)
? this.edgelessTheme
: this.appTheme;
}
get theme$() {
return isInsideEdgelessEditor(this.std.host) ? this.edgeless$ : this.app$;
}
constructor(private std: BlockStdScope) {
super();
const extension = this.std.getOptional(ThemeExtensionIdentifier);
this.app$ = extension?.getAppTheme?.() || getThemeObserver().theme$;
this.edgeless$ =
extension?.getEdgelessTheme?.(this.std.doc.id) ||
getThemeObserver().theme$;
}
static override setup(di: Container) {
di.addImpl(ThemeProvider, ThemeService, [StdIdentifier]);
}
/**
* Generates a CSS's color property with `var` or `light-dark` functions.
*
* Sometimes used to set the frame/note background.
*
* @param color - A color value.
* @param fallback - If color value processing fails, it will be used as a fallback.
* @returns - A color property string.
*
* @example
*
* ```
* `rgba(255,0,0)`
* `#fff`
* `light-dark(#fff, #000)`
* `var(--affine-palette-shape-blue)`
* ```
*/
generateColorProperty(
color: Color,
fallback = 'transparent',
theme = this.theme
) {
let result: string | undefined = undefined;
if (typeof color === 'object') {
result = color[theme] ?? color.normal;
} else {
result = color;
}
if (!result) {
result = fallback;
}
if (result.startsWith('--')) {
return result.endsWith(TRANSPARENT) ? TRANSPARENT : `var(${result})`;
}
return result ?? TRANSPARENT;
}
/**
* Gets a color with the current theme.
*
* @param color - A color value.
* @param fallback - If color value processing fails, it will be used as a fallback.
* @param real - If true, it returns the computed style.
* @returns - A color property string.
*
* @example
*
* ```
* `rgba(255,0,0)`
* `#fff`
* `--affine-palette-shape-blue`
* ```
*/
getColorValue(
color: Color,
fallback = TRANSPARENT,
real = false,
theme = this.theme
) {
let result: string | undefined = undefined;
if (typeof color === 'object') {
result = color[theme] ?? color.normal;
} else {
result = color;
}
if (!result) {
result = fallback;
}
if (real && result.startsWith('--')) {
result = result.endsWith(TRANSPARENT)
? TRANSPARENT
: this.getCssVariableColor(result, theme);
}
return result ?? TRANSPARENT;
}
getCssVariableColor(property: string, theme = this.theme) {
if (property.startsWith('--')) {
if (property.endsWith(TRANSPARENT)) {
return TRANSPARENT;
}
const key = property as keyof AffineCssVariables;
const color =
theme === ColorScheme.Dark
? combinedDarkCssVariables[key]
: combinedLightCssVariables[key];
return color;
}
return property;
}
}
export class ThemeObserver {
private observer: MutationObserver;
theme$ = signal(ColorScheme.Light);
constructor() {
const COLOR_SCHEMES: string[] = Object.values(ColorScheme);
this.observer = new MutationObserver(() => {
const mode = document.documentElement.dataset.theme;
if (!mode) return;
if (!COLOR_SCHEMES.includes(mode)) return;
if (mode === this.theme$.value) return;
this.theme$.value = mode as ColorScheme;
});
this.observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
}
destroy() {
this.observer.disconnect();
}
}
export const getThemeObserver = (function () {
let observer: ThemeObserver;
return function () {
if (observer) return observer;
observer = new ThemeObserver();
return observer;
};
})();