refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions
@@ -0,0 +1,60 @@
import type { CallbackMap } from '@affine/sdk/entry';
import { assertExists } from '@blocksuite/global/utils';
import { atomWithStorage } from 'jotai/utils';
import { atom } from 'jotai/vanilla';
import type { z } from 'zod';
import type { packageJsonOutputSchema } from '../type.js';
export const builtinPluginPaths = new Set([
'/plugins/copilot',
'/plugins/hello-world',
'/plugins/image-preview',
'/plugins/vue-hello-world',
'/plugins/outline',
]);
const pluginCleanupMap = new Map<string, Set<() => void>>();
export function addCleanup(
pluginName: string,
cleanup: () => void
): () => void {
if (!pluginCleanupMap.has(pluginName)) {
pluginCleanupMap.set(pluginName, new Set());
}
const cleanupSet = pluginCleanupMap.get(pluginName);
assertExists(cleanupSet);
cleanupSet.add(cleanup);
return () => {
cleanupSet.delete(cleanup);
};
}
export function invokeCleanup(pluginName: string) {
pluginCleanupMap.get(pluginName)?.forEach(cleanup => cleanup());
pluginCleanupMap.delete(pluginName);
}
export const pluginPackageJson = atom<
z.infer<typeof packageJsonOutputSchema>[]
>([]);
export const enabledPluginAtom = atomWithStorage('affine-enabled-plugin', [
'@affine/image-preview-plugin',
'@affine/outline-plugin',
]);
export const pluginHeaderItemAtom = atom<
Record<string, CallbackMap['headerItem']>
>({});
export const pluginSettingAtom = atom<Record<string, CallbackMap['setting']>>(
{}
);
export const pluginEditorAtom = atom<Record<string, CallbackMap['editor']>>({});
export const pluginWindowAtom = atom<
Record<string, (root: HTMLElement) => () => void>
>({});
@@ -0,0 +1,79 @@
import type { ActiveDocProvider, Workspace } from '@blocksuite/store';
import type { PassiveDocProvider } from '@blocksuite/store';
import type { Atom } from 'jotai/vanilla';
import { atom } from 'jotai/vanilla';
import { atomEffect } from 'jotai-effect';
/**
* Map: guid -> Workspace
*/
export const INTERNAL_BLOCKSUITE_HASH_MAP = new Map<string, Workspace>([]);
const workspaceActiveAtomWeakMap = new WeakMap<
Workspace,
Atom<Promise<Workspace>>
>();
const workspaceActiveWeakMap = new WeakMap<Workspace, boolean>();
const workspaceEffectAtomWeakMap = new WeakMap<Workspace, Atom<void>>();
export async function waitForWorkspace(workspace: Workspace) {
if (workspaceActiveWeakMap.get(workspace) !== true) {
const providers = workspace.providers.filter(
(provider): provider is ActiveDocProvider =>
'active' in provider && provider.active === true
);
for (const provider of providers) {
provider.sync();
// we will wait for the necessary providers to be ready
await provider.whenReady;
}
// timeout is INFINITE
workspaceActiveWeakMap.set(workspace, true);
}
}
export function getWorkspace(id: string) {
if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) {
throw new Error('Workspace not found');
}
return INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace;
}
export function getBlockSuiteWorkspaceAtom(
id: string
): [workspaceAtom: Atom<Promise<Workspace>>, workspaceEffectAtom: Atom<void>] {
if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) {
throw new Error('Workspace not found');
}
const workspace = INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace;
if (!workspaceActiveAtomWeakMap.has(workspace)) {
const baseAtom = atom(async () => {
await waitForWorkspace(workspace);
return workspace;
});
workspaceActiveAtomWeakMap.set(workspace, baseAtom);
}
if (!workspaceEffectAtomWeakMap.has(workspace)) {
const effectAtom = atomEffect(() => {
const providers = workspace.providers.filter(
(provider): provider is PassiveDocProvider =>
'passive' in provider && provider.passive === true
);
providers.forEach(provider => {
provider.connect();
});
return () => {
providers.forEach(provider => {
provider.disconnect();
});
};
});
workspaceEffectAtomWeakMap.set(workspace, effectAtom);
}
return [
workspaceActiveAtomWeakMap.get(workspace) as Atom<Promise<Workspace>>,
workspaceEffectAtomWeakMap.get(workspace) as Atom<void>,
];
}
@@ -0,0 +1,55 @@
/**
* @vitest-environment happy-dom
*/
import { Schema, Workspace } from '@blocksuite/store';
import { waitFor } from '@testing-library/react';
import { getDefaultStore } from 'jotai/vanilla';
import { expect, test, vi } from 'vitest';
import {
getBlockSuiteWorkspaceAtom,
INTERNAL_BLOCKSUITE_HASH_MAP,
} from '../__internal__/workspace.js';
test('blocksuite atom', async () => {
const sync = vi.fn();
let connected = false;
const connect = vi.fn(() => (connected = true));
const workspace = new Workspace({
schema: new Schema(),
id: '1',
providerCreators: [
() => ({
flavour: 'fake',
active: true,
sync,
get whenReady(): Promise<void> {
return Promise.resolve();
},
}),
() => ({
flavour: 'fake-2',
passive: true,
get connected() {
return connected;
},
connect,
disconnect: vi.fn(),
}),
],
});
INTERNAL_BLOCKSUITE_HASH_MAP.set('1', workspace);
{
const [atom, effectAtom] = getBlockSuiteWorkspaceAtom('1');
const store = getDefaultStore();
const result = await store.get(atom);
expect(result).toBe(workspace);
expect(sync).toBeCalledTimes(1);
expect(connect).not.toHaveBeenCalled();
store.sub(effectAtom, vi.fn());
await waitFor(() => expect(connect).toBeCalledTimes(1));
expect(connected).toBe(true);
}
});
@@ -0,0 +1,83 @@
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { describe, expect, test } from 'vitest';
import type { Array as YArray, Map as YMap } from 'yjs';
import { applyUpdate, Doc } from 'yjs';
import { migrateToSubdoc } from '../blocksuite/index.js';
const fixturePath = resolve(
dirname(fileURLToPath(import.meta.url)),
'workspace.ydoc'
);
const yDocBuffer = readFileSync(fixturePath);
const doc = new Doc();
applyUpdate(doc, new Uint8Array(yDocBuffer));
const migratedDoc = migrateToSubdoc(doc);
describe('migration', () => {
test('migration to subdoc', async () => {
const { default: json } = await import('@affine-test/fixtures/output.json');
const length = Object.keys(json).length;
const binary = new Uint8Array(length);
for (let i = 0; i < length; i++) {
binary[i] = (json as any)[i];
}
const doc = new Doc();
applyUpdate(doc, binary);
{
// invoke data
doc.getMap('space:hello-world');
doc.getMap('space:meta');
}
const blocks = doc.getMap('space:hello-world').toJSON();
const newDoc = migrateToSubdoc(doc);
const subDoc = newDoc.getMap('spaces').values().next().value as Doc;
const data = (subDoc.toJSON() as any).blocks;
Object.keys(data).forEach(id => {
if (id === 'xyWNqindHH') {
return;
}
if (
blocks[id]['sys:flavour'] === 'affine:surface' &&
!blocks[id]['prop:elements']
) {
blocks[id]['prop:elements'] = data[id]['prop:elements'];
}
expect(data[id]).toEqual(blocks[id]);
});
});
test('test fixture should be set correctly', () => {
const meta = doc.getMap('space:meta');
const versions = meta.get('versions') as YMap<unknown>;
expect(versions.get('affine:code')).toBeTypeOf('number');
});
test('metadata should be migrated correctly', () => {
const originalMeta = doc.getMap('space:meta');
const originalVersions = originalMeta.get('versions') as YMap<unknown>;
const meta = migratedDoc.getMap('meta');
const blockVersions = meta.get('blockVersions') as YMap<unknown>;
expect(meta.get('workspaceVersion')).toBe(1);
expect(blockVersions.get('affine:code')).toBe(
originalVersions.get('affine:code')
);
expect((meta.get('pages') as YArray<unknown>).length).toBe(
(originalMeta.get('pages') as YArray<unknown>).length
);
expect(blockVersions.get('affine:embed')).toBeUndefined();
expect(blockVersions.get('affine:image')).toBe(
originalVersions.get('affine:embed')
);
expect(blockVersions.get('affine:frame')).toBeUndefined();
expect(blockVersions.get('affine:note')).toBe(
originalVersions.get('affine:frame')
);
});
});
Binary file not shown.
+63
View File
@@ -0,0 +1,63 @@
import type { ExpectedLayout } from '@affine/sdk/entry';
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { atom, createStore } from 'jotai/vanilla';
import { getBlockSuiteWorkspaceAtom } from './__internal__/workspace';
// global store
let rootStore = createStore();
export function getCurrentStore() {
return rootStore;
}
/**
* @internal do not use this function unless you know what you are doing
*/
export function _setCurrentStore(store: ReturnType<typeof createStore>) {
rootStore = store;
}
export const loadedPluginNameAtom = atom<string[]>([]);
export const currentWorkspaceIdAtom = atom<string | null>(null);
export const currentPageIdAtom = atom<string | null>(null);
export const currentWorkspaceAtom = atom<Promise<Workspace>>(async get => {
const workspaceId = get(currentWorkspaceIdAtom);
assertExists(workspaceId);
const [currentWorkspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
return get(currentWorkspaceAtom);
});
const contentLayoutBaseAtom = atom<ExpectedLayout>('editor');
type SetStateAction<Value> = Value | ((prev: Value) => Value);
export const contentLayoutAtom = atom<
ExpectedLayout,
[SetStateAction<ExpectedLayout>],
void
>(
get => get(contentLayoutBaseAtom),
(_, set, layout) => {
set(contentLayoutBaseAtom, prev => {
let setV: (prev: ExpectedLayout) => ExpectedLayout;
if (typeof layout !== 'function') {
setV = () => layout;
} else {
setV = layout;
}
const nextValue = setV(prev);
if (nextValue === 'editor') {
return nextValue;
}
if (nextValue.first !== 'editor') {
throw new Error('The first element of the layout should be editor.');
}
if (nextValue.splitPercentage && nextValue.splitPercentage < 70) {
throw new Error('The split percentage should be greater than 70.');
}
return nextValue;
});
}
);
@@ -0,0 +1,704 @@
import type { Page, PageMeta, Workspace } from '@blocksuite/store';
import { createIndexeddbStorage } from '@blocksuite/store';
import type { createStore, WritableAtom } from 'jotai/vanilla';
import type { Doc } from 'yjs';
import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
export async function initEmptyPage(page: Page, title?: string) {
await page.waitForLoaded();
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(title ?? ''),
});
page.addBlock('affine:surface', {}, pageBlockId);
const noteBlockId = page.addBlock('affine:note', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, noteBlockId);
}
export async function buildEmptyBlockSuite(workspace: Workspace) {
const page = workspace.createPage();
await initEmptyPage(page);
workspace.setPageMeta(page.id, {
jumpOnce: true,
});
}
export async function buildShowcaseWorkspace(
workspace: Workspace,
options: {
schema: Schema;
atoms: {
pageMode: WritableAtom<
undefined,
[pageId: string, mode: 'page' | 'edgeless'],
void
>;
};
store: ReturnType<typeof createStore>;
}
) {
const prototypes = {
tags: {
options: [
{
id: 'icg1n5UdkP',
value: 'Travel',
color: 'var(--affine-tag-gray)',
},
{
id: 'Oe5dSe1DDJ',
value: 'Quick summary',
color: 'var(--affine-tag-green)',
},
{
id: 'g1L5dXKctL',
value: 'OKR',
color: 'var(--affine-tag-purple)',
},
{
id: 'q3mceOl_zi',
value: 'Streamline your workflow',
color: 'var(--affine-tag-teal)',
},
{
id: 'ze07JVwBu4',
value: 'Plan',
color: 'var(--affine-tag-teal)',
},
{
id: '8qcYPCTK0h',
value: 'Review',
color: 'var(--affine-tag-orange)',
},
{
id: 'wg-fBtd2eI',
value: 'Engage',
color: 'var(--affine-tag-pink)',
},
{
id: 'QYFD_HeQc-',
value: 'Create',
color: 'var(--affine-tag-blue)',
},
{
id: 'ZHBa2NtdSo',
value: 'Learn',
color: 'var(--affine-tag-yellow)',
},
],
},
};
workspace.meta.setProperties(prototypes);
const edgelessPage1 = nanoid();
const edgelessPage2 = nanoid();
const edgelessPage3 = nanoid();
const { store, atoms } = options;
[edgelessPage1, edgelessPage2, edgelessPage3].forEach(pageId => {
store.set(atoms.pageMode, pageId, 'edgeless');
});
const pageMetas = {
'9f6f3c04-cf32-470c-9648-479dc838f10e': {
createDate: 1691548231530,
tags: ['ZHBa2NtdSo', 'QYFD_HeQc-', 'wg-fBtd2eI'],
updatedDate: 1691676331623,
favorite: true,
jumpOnce: true,
},
'0773e198-5de0-45d4-a35e-de22ea72b96b': {
createDate: 1691548220794,
tags: [],
updatedDate: 1691676775642,
favorite: false,
},
'59b140eb-4449-488f-9eeb-42412dcc044e': {
createDate: 1691551731225,
tags: [],
updatedDate: 1691654611175,
favorite: false,
},
'7217fbe2-61db-4a91-93c6-ad5c800e5a43': {
createDate: 1691552082822,
tags: [],
updatedDate: 1691654606912,
favorite: false,
},
'6eb43ea8-8c11-456d-bb1d-5193937961ab': {
createDate: 1691552090989,
tags: [],
updatedDate: 1691646748171,
favorite: false,
},
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a': {
createDate: 1691564303138,
tags: [],
updatedDate: 1691646845195,
},
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238': {
createDate: 1691574743531,
tags: ['icg1n5UdkP'],
updatedDate: 1691647117761,
},
'22163830-8252-43fe-b62d-fd9bbeaa4caa': {
createDate: 1691574859042,
tags: [],
updatedDate: 1691648159371,
},
'b7a9e1bc-e205-44aa-8dad-7e328269d00b': {
createDate: 1691575011078,
tags: ['8qcYPCTK0h'],
updatedDate: 1691645074511,
favorite: false,
},
'646305d9-93e0-48df-bb92-d82944ceb5a3': {
createDate: 1691634722239,
tags: ['ze07JVwBu4'],
updatedDate: 1691647069662,
favorite: false,
},
'0350509d-8702-4797-b4d7-168f5e9359c7': {
createDate: 1691635388447,
tags: ['Oe5dSe1DDJ'],
updatedDate: 1691645873930,
},
'aa02af3c-5c5c-4856-b7ce-947ad17331f3': {
createDate: 1691636192263,
tags: ['q3mceOl_zi', 'g1L5dXKctL'],
updatedDate: 1691645102104,
},
'9d6e716e-a071-45a2-88ac-2f2f6eec0109': {
createDate: 1691574743531,
tags: ['icg1n5UdkP'],
updatedDate: 1691574743531,
},
} satisfies Record<string, Partial<PageMeta>>;
const data = [
[
'9f6f3c04-cf32-470c-9648-479dc838f10e',
import('@affine/templates/v1/getting-started.json'),
nanoid(),
],
[
'0773e198-5de0-45d4-a35e-de22ea72b96b',
import('@affine/templates/v1/preloading.json'),
edgelessPage1,
],
[
'59b140eb-4449-488f-9eeb-42412dcc044e',
import('@affine/templates/v1/template-galleries.json'),
nanoid(),
],
[
'7217fbe2-61db-4a91-93c6-ad5c800e5a43',
import('@affine/templates/v1/personal-home.json'),
nanoid(),
],
[
'6eb43ea8-8c11-456d-bb1d-5193937961ab',
import('@affine/templates/v1/working-home.json'),
nanoid(),
],
[
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a',
import('@affine/templates/v1/personal-project-management.json'),
nanoid(),
],
[
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238',
import('@affine/templates/v1/travel-plan.json'),
edgelessPage2,
],
[
'22163830-8252-43fe-b62d-fd9bbeaa4caa',
import('@affine/templates/v1/personal-knowledge-management.json'),
nanoid(),
],
[
'b7a9e1bc-e205-44aa-8dad-7e328269d00b',
import('@affine/templates/v1/annual-performance-review.json'),
nanoid(),
],
[
'646305d9-93e0-48df-bb92-d82944ceb5a3',
import('@affine/templates/v1/brief-event-planning.json'),
nanoid(),
],
[
'0350509d-8702-4797-b4d7-168f5e9359c7',
import('@affine/templates/v1/meeting-summary.json'),
nanoid(),
],
[
'aa02af3c-5c5c-4856-b7ce-947ad17331f3',
import('@affine/templates/v1/okr-template.json'),
nanoid(),
],
[
'9d6e716e-a071-45a2-88ac-2f2f6eec0109',
import('@affine/templates/v1/travel-note.json'),
edgelessPage3,
],
] as const;
const idMap = await Promise.all(data).then(async data => {
return data.reduce<Record<string, string>>(
(record, currentValue) => {
const [oldId, _, newId] = currentValue;
record[oldId] = newId;
return record;
},
{} as Record<string, string>
);
});
await Promise.all(
data.map(async ([id, promise, newId]) => {
const { default: template } = await promise;
let json = JSON.stringify(template);
Object.entries(idMap).forEach(([oldId, newId]) => {
json = json.replaceAll(oldId, newId);
});
json = JSON.parse(json);
await workspace
.importPageSnapshot(structuredClone(json), newId)
.catch(error => {
console.error('error importing page', id, error);
});
const page = workspace.getPage(newId);
assertExists(page);
await page.waitForLoaded();
workspace.schema.upgradePage(
0,
{
'affine:note': 1,
'affine:bookmark': 1,
'affine:database': 2,
'affine:divider': 1,
'affine:image': 1,
'affine:list': 1,
'affine:code': 1,
'affine:page': 2,
'affine:paragraph': 1,
'affine:surface': 3,
},
page.spaceDoc
);
})
);
Object.entries(pageMetas).forEach(([oldId, meta]) => {
const newId = idMap[oldId];
workspace.setPageMeta(newId, meta);
});
}
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
const migrationOrigin = 'affine-migration';
import { assertExists } from '@blocksuite/global/utils';
import type { Schema } from '@blocksuite/store';
import { nanoid } from 'nanoid';
type XYWH = [number, number, number, number];
function deserializeXYWH(xywh: string): XYWH {
return JSON.parse(xywh) as XYWH;
}
const getLatestVersions = (schema: Schema): Record<string, number> => {
return [...schema.flavourSchemaMap.entries()].reduce(
(record, [flavour, schema]) => {
record[flavour] = schema.version;
return record;
},
{} as Record<string, number>
);
};
function migrateDatabase(data: YMap<unknown>) {
data.delete('prop:mode');
data.set('prop:views', new YArray());
const columns = (data.get('prop:columns') as YArray<unknown>).toJSON() as {
id: string;
name: string;
hide: boolean;
type: string;
width: number;
selection?: unknown[];
}[];
const views = [
{
id: 'default',
name: 'Table',
columns: columns.map(col => ({
id: col.id,
width: col.width,
hide: col.hide,
})),
filter: { type: 'group', op: 'and', conditions: [] },
mode: 'table',
},
];
const cells = (data.get('prop:cells') as YMap<unknown>).toJSON() as Record<
string,
Record<
string,
{
id: string;
value: unknown;
}
>
>;
const convertColumn = (
id: string,
update: (cell: { id: string; value: unknown }) => void
) => {
Object.values(cells).forEach(row => {
if (row[id] != null) {
update(row[id]);
}
});
};
const newColumns = columns.map(v => {
let data: Record<string, unknown> = {};
if (v.type === 'select' || v.type === 'multi-select') {
data = { options: v.selection };
if (v.type === 'select') {
convertColumn(v.id, cell => {
if (Array.isArray(cell.value)) {
cell.value = cell.value[0]?.id;
}
});
} else {
convertColumn(v.id, cell => {
if (Array.isArray(cell.value)) {
cell.value = cell.value.map(v => v.id);
}
});
}
}
if (v.type === 'number') {
convertColumn(v.id, cell => {
if (typeof cell.value === 'string') {
cell.value = Number.parseFloat(cell.value.toString());
}
});
}
return {
id: v.id,
type: v.type,
name: v.name,
data,
};
});
data.set('prop:columns', newColumns);
data.set('prop:views', views);
data.set('prop:cells', cells);
}
function runBlockMigration(
flavour: string,
data: YMap<unknown>,
version: number
) {
if (flavour === 'affine:frame') {
data.set('sys:flavour', 'affine:note');
return;
}
if (flavour === 'affine:surface' && version <= 3) {
if (data.has('elements')) {
const elements = data.get('elements') as YMap<unknown>;
migrateSurface(elements);
data.set('prop:elements', elements.clone());
data.delete('elements');
} else {
data.set('prop:elements', new YMap());
}
}
if (flavour === 'affine:embed') {
data.set('sys:flavour', 'affine:image');
data.delete('prop:type');
}
if (flavour === 'affine:database' && version < 2) {
migrateDatabase(data);
}
}
function migrateSurface(data: YMap<unknown>) {
for (const [, value] of <IterableIterator<[string, YMap<unknown>]>>(
data.entries()
)) {
if (value.get('type') === 'connector') {
migrateSurfaceConnector(value);
}
}
}
function migrateSurfaceConnector(data: YMap<any>) {
let id = data.get('startElement')?.id;
const controllers = data.get('controllers');
const length = controllers.length;
const xywh = deserializeXYWH(data.get('xywh'));
if (id) {
data.set('source', { id });
} else {
data.set('source', {
position: [controllers[0].x + xywh[0], controllers[0].y + xywh[1]],
});
}
id = data.get('endElement')?.id;
if (id) {
data.set('target', { id });
} else {
data.set('target', {
position: [
controllers[length - 1].x + xywh[0],
controllers[length - 1].y + xywh[1],
],
});
}
const width = data.get('lineWidth') ?? 4;
data.set('strokeWidth', width);
const color = data.get('color');
data.set('stroke', color);
data.delete('startElement');
data.delete('endElement');
data.delete('controllers');
data.delete('lineWidth');
data.delete('color');
data.delete('xywh');
}
function updateBlockVersions(versions: YMap<number>) {
const frameVersion = versions.get('affine:frame');
if (frameVersion !== undefined) {
versions.set('affine:note', frameVersion);
versions.delete('affine:frame');
}
const embedVersion = versions.get('affine:embed');
if (embedVersion !== undefined) {
versions.set('affine:image', embedVersion);
versions.delete('affine:embed');
}
const databaseVersion = versions.get('affine:database');
if (databaseVersion !== undefined && databaseVersion < 2) {
versions.set('affine:database', 2);
}
}
function migrateMeta(
oldDoc: YDoc,
newDoc: YDoc,
idMap: Record<string, string>
) {
const originalMeta = oldDoc.getMap('space:meta');
const originalVersions = originalMeta.get('versions') as YMap<number>;
const originalPages = originalMeta.get('pages') as YArray<YMap<unknown>>;
const meta = newDoc.getMap('meta');
const pages = new YArray();
const blockVersions = originalVersions.clone();
meta.set('workspaceVersion', 1);
meta.set('blockVersions', blockVersions);
meta.set('pages', pages);
meta.set('name', originalMeta.get('name') as string);
updateBlockVersions(blockVersions);
const mapList = originalPages.map(page => {
const map = new YMap();
Array.from(page.entries())
.filter(([key]) => key !== 'subpageIds')
.forEach(([key, value]) => {
if (key === 'id') {
idMap[value] = nanoid();
map.set(key, idMap[value]);
} else {
map.set(key, value);
}
});
return map;
});
pages.push(mapList);
}
function migrateBlocks(
oldDoc: YDoc,
newDoc: YDoc,
idMap: Record<string, string>
) {
const spaces = newDoc.getMap('spaces');
const originalMeta = oldDoc.getMap('space:meta');
const originalVersions = originalMeta.get('versions') as YMap<number>;
const originalPages = originalMeta.get('pages') as YArray<YMap<unknown>>;
originalPages.forEach(page => {
const id = page.get('id') as string;
const newId = idMap[id];
const spaceId = id.startsWith('space:') ? id : `space:${id}`;
const originalBlocks = oldDoc.getMap(spaceId) as YMap<unknown>;
const subdoc = new YDoc();
spaces.set(newId, subdoc);
const blocks = subdoc.getMap('blocks');
Array.from(originalBlocks.entries()).forEach(([key, value]) => {
const blockData = value.clone();
blocks.set(key, blockData);
const flavour = blockData.get('sys:flavour') as string;
const version = originalVersions.get(flavour);
if (version !== undefined) {
runBlockMigration(flavour, blockData, version);
}
});
});
}
export function migrateToSubdoc(oldDoc: YDoc): YDoc {
const needMigration =
Array.from(oldDoc.getMap('space:meta').keys()).length > 0;
if (!needMigration) {
return oldDoc;
}
const newDoc = new YDoc();
const idMap = {} as Record<string, string>;
migrateMeta(oldDoc, newDoc, idMap);
migrateBlocks(oldDoc, newDoc, idMap);
return newDoc;
}
export type UpgradeOptions = {
getCurrentRootDoc: () => Promise<YDoc>;
createWorkspace: () => Promise<Workspace>;
getSchema: () => Schema;
};
const upgradeV1ToV2 = async (options: UpgradeOptions) => {
const oldDoc = await options.getCurrentRootDoc();
const newDoc = migrateToSubdoc(oldDoc);
const newWorkspace = await options.createWorkspace();
applyUpdate(newWorkspace.doc, encodeStateAsUpdate(newDoc), migrationOrigin);
newDoc.getSubdocs().forEach(subdoc => {
newWorkspace.doc.getSubdocs().forEach(newDoc => {
if (subdoc.guid === newDoc.guid) {
applyUpdate(newDoc, encodeStateAsUpdate(subdoc), migrationOrigin);
}
});
});
return newWorkspace;
};
/**
* Force upgrade block schema to the latest.
* Don't force to upgrade the pages without the check.
*
* Please note that this function will not upgrade the workspace version.
*
* @returns true if any schema is upgraded.
* @returns false if no schema is upgraded.
*/
export async function forceUpgradePages(
options: Omit<UpgradeOptions, 'createWorkspace'>
): Promise<boolean> {
const rootDoc = await options.getCurrentRootDoc();
const spaces = rootDoc.getMap('spaces') as YMap<any>;
const meta = rootDoc.getMap('meta') as YMap<unknown>;
const versions = meta.get('blockVersions') as YMap<number>;
const schema = options.getSchema();
const oldVersions = versions.toJSON();
spaces.forEach((space: Doc) => {
try {
schema.upgradePage(0, oldVersions, space);
} catch (e) {
console.error(`page ${space.guid} upgrade failed`, e);
}
});
const newVersions = getLatestVersions(schema);
meta.set('blockVersions', new YMap(Object.entries(newVersions)));
return Object.entries(oldVersions).some(
([flavour, version]) => newVersions[flavour] !== version
);
}
// database from 2 to 3
async function upgradeV2ToV3(options: UpgradeOptions): Promise<boolean> {
const rootDoc = await options.getCurrentRootDoc();
const spaces = rootDoc.getMap('spaces') as YMap<any>;
const meta = rootDoc.getMap('meta') as YMap<unknown>;
const versions = meta.get('blockVersions') as YMap<number>;
const schema = options.getSchema();
spaces.forEach((space: Doc) => {
schema.upgradePage(
0,
{
'affine:note': 1,
'affine:bookmark': 1,
'affine:database': 2,
'affine:divider': 1,
'affine:image': 1,
'affine:list': 1,
'affine:code': 1,
'affine:page': 2,
'affine:paragraph': 1,
'affine:surface': 3,
},
space
);
});
if ('affine:database' in versions) {
meta.set(
'blockVersions',
new YMap(Object.entries(getLatestVersions(schema)))
);
} else {
Object.entries(getLatestVersions(schema)).map(([flavour, version]) =>
versions.set(flavour, version)
);
}
return true;
}
export enum WorkspaceVersion {
// v1 is treated as undefined
SubDoc = 2,
DatabaseV3 = 3,
Surface = 4,
}
/**
* If returns false, it means no migration is needed.
* If returns true, it means migration is done.
* If returns Workspace, it means new workspace is created,
* and the old workspace should be deleted.
*/
export async function migrateWorkspace(
currentVersion: WorkspaceVersion | undefined,
options: UpgradeOptions
): Promise<Workspace | boolean> {
if (currentVersion === undefined) {
const workspace = await upgradeV1ToV2(options);
await upgradeV2ToV3({
...options,
getCurrentRootDoc: () => Promise.resolve(workspace.doc),
});
return workspace;
}
if (currentVersion === WorkspaceVersion.SubDoc) {
return upgradeV2ToV3(options);
} else if (currentVersion === WorkspaceVersion.DatabaseV3) {
// surface from 3 to 5
return forceUpgradePages(options);
} else {
return false;
}
}
export async function migrateLocalBlobStorage(from: string, to: string) {
const fromStorage = createIndexeddbStorage(from);
const toStorage = createIndexeddbStorage(to);
const keys = await fromStorage.crud.list();
for (const key of keys) {
const value = await fromStorage.crud.get(key);
if (!value) {
console.warn('cannot find blob:', key);
continue;
}
await toStorage.crud.set(key, value);
}
}
@@ -0,0 +1,5 @@
# AFFiNE Command Abstractions
This package contains the command abstractions for the AFFiNE framework to be used for CMD-K.
The implementation is highly inspired by the [VSCode Command Abstractions](https://github.com/microsoft/vscode)
@@ -0,0 +1,82 @@
import type { ReactNode } from 'react';
// TODO: need better way for composing different precondition strategies
export enum PreconditionStrategy {
Always,
InPaperOrEdgeless,
InPaper,
InEdgeless,
InEdgelessPresentationMode,
NoSearchResult,
Never,
}
export type CommandCategory =
| 'editor:insert-object'
| 'editor:page'
| 'editor:edgeless'
| 'affine:recent'
| 'affine:pages'
| 'affine:edgeless'
| 'affine:collections'
| 'affine:navigation'
| 'affine:creation'
| 'affine:settings'
| 'affine:layout'
| 'affine:updates'
| 'affine:help'
| 'affine:general';
export interface KeybindingOptions {
binding: string;
// some keybindings are already registered in blocksuite
// we can skip the registration of these keybindings __FOR NOW__
skipRegister?: boolean;
}
export interface AffineCommandOptions {
id: string;
// a set of predefined precondition strategies, but also allow user to customize their own
preconditionStrategy?: PreconditionStrategy | (() => boolean);
// main text on the left..
// make text a function so that we can do i18n and interpolation when we need to
label?: string | (() => string) | ReactNode | (() => ReactNode);
icon: React.ReactNode; // todo: need a mapping from string -> React element/SVG
category?: CommandCategory;
// we use https://github.com/jamiebuilds/tinykeys so that we can use the same keybinding definition
// for both mac and windows
// todo: render keybinding in command palette
keyBinding?: KeybindingOptions | string;
run: () => void | Promise<void>;
}
export interface AffineCommand {
readonly id: string;
readonly preconditionStrategy: PreconditionStrategy | (() => boolean);
readonly label?: ReactNode | string;
readonly icon?: React.ReactNode; // icon name
readonly category: CommandCategory;
readonly keyBinding?: KeybindingOptions;
run(): void | Promise<void>;
}
export function createAffineCommand(
options: AffineCommandOptions
): AffineCommand {
return {
id: options.id,
run: options.run,
icon: options.icon,
preconditionStrategy:
options.preconditionStrategy ?? PreconditionStrategy.Always,
category: options.category ?? 'affine:general',
get label() {
const label = options.label;
return typeof label === 'function' ? label?.() : label;
},
keyBinding:
typeof options.keyBinding === 'string'
? { binding: options.keyBinding }
: options.keyBinding,
};
}
@@ -0,0 +1,2 @@
export * from './command';
export * from './registry';
@@ -0,0 +1,64 @@
// @ts-expect-error upstream type is wrong
import { tinykeys } from 'tinykeys';
import {
type AffineCommand,
type AffineCommandOptions,
createAffineCommand,
} from './command';
export const AffineCommandRegistry = new (class {
readonly commands: Map<string, AffineCommand> = new Map();
register(options: AffineCommandOptions) {
if (this.commands.has(options.id)) {
console.warn(`Command ${options.id} already registered.`);
return () => {};
}
const command = createAffineCommand(options);
this.commands.set(command.id, command);
let unsubKb: (() => void) | undefined;
if (
command.keyBinding &&
!command.keyBinding.skipRegister &&
typeof window !== 'undefined'
) {
const { binding: keybinding } = command.keyBinding;
unsubKb = tinykeys(window, {
[keybinding]: async (e: Event) => {
e.preventDefault();
try {
await command.run();
} catch (e) {
console.error(`Failed to invoke keybinding [${keybinding}]`, e);
}
},
});
}
console.debug(`Registered command ${command.id}`);
return () => {
unsubKb?.();
this.commands.delete(command.id);
console.debug(`Unregistered command ${command.id}`);
};
}
get(id: string): AffineCommand | undefined {
if (!this.commands.has(id)) {
console.warn(`Command ${id} not registered.`);
return undefined;
}
return this.commands.get(id);
}
getAll(): AffineCommand[] {
return Array.from(this.commands.values());
}
})();
export function registerAffineCommand(options: AffineCommandOptions) {
return AffineCommandRegistry.register(options);
}
@@ -0,0 +1,74 @@
/**
* The MIT License (MIT)
*
* Copyright (c) 2018 Andy Wermke
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
export type EventMap = {
[key: string]: (...args: any[]) => void;
};
/**
* Type-safe event emitter.
*
* Use it like this:
*
* ```typescript
* type MyEvents = {
* error: (error: Error) => void;
* message: (from: string, content: string) => void;
* }
*
* const myEmitter = new EventEmitter() as TypedEmitter<MyEvents>;
*
* myEmitter.emit("error", "x") // <- Will catch this type error;
* ```
*
* Lifecycle:
* invoke -> handle -> emit -> on/once
*/
export interface TypedEventEmitter<Events extends EventMap> {
addListener<E extends keyof Events>(event: E, listener: Events[E]): this;
on<E extends keyof Events>(event: E, listener: Events[E]): this;
once<E extends keyof Events>(event: E, listener: Events[E]): this;
off<E extends keyof Events>(event: E, listener: Events[E]): this;
removeAllListeners<E extends keyof Events>(event?: E): this;
removeListener<E extends keyof Events>(event: E, listener: Events[E]): this;
emit<E extends keyof Events>(
event: E,
...args: Parameters<Events[E]>
): boolean;
// The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5
eventNames(): (keyof Events | string | symbol)[];
rawListeners<E extends keyof Events>(event: E): Events[E][];
listeners<E extends keyof Events>(event: E): Events[E][];
listenerCount<E extends keyof Events>(event: E): number;
handle<E extends keyof Events>(event: E, handler: Events[E]): this;
invoke<E extends keyof Events>(
event: E,
...args: Parameters<Events[E]>
): Promise<ReturnType<Events[E]>>;
getMaxListeners(): number;
setMaxListeners(maxListeners: number): this;
}
+51
View File
@@ -0,0 +1,51 @@
import type {
ClipboardHandlers,
DBHandlers,
DebugHandlers,
DialogHandlers,
ExportHandlers,
UIHandlers,
UpdaterHandlers,
WorkspaceHandlers,
} from './type.js';
import { HandlerManager } from './type.js';
export abstract class DBHandlerManager extends HandlerManager<
'db',
DBHandlers
> {}
export abstract class DebugHandlerManager extends HandlerManager<
'debug',
DebugHandlers
> {}
export abstract class DialogHandlerManager extends HandlerManager<
'dialog',
DialogHandlers
> {}
export abstract class UIHandlerManager extends HandlerManager<
'ui',
UIHandlers
> {}
export abstract class ClipboardHandlerManager extends HandlerManager<
'clipboard',
ClipboardHandlers
> {}
export abstract class ExportHandlerManager extends HandlerManager<
'export',
ExportHandlers
> {}
export abstract class UpdaterHandlerManager extends HandlerManager<
'updater',
UpdaterHandlers
> {}
export abstract class WorkspaceHandlerManager extends HandlerManager<
'workspace',
WorkspaceHandlers
> {}
+2
View File
@@ -0,0 +1,2 @@
export * from './handler.js';
export * from './type.js';
@@ -0,0 +1,230 @@
// Please add modules to `external` in `rollupOptions` to avoid wrong bundling.
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
import type { app, dialog, shell } from 'electron';
import { ipcRenderer } from 'electron';
import { Subject } from 'rxjs';
import { z } from 'zod';
export interface ExposedMeta {
handlers: [string, string[]][];
events: [string, string[]][];
}
// render <-> helper
export interface RendererToHelper {
postEvent: (channel: string, ...args: any[]) => void;
}
export interface HelperToRenderer {
[key: string]: (...args: any[]) => Promise<any>;
}
// helper <-> main
export interface HelperToMain {
getMeta: () => ExposedMeta;
}
export type MainToHelper = Pick<
typeof dialog & typeof shell & typeof app,
| 'showOpenDialog'
| 'showSaveDialog'
| 'openExternal'
| 'showItemInFolder'
| 'getPath'
>;
export function getElectronAPIs() {
const mainAPIs = getMainAPIs();
const helperAPIs = getHelperAPIs();
return {
apis: {
...mainAPIs.apis,
...helperAPIs.apis,
},
events: {
...mainAPIs.events,
...helperAPIs.events,
},
};
}
// todo: remove duplicated codes
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const isDev = process.env.NODE_ENV === 'development';
let schema = buildType === 'stable' ? 'affine' : `affine-${envBuildType}`;
schema = isDev ? 'affine-dev' : schema;
export const appInfo = {
electron: true,
schema,
};
function getMainAPIs() {
const meta: ExposedMeta = (() => {
const val = process.argv
.find(arg => arg.startsWith('--main-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
// main handlers that can be invoked from the renderer process
const apis: any = (() => {
const { handlers: handlersMeta } = meta;
const all = handlersMeta.map(([namespace, functionNames]) => {
const namespaceApis = functionNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(...args: any[]) => {
return ipcRenderer.invoke(channel, ...args);
},
];
});
return [namespace, Object.fromEntries(namespaceApis)];
});
return Object.fromEntries(all);
})();
// main events that can be listened to from the renderer process
const events: any = (() => {
const { events: eventsMeta } = meta;
// NOTE: ui may try to listen to a lot of the same events, so we increase the limit...
ipcRenderer.setMaxListeners(100);
const all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(callback: (...args: any[]) => void) => {
const fn: (
event: Electron.IpcRendererEvent,
...args: any[]
) => void = (_, ...args) => {
callback(...args);
};
ipcRenderer.on(channel, fn);
return () => {
ipcRenderer.off(channel, fn);
};
},
];
});
return [namespace, Object.fromEntries(namespaceEvents)];
});
return Object.fromEntries(all);
})();
return { apis, events };
}
const helperPort$ = new Promise<MessagePort>(resolve =>
ipcRenderer.on('helper-connection', e => {
console.info('[preload] helper-connection', e);
resolve(e.ports[0]);
})
);
const createMessagePortChannel = (port: MessagePort): EventBasedChannel => {
return {
on(listener) {
port.onmessage = e => {
listener(e.data);
};
port.start();
return () => {
port.onmessage = null;
port.close();
};
},
send(data) {
port.postMessage(data);
},
};
};
function getHelperAPIs() {
const events$ = new Subject<{ channel: string; args: any[] }>();
const meta: ExposedMeta | null = (() => {
const val = process.argv
.find(arg => arg.startsWith('--helper-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
const rendererToHelperServer: RendererToHelper = {
postEvent: (channel, ...args) => {
events$.next({ channel, args });
},
};
const rpc = AsyncCall<HelperToRenderer>(rendererToHelperServer, {
channel: helperPort$.then(helperPort =>
createMessagePortChannel(helperPort)
),
log: false,
});
const toHelperHandler = (namespace: string, name: string) => {
return rpc[`${namespace}:${name}`];
};
const toHelperEventSubscriber = (namespace: string, name: string) => {
return (callback: (...args: any[]) => void) => {
const subscription = events$.subscribe(({ channel, args }) => {
if (channel === `${namespace}:${name}`) {
callback(...args);
}
});
return () => {
subscription.unsubscribe();
};
};
};
const setup = (meta: ExposedMeta) => {
const { handlers, events } = meta;
const helperHandlers = Object.fromEntries(
handlers.map(([namespace, functionNames]) => {
return [
namespace,
Object.fromEntries(
functionNames.map(name => {
return [name, toHelperHandler(namespace, name)];
})
),
];
})
);
const helperEvents = Object.fromEntries(
events.map(([namespace, eventNames]) => {
return [
namespace,
Object.fromEntries(
eventNames.map(name => {
return [name, toHelperEventSubscriber(namespace, name)];
})
),
];
})
);
return [helperHandlers, helperEvents];
};
if (meta) {
const [apis, events] = setup(meta);
return { apis, events };
} else {
return { apis: {}, events: {} };
}
}
+279
View File
@@ -0,0 +1,279 @@
import type { ExpectedLayout } from '@affine/sdk/entry';
import type Buffer from 'buffer';
import type { WritableAtom } from 'jotai';
import { z } from 'zod';
import type { TypedEventEmitter } from './core/event-emitter.js';
type Buffer = Buffer.Buffer;
export const packageJsonInputSchema = z.object({
name: z.string(),
version: z.string(),
description: z.string(),
affinePlugin: z.object({
release: z.union([z.boolean(), z.enum(['development'])]),
entry: z.object({
core: z.string(),
}),
}),
});
export const packageJsonOutputSchema = z.object({
name: z.string(),
version: z.string(),
description: z.string(),
affinePlugin: z.object({
release: z.union([z.boolean(), z.enum(['development'])]),
entry: z.object({
core: z.string(),
}),
assets: z.array(z.string()),
}),
});
type SetStateAction<Value> = Value | ((prev: Value) => Value);
export type ContentLayoutAtom = WritableAtom<
ExpectedLayout,
[SetStateAction<ExpectedLayout>],
void
>;
export abstract class HandlerManager<
Namespace extends string,
Handlers extends Record<string, PrimitiveHandlers>,
> {
static instance: HandlerManager<string, Record<string, PrimitiveHandlers>>;
private _app: App<Namespace, Handlers>;
private _namespace: Namespace;
private _handlers: Handlers;
constructor() {
throw new Error('Method not implemented.');
}
private _initialized = false;
registerHandlers(handlers: Handlers) {
if (this._initialized) {
throw new Error('Already initialized');
}
this._handlers = handlers;
for (const [name, handler] of Object.entries(this._handlers)) {
this._app.handle(`${this._namespace}:${name}`, (async (...args: any[]) =>
handler(...args)) as any);
}
this._initialized = true;
}
invokeHandler<K extends keyof Handlers>(
name: K,
...args: Parameters<Handlers[K]>
): Promise<ReturnType<Handlers[K]>> {
return this._handlers[name](...args);
}
static getInstance(): HandlerManager<
string,
Record<string, PrimitiveHandlers>
> {
throw new Error('Method not implemented.');
}
}
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
secondaryDBPath?: string; // assume there will be only one
}
export type PrimitiveHandlers = (...args: any[]) => Promise<any>;
export type DBHandlers = {
getDocAsUpdates: (
workspaceId: string,
subdocId?: string
) => Promise<Uint8Array | false>;
applyDocUpdate: (
id: string,
update: Uint8Array,
subdocId?: string
) => Promise<void>;
addBlob: (
workspaceId: string,
key: string,
data: Uint8Array
) => Promise<void>;
getBlob: (workspaceId: string, key: string) => Promise<Buffer | null>;
deleteBlob: (workspaceId: string, key: string) => Promise<void>;
getBlobKeys: (workspaceId: string) => Promise<string[]>;
getDefaultStorageLocation: () => Promise<string>;
};
export type DebugHandlers = {
revealLogFile: () => Promise<string>;
logFilePath: () => Promise<string>;
};
export type ErrorMessage =
| 'DB_FILE_ALREADY_LOADED'
| 'DB_FILE_PATH_INVALID'
| 'DB_FILE_INVALID'
| 'DB_FILE_MIGRATION_FAILED'
| 'FILE_ALREADY_EXISTS'
| 'UNKNOWN_ERROR';
export interface LoadDBFileResult {
workspaceId?: string;
error?: ErrorMessage;
canceled?: boolean;
}
export interface SaveDBFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
}
export interface SelectDBFileLocationResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
export interface MoveDBFileResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
export type DialogHandlers = {
revealDBFile: (workspaceId: string) => Promise<void>;
loadDBFile: () => Promise<LoadDBFileResult>;
saveDBFileAs: (workspaceId: string) => Promise<SaveDBFileResult>;
moveDBFile: (
workspaceId: string,
dbFileLocation?: string
) => Promise<MoveDBFileResult>;
selectDBFileLocation: () => Promise<SelectDBFileLocationResult>;
setFakeDialogResult: (result: any) => Promise<void>;
};
export type UIHandlers = {
handleThemeChange: (theme: 'system' | 'light' | 'dark') => Promise<any>;
handleSidebarVisibilityChange: (visible: boolean) => Promise<any>;
handleMinimizeApp: () => Promise<any>;
handleMaximizeApp: () => Promise<any>;
handleCloseApp: () => Promise<any>;
getGoogleOauthCode: () => Promise<any>;
getChallengeResponse: (resource: string) => Promise<string>;
};
export type ClipboardHandlers = {
copyAsImageFromString: (dataURL: string) => Promise<void>;
};
export type ExportHandlers = {
savePDFFileAs: (title: string) => Promise<any>;
};
export interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}
export type UpdaterHandlers = {
currentVersion: () => Promise<string>;
quitAndInstall: () => Promise<void>;
checkForUpdatesAndNotify: () => Promise<{ version: string } | null>;
};
export type WorkspaceHandlers = {
list: () => Promise<[workspaceId: string, meta: WorkspaceMeta][]>;
delete: (id: string) => Promise<void>;
getMeta: (id: string) => Promise<WorkspaceMeta>;
};
export type UnwrapManagerHandlerToServerSide<
ElectronEvent extends {
frameId: number;
processId: number;
},
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>,
> = Manager extends HandlerManager<infer _, infer Handlers>
? {
[K in keyof Handlers]: Handlers[K] extends (
...args: infer Args
) => Promise<infer R>
? (event: ElectronEvent, ...args: Args) => Promise<R>
: never;
}
: never;
export type UnwrapManagerHandlerToClientSide<
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>,
> = Manager extends HandlerManager<infer _, infer Handlers>
? {
[K in keyof Handlers]: Handlers[K] extends (
...args: infer Args
) => Promise<infer R>
? (...args: Args) => Promise<R>
: never;
}
: never;
/**
* @internal
*/
export type App<
Namespace extends string,
Handlers extends Record<string, PrimitiveHandlers>,
> = TypedEventEmitter<{
[K in keyof Handlers as `${Namespace}:${K & string}`]: Handlers[K];
}>;
export interface UpdaterEvents {
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => () => void;
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => () => void;
onDownloadProgress: (fn: (progress: number) => void) => () => void;
}
export interface ApplicationMenuEvents {
onNewPageAction: (fn: () => void) => () => void;
}
export interface DBEvents {
onExternalUpdate: (
fn: (update: {
workspaceId: string;
update: Uint8Array;
docId?: string;
}) => void
) => () => void;
}
export interface WorkspaceEvents {
onMetaChange: (
fn: (workspaceId: string, meta: WorkspaceMeta) => void
) => () => void;
}
export interface UIEvents {
onMaximized: (fn: (maximized: boolean) => void) => () => void;
}
export interface EventMap {
updater: UpdaterEvents;
applicationMenu: ApplicationMenuEvents;
db: DBEvents;
ui: UIEvents;
workspace: WorkspaceEvents;
}