mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 21:25:45 +08:00
refactor(infra): directory structure (#4615)
This commit is contained in:
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
> {}
|
||||
@@ -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: {} };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user