Compare commits

..

7 Commits

Author SHA1 Message Date
Alex Yang
a4f60f22cf v0.7.0-canary.51 2023-07-21 18:46:08 +08:00
Alex Yang
f05cd66368 fix(core): use Link from react-router-dom (#3342) 2023-07-21 10:29:36 +00:00
Peng Xiao
869d98d019 perf: lazy doc provider factory (#3330)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-07-21 05:23:18 +00:00
JimmFly
cff741e9ba style: add text overflow style for collections (#3292)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-07-21 03:48:22 +00:00
Alex Yang
9f105b5806 v0.7.0-canary.50 2023-07-21 11:52:50 +08:00
Alex Yang
cac609d36f fix(core): migration (#3322) 2023-07-20 20:16:15 +00:00
Alex Yang
c319e7e707 fix: type check in plugins (#3337) 2023-07-20 19:28:55 +00:00
56 changed files with 796 additions and 114 deletions

View File

@@ -2,7 +2,7 @@
"name": "@affine/core",
"type": "module",
"private": true,
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"scripts": {
"build": "yarn -T run build-core",
"dev": "yarn -T run dev-core",

View File

@@ -200,7 +200,7 @@ export const Header = forwardRef<
data-testid="editor-header-items"
data-is-edgeless={mode === 'edgeless'}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div className={styles.headerLeftSide}>
{!open && <SidebarSwitch />}
{props.leftSlot}
</div>

View File

@@ -83,13 +83,23 @@ export const titleWrapper = style({
justifyContent: 'center',
alignItems: 'center',
});
export const headerLeftSide = style({
display: 'flex',
alignItems: 'center',
width: '150px',
'@media': {
'(max-width: 900px)': {
width: 'auto',
},
},
});
export const headerRightSide = style({
height: '100%',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 1,
justifyContent: 'flex-end',
});
export const browserWarning = style({
@@ -153,7 +163,6 @@ export const allPageListTitleWrapper = style({
fontSize: 'var(--affine-font-base)',
color: 'var(--affine-text-primary-color)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
'::after': {
content: '""',

View File

@@ -52,7 +52,7 @@ export const ReferencePage = ({
data-type="favorite-list-item"
data-testid={`favorite-list-item-${pageId}`}
active={active}
href={`/workspace/${workspace.id}/${pageId}`}
to={`/workspace/${workspace.id}/${pageId}`}
icon={icon}
collapsed={collapsible ? collapsed : undefined}
onCollapsedChange={setCollapsed}

View File

@@ -53,7 +53,7 @@ const RouteMenuLinkItem = React.forwardRef<
HTMLDivElement,
{
currentPath: string; // todo: pass through useRouter?
path?: string | null;
path: string;
icon: ReactElement;
children?: ReactElement;
isDraggedOver?: boolean;
@@ -66,7 +66,7 @@ const RouteMenuLinkItem = React.forwardRef<
ref={ref}
{...props}
active={active}
href={path ?? ''}
to={path ?? ''}
icon={icon}
>
{children}
@@ -169,7 +169,7 @@ export const RootAppSidebar = ({
<RouteMenuLinkItem
icon={<FolderIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.all(currentWorkspaceId)}
path={paths.all(currentWorkspaceId)}
onClick={backToAll}
>
<span data-testid="all-pages">{t['All pages']()}</span>
@@ -198,7 +198,7 @@ export const RootAppSidebar = ({
isDraggedOver={trashDroppable.isOver}
icon={<DeleteTemporarilyIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.trash(currentWorkspaceId)}
path={paths.trash(currentWorkspaceId)}
>
<span data-testid="trash-page">{t['Trash']()}</span>
</RouteMenuLinkItem>

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/docs",
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/electron",
"private": true,
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"author": "affine",
"repository": {
"url": "https://github.com/toeverything/AFFiNE",

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"description": "Affine Node.js server",
"type": "module",
"bin": {

View File

@@ -48,5 +48,5 @@
"@blocksuite/lit": "*",
"@blocksuite/store": "*"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -24,6 +24,7 @@ import {
import type { Meta, StoryFn } from '@storybook/react';
import { useAtom } from 'jotai';
import { type PropsWithChildren, useState } from 'react';
import { MemoryRouter } from 'react-router-dom';
export default {
title: 'Components/AppSidebar',
@@ -31,18 +32,20 @@ export default {
} satisfies Meta;
const Container = ({ children }: PropsWithChildren) => (
<main
style={{
position: 'relative',
width: '100vw',
height: 'calc(100vh - 40px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'row',
}}
>
{children}
</main>
<MemoryRouter>
<main
style={{
position: 'relative',
width: '100vw',
height: 'calc(100vh - 40px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'row',
}}
>
{children}
</main>
</MemoryRouter>
);
const Main = () => {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
@@ -94,21 +97,21 @@ export const WithItems: StoryFn = () => {
<div style={{ height: '20px' }} />
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Settings
@@ -121,7 +124,7 @@ export const WithItems: StoryFn = () => {
collapsed={collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Collapsible Item
@@ -130,14 +133,14 @@ export const WithItems: StoryFn = () => {
collapsed={!collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Collapsible Item
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Settings
@@ -146,7 +149,7 @@ export const WithItems: StoryFn = () => {
<CategoryDivider label="Others" />
<MenuLinkItem
icon={<DeleteTemporarilyIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Trash

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/monorepo",
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"private": true,
"author": "toeverything",
"license": "MPL-2.0",

View File

@@ -20,5 +20,5 @@
"peerDependencies": {
"ts-node": "*"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -67,5 +67,5 @@
"vite": "^4.4.4",
"yjs": "^13.6.7"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -18,7 +18,7 @@ export const Default: StoryFn = () => {
</MenuItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Normal Link Item
@@ -26,7 +26,7 @@ export const Default: StoryFn = () => {
<MenuLinkItem
active
icon={<SettingsIcon />}
href="/test"
to="/test"
onClick={() => alert('opened')}
>
Primary Item

View File

@@ -1,7 +1,8 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import { Link, type LinkProps } from '@mui/material';
import clsx from 'clsx';
import React from 'react';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import * as styles from './index.css';
@@ -16,7 +17,7 @@ export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
export interface MenuLinkItemProps
extends MenuItemProps,
Pick<LinkProps, 'href'> {}
Pick<LinkProps, 'to'> {}
const stopPropagation: React.MouseEventHandler = e => {
e.stopPropagation();
@@ -90,9 +91,9 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
MenuItem.displayName = 'MenuItem';
export const MenuLinkItem = React.forwardRef<HTMLDivElement, MenuLinkItemProps>(
({ href, ...props }, ref) => {
({ to, ...props }, ref) => {
return (
<Link href={href} className={styles.linkItemRoot}>
<Link to={to} className={styles.linkItemRoot}>
{/* The <a> element rendered by Link does not generate display box due to `display: contents` style */}
{/* Thus ref is passed to MenuItem instead of Link */}
<MenuItem ref={ref} {...props}></MenuItem>

View File

@@ -1,3 +1,4 @@
import { Tooltip } from '@affine/component';
import { EditCollectionModel } from '@affine/component/page-list';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
@@ -102,9 +103,21 @@ export const CollectionBar = ({
width: 20,
}}
/>
<div style={{ marginRight: 10 }}>
{setting.currentCollection.name}
</div>
<Tooltip
content={setting.currentCollection.name}
pointerEnterDelay={1500}
>
<div
style={{
marginRight: 10,
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{setting.currentCollection.name}
</div>
</Tooltip>
{actions.map(action => {
return (
<div

View File

@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
export const menuTitleStyle = style({
marginLeft: '12px',
@@ -20,6 +20,8 @@ export const viewButton = style({
padding: '4px 8px',
fontSize: 'var(--affine-font-xs)',
background: 'var(--affine-white)',
maxWidth: '200px',
overflow: 'hidden',
color: 'var(--affine-text-secondary-color)',
border: '1px solid var(--affine-border-color)',
transition: 'margin-left 0.2s ease-in-out',
@@ -28,6 +30,20 @@ export const viewButton = style({
background: 'var(--affine-hover-color)',
},
marginRight: '20px',
'@media': {
'(max-width: 1200px)': {
maxWidth: '100px',
},
'(max-width: 900px)': {
maxWidth: '150px',
marginRight: '10px',
},
},
});
globalStyle(`${viewButton} > span`, {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const viewMenu = style({});
export const viewOption = style({

View File

@@ -16,7 +16,7 @@ import { useAtom } from 'jotai';
import type { MouseEvent, ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Button, MenuItem } from '../../..';
import { Button, MenuItem, Tooltip } from '../../..';
import Menu from '../../../ui/menu/menu';
import { appSidebarOpenAtom } from '../../app-sidebar';
import { CreateFilterMenu } from '../filter/vars';
@@ -80,39 +80,53 @@ const CollectionOption = ({
key={collection.id}
className={styles.viewMenu}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
<Tooltip
content={collection.name}
placement="right"
pointerEnterDelay={1500}
>
<div>{collection.name}</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{actions.map((v, i) => {
const onClick = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
v.click();
};
return (
<div
data-testid={`collection-select-option-${v.name}`}
key={i}
onClick={onClick}
style={{ marginLeft: i === 0 ? 28 : undefined }}
className={clsx(styles.viewOption, v.className)}
>
{v.icon}
</div>
);
})}
<div
style={{
maxWidth: '150px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{collection.name}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
}}
>
{actions.map((v, i) => {
const onClick = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
v.click();
};
return (
<div
data-testid={`collection-select-option-${v.name}`}
key={i}
onClick={onClick}
style={{ marginLeft: i === 0 ? 28 : undefined }}
className={clsx(styles.viewOption, v.className)}
>
{v.icon}
</div>
);
})}
</div>
</div>
</div>
</Tooltip>
</MenuItem>
);
};
@@ -155,7 +169,6 @@ export const CollectionList = ({
[styles.filterButtonCollapse]: !open,
})}
style={{
marginLeft: 4,
display: 'flex',
alignItems: 'center',
}}
@@ -199,7 +212,12 @@ export const CollectionList = ({
hoverColor="var(--affine-icon-color)"
data-testid="collection-select"
>
{setting.currentCollection.name}
<Tooltip
content={setting.currentCollection.name}
pointerEnterDelay={1500}
>
<>{setting.currentCollection.name}</>
</Tooltip>
</Button>
</Menu>
)}

View File

@@ -22,7 +22,7 @@ export const Popper = ({
defaultVisible = false,
visible: propsVisible,
trigger = 'hover',
pointerEnterDelay = 100,
pointerEnterDelay = 500,
pointerLeaveDelay = 100,
onVisibleChange,
popoverStyle,

View File

@@ -15,6 +15,7 @@ const StyledTooltip = styled(StyledPopperContainer)(() => {
fontSize: 'var(--affine-font-sm)',
borderRadius: '8px',
marginBottom: '12px',
overflowWrap: 'break-word',
};
});

View File

@@ -8,5 +8,5 @@
"devDependencies": {
"@types/debug": "^4.1.8"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -27,5 +27,5 @@
"dependencies": {
"lit": "^2.7.6"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/graphql",
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"description": "Autogenerated GraphQL client for affine.pro",
"license": "MPL-2.0",
"type": "module",

View File

@@ -25,5 +25,5 @@
"@blocksuite/lit": "*",
"@blocksuite/store": "*"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -37,5 +37,5 @@
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -46,5 +46,5 @@
"optional": true
}
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -23,5 +23,5 @@
"@blocksuite/store": "*",
"lottie-web": "*"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -38,5 +38,5 @@
"test": "cross-env TS_NODE_TRANSPILE_ONLY=1 TS_NODE_PROJECT=./tsconfig.json node --test --loader ts-node/esm --experimental-specifier-resolution=node ./__tests__/**/*.mts",
"version": "napi version"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -54,5 +54,5 @@
"react": "*",
"react-dom": "*"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/storage",
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
},

View File

@@ -6,5 +6,5 @@
"./*.md": "./*.md",
"./preloading.json": "./preloading.json"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -35,5 +35,5 @@
"@types/ws": "^8.5.5",
"ws": "^8.13.0"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -81,9 +81,30 @@ const rootWorkspacesMetadataPromiseAtom = atom<
// fixme(himself65): we might not need step 1
// step 1: try load metadata from localStorage
{
const loadFromLocalStorage = (): RootWorkspaceMetadata[] => {
// don't change this key,
// otherwise it will cause the data loss in the production
const primitiveMetadata = localStorage.getItem(METADATA_STORAGE_KEY);
if (primitiveMetadata) {
try {
const items = JSON.parse(primitiveMetadata) as z.infer<
typeof rootWorkspaceMetadataArraySchema
>;
rootWorkspaceMetadataArraySchema.parse(items);
return [...items];
} catch (e) {
console.error('cannot parse worksapce', e);
}
return [];
}
return [];
};
const maybeMetadata = loadFromLocalStorage();
// migration step, only data in `METADATA_STORAGE_KEY` will be migrated
if (
metadata.some(meta => !('version' in meta)) &&
maybeMetadata.some(meta => !('version' in meta)) &&
!globalThis.$migrationDone
) {
await new Promise<void>((resolve, reject) => {
@@ -94,20 +115,7 @@ const rootWorkspacesMetadataPromiseAtom = atom<
});
}
// don't change this key,
// otherwise it will cause the data loss in the production
const primitiveMetadata = localStorage.getItem(METADATA_STORAGE_KEY);
if (primitiveMetadata) {
try {
const items = JSON.parse(primitiveMetadata) as z.infer<
typeof rootWorkspaceMetadataArraySchema
>;
rootWorkspaceMetadataArraySchema.parse(items);
metadata.push(...items);
} catch (e) {
console.error('cannot parse worksapce', e);
}
}
metadata.push(...loadFromLocalStorage());
}
// step 2: fetch from adapters
{

View File

@@ -0,0 +1,19 @@
export interface DatasourceDocAdapter {
// request diff update from other clients
queryDocState: (
guid: string,
options?: {
stateVector?: Uint8Array;
targetClientId?: number;
}
) => Promise<Uint8Array | false>;
// send update to the datasource
sendDocUpdate: (guid: string, update: Uint8Array) => Promise<void>;
// listen to update from the datasource. Returns a function to unsubscribe.
// this is optional because some datasource might not support it
onDocUpdate?(
callback: (guid: string, update: Uint8Array) => void
): () => void;
}

View File

@@ -0,0 +1,148 @@
import type { PassiveDocProvider } from '@blocksuite/store';
import {
applyUpdate,
type Doc,
encodeStateAsUpdate,
encodeStateVectorFromUpdate,
} from 'yjs';
import type { DatasourceDocAdapter } from './datasource-doc-adapter';
const selfUpdateOrigin = 'lazy-provider-self-origin';
function getDoc(doc: Doc, guid: string): Doc | undefined {
if (doc.guid === guid) {
return doc;
}
for (const subdoc of doc.subdocs) {
const found = getDoc(subdoc, guid);
if (found) {
return found;
}
}
return undefined;
}
/**
* Creates a lazy provider that connects to a datasource and synchronizes a root document.
*/
export const createLazyProvider = (
rootDoc: Doc,
datasource: DatasourceDocAdapter
): Omit<PassiveDocProvider, 'flavour'> => {
let connected = false;
const pendingMap = new Map<string, Uint8Array[]>(); // guid -> pending-updates
const disposableMap = new Map<string, Set<() => void>>();
let datasourceUnsub: (() => void) | undefined;
async function syncDoc(doc: Doc) {
const guid = doc.guid;
// perf: optimize me
const currentUpdate = encodeStateAsUpdate(doc);
const remoteUpdate = await datasource.queryDocState(guid, {
stateVector: encodeStateVectorFromUpdate(currentUpdate),
});
const updates = [currentUpdate];
pendingMap.set(guid, []);
if (remoteUpdate) {
applyUpdate(doc, remoteUpdate, selfUpdateOrigin);
const newUpdate = encodeStateAsUpdate(
doc,
encodeStateVectorFromUpdate(remoteUpdate)
);
updates.push(newUpdate);
await datasource.sendDocUpdate(guid, newUpdate);
}
}
function setupDocListener(doc: Doc) {
const disposables = new Set<() => void>();
disposableMap.set(doc.guid, disposables);
const updateHandler = async (update: Uint8Array, origin: unknown) => {
if (origin === selfUpdateOrigin) {
return;
}
datasource.sendDocUpdate(doc.guid, update).catch(console.error);
};
const subdocLoadHandler = (event: { loaded: Set<Doc> }) => {
event.loaded.forEach(subdoc => {
connectDoc(subdoc).catch(console.error);
});
};
doc.on('update', updateHandler);
doc.on('subdocs', subdocLoadHandler);
// todo: handle destroy?
disposables.add(() => {
doc.off('update', updateHandler);
doc.off('subdocs', subdocLoadHandler);
});
}
function setupDatasourceListeners() {
datasourceUnsub = datasource.onDocUpdate?.((guid, update) => {
const doc = getDoc(rootDoc, guid);
if (doc) {
applyUpdate(doc, update);
//
if (pendingMap.has(guid)) {
pendingMap.get(guid)?.forEach(update => applyUpdate(doc, update));
pendingMap.delete(guid);
}
} else {
// This case happens when the father doc is not yet updated,
// so that the child doc is not yet created.
// We need to put it into cache so that it can be applied later.
console.warn('idb: doc not found', guid);
pendingMap.set(guid, (pendingMap.get(guid) ?? []).concat(update));
}
});
}
// when a subdoc is loaded, we need to sync it with the datasource and setup listeners
async function connectDoc(doc: Doc) {
setupDocListener(doc);
await syncDoc(doc);
await Promise.all(
[...doc.subdocs]
.filter(subdoc => subdoc.shouldLoad)
.map(subdoc => connectDoc(subdoc))
);
}
function disposeAll() {
disposableMap.forEach(disposables => {
disposables.forEach(dispose => dispose());
});
disposableMap.clear();
}
function connect() {
connected = true;
// root doc should be already loaded,
// but we want to populate the cache for later update events
connectDoc(rootDoc).catch(console.error);
setupDatasourceListeners();
}
async function disconnect() {
connected = false;
disposeAll();
datasourceUnsub?.();
datasourceUnsub = undefined;
}
return {
get connected() {
return connected;
},
passive: true,
connect,
disconnect,
};
};

View File

@@ -1,7 +1,7 @@
{
"name": "@toeverything/y-indexeddb",
"type": "module",
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"description": "IndexedDB database adapter for Yjs",
"repository": "toeverything/AFFiNE",
"author": "toeverything",

View File

@@ -0,0 +1,8 @@
# A set of provider utilities for Yjs
## createLazyProvider
A factory function to create a lazy provider. It will not download the document from the provider until the first time a document is loaded at the parent doc.
To use it, first define a `DatasourceDocAdapter`.
Then, create a `LazyProvider` with `createLazyProvider(rootDoc, datasource)`.

View File

@@ -0,0 +1,17 @@
{
"name": "@affine/y-provider",
"type": "module",
"version": "0.7.0-canary.51",
"description": "Yjs provider utilities for AFFiNE",
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"devDependencies": {
"@blocksuite/store": "0.0.0-20230719163314-76d863fc-nightly"
},
"peerDependencies": {
"yjs": "^13.5.51"
}
}

View File

@@ -0,0 +1,181 @@
import { setTimeout } from 'node:timers/promises';
import { describe, expect, test } from 'vitest';
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
import { createLazyProvider } from '../lazy-provider';
import type { DatasourceDocAdapter } from '../types';
import { getDoc } from '../utils';
const createMemoryDatasource = (rootDoc: Doc) => {
const selfUpdateOrigin = Symbol('self-origin');
const listeners = new Set<(guid: string, update: Uint8Array) => void>();
function trackDoc(doc: Doc) {
doc.on('update', (update, origin) => {
if (origin === selfUpdateOrigin) {
return;
}
for (const listener of listeners) {
listener(doc.guid, update);
}
});
doc.on('subdocs', () => {
for (const subdoc of rootDoc.subdocs) {
trackDoc(subdoc);
}
});
}
trackDoc(rootDoc);
const adapter = {
queryDocState: async (guid, options) => {
const subdoc = getDoc(rootDoc, guid);
if (!subdoc) {
return false;
}
return encodeStateAsUpdate(subdoc, options?.stateVector);
},
sendDocUpdate: async (guid, update) => {
const subdoc = getDoc(rootDoc, guid);
if (!subdoc) {
return;
}
applyUpdate(subdoc, update, selfUpdateOrigin);
},
onDocUpdate: callback => {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
},
} satisfies DatasourceDocAdapter;
return {
rootDoc, // expose rootDoc for testing
...adapter,
};
};
describe('y-provider', () => {
test('should sync a subdoc if it is loaded after connect', async () => {
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
const datasource = createMemoryDatasource(remoteRootDoc);
const remotesubdoc = new Doc();
remotesubdoc.getText('text').insert(0, 'test-subdoc-value');
// populate remote doc with simple data
remoteRootDoc.getMap('map').set('test-0', 'test-0-value');
remoteRootDoc.getMap('map').set('subdoc', remotesubdoc);
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
const provider = createLazyProvider(rootDoc, datasource);
provider.connect();
await setTimeout(); // wait for the provider to sync
const subdoc = rootDoc.getMap('map').get('subdoc') as Doc;
expect(rootDoc.getMap('map').get('test-0')).toBe('test-0-value');
expect(subdoc.getText('text').toJSON()).toBe('');
// onload, the provider should sync the subdoc
subdoc.load();
await setTimeout();
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
remotesubdoc.getText('text').insert(0, 'prefix-');
await setTimeout();
expect(subdoc.getText('text').toJSON()).toBe('prefix-test-subdoc-value');
});
test('should sync a shouldLoad=true subdoc on connect', async () => {
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
const datasource = createMemoryDatasource(remoteRootDoc);
const remotesubdoc = new Doc();
remotesubdoc.getText('text').insert(0, 'test-subdoc-value');
// populate remote doc with simple data
remoteRootDoc.getMap('map').set('test-0', 'test-0-value');
remoteRootDoc.getMap('map').set('subdoc', remotesubdoc);
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
applyUpdate(rootDoc, encodeStateAsUpdate(remoteRootDoc)); // sync rootDoc with remoteRootDoc
const subdoc = rootDoc.getMap('map').get('subdoc') as Doc;
expect(subdoc.getText('text').toJSON()).toBe('');
subdoc.load();
const provider = createLazyProvider(rootDoc, datasource);
provider.connect();
await setTimeout(); // wait for the provider to sync
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
});
test('should send existing local update to remote on connect', async () => {
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
const datasource = createMemoryDatasource(remoteRootDoc);
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
applyUpdate(rootDoc, encodeStateAsUpdate(remoteRootDoc)); // sync rootDoc with remoteRootDoc
rootDoc.getText('text').insert(0, 'test-value');
const provider = createLazyProvider(rootDoc, datasource);
provider.connect();
await setTimeout(); // wait for the provider to sync
expect(remoteRootDoc.getText('text').toJSON()).toBe('test-value');
});
test('should send local update to remote for subdoc after connect', async () => {
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
const datasource = createMemoryDatasource(remoteRootDoc);
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
const provider = createLazyProvider(rootDoc, datasource);
provider.connect();
await setTimeout(); // wait for the provider to sync
const subdoc = new Doc();
rootDoc.getMap('map').set('subdoc', subdoc);
subdoc.getText('text').insert(0, 'test-subdoc-value');
await setTimeout(); // wait for the provider to sync
const remoteSubdoc = remoteRootDoc.getMap('map').get('subdoc') as Doc;
expect(remoteSubdoc.getText('text').toJSON()).toBe('test-subdoc-value');
});
test('should not send local update to remote for subdoc after disconnect', async () => {
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
const datasource = createMemoryDatasource(remoteRootDoc);
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
const provider = createLazyProvider(rootDoc, datasource);
provider.connect();
await setTimeout(); // wait for the provider to sync
const subdoc = new Doc();
rootDoc.getMap('map').set('subdoc', subdoc);
await setTimeout(); // wait for the provider to sync
const remoteSubdoc = remoteRootDoc.getMap('map').get('subdoc') as Doc;
expect(remoteSubdoc.getText('text').toJSON()).toBe('');
provider.disconnect();
subdoc.getText('text').insert(0, 'test-subdoc-value');
setTimeout();
expect(remoteSubdoc.getText('text').toJSON()).toBe('');
expect(provider.connected).toBe(false);
});
});

View File

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

View File

@@ -0,0 +1,182 @@
import type { PassiveDocProvider } from '@blocksuite/store';
import {
applyUpdate,
type Doc,
encodeStateAsUpdate,
encodeStateVectorFromUpdate,
} from 'yjs';
import type { DatasourceDocAdapter } from './types';
const selfUpdateOrigin = 'lazy-provider-self-origin';
function getDoc(doc: Doc, guid: string): Doc | undefined {
if (doc.guid === guid) {
return doc;
}
for (const subdoc of doc.subdocs) {
const found = getDoc(subdoc, guid);
if (found) {
return found;
}
}
return undefined;
}
/**
* Creates a lazy provider that connects to a datasource and synchronizes a root document.
*/
export const createLazyProvider = (
rootDoc: Doc,
datasource: DatasourceDocAdapter
): Omit<PassiveDocProvider, 'flavour'> => {
let connected = false;
const pendingMap = new Map<string, Uint8Array[]>(); // guid -> pending-updates
const disposableMap = new Map<string, Set<() => void>>();
const connectedDocs = new Set();
let datasourceUnsub: (() => void) | undefined;
async function syncDoc(doc: Doc) {
const guid = doc.guid;
// perf: optimize me
const currentUpdate = encodeStateAsUpdate(doc);
const remoteUpdate = await datasource.queryDocState(guid, {
stateVector: encodeStateVectorFromUpdate(currentUpdate),
});
const updates = [currentUpdate];
pendingMap.set(guid, []);
if (remoteUpdate) {
applyUpdate(doc, remoteUpdate, selfUpdateOrigin);
const newUpdate = encodeStateAsUpdate(
doc,
encodeStateVectorFromUpdate(remoteUpdate)
);
updates.push(newUpdate);
await datasource.sendDocUpdate(guid, newUpdate);
}
}
/**
* Sets up event listeners for a Yjs document.
* @param doc - The Yjs document to set up listeners for.
*/
function setupDocListener(doc: Doc) {
const disposables = new Set<() => void>();
disposableMap.set(doc.guid, disposables);
const updateHandler = async (update: Uint8Array, origin: unknown) => {
if (origin === selfUpdateOrigin) {
return;
}
datasource.sendDocUpdate(doc.guid, update).catch(console.error);
};
const subdocLoadHandler = (event: {
loaded: Set<Doc>;
removed: Set<Doc>;
}) => {
event.loaded.forEach(subdoc => {
connectDoc(subdoc).catch(console.error);
});
event.removed.forEach(subdoc => {
disposeDoc(subdoc);
});
};
doc.on('update', updateHandler);
doc.on('subdocs', subdocLoadHandler);
// todo: handle destroy?
disposables.add(() => {
doc.off('update', updateHandler);
doc.off('subdocs', subdocLoadHandler);
});
}
/**
* Sets up event listeners for the datasource.
* Specifically, listens for updates to documents and applies them to the corresponding Yjs document.
*/
function setupDatasourceListeners() {
datasourceUnsub = datasource.onDocUpdate?.((guid, update) => {
const doc = getDoc(rootDoc, guid);
if (doc) {
applyUpdate(doc, update);
//
if (pendingMap.has(guid)) {
pendingMap.get(guid)?.forEach(update => applyUpdate(doc, update));
pendingMap.delete(guid);
}
} else {
// This case happens when the father doc is not yet updated,
// so that the child doc is not yet created.
// We need to put it into cache so that it can be applied later.
console.warn('idb: doc not found', guid);
pendingMap.set(guid, (pendingMap.get(guid) ?? []).concat(update));
}
});
}
// when a subdoc is loaded, we need to sync it with the datasource and setup listeners
async function connectDoc(doc: Doc) {
// skip if already connected
if (connectedDocs.has(doc.guid)) {
return;
}
connectedDocs.add(doc.guid);
setupDocListener(doc);
await syncDoc(doc);
await Promise.all(
[...doc.subdocs]
.filter(subdoc => subdoc.shouldLoad)
.map(subdoc => connectDoc(subdoc))
);
}
function disposeDoc(doc: Doc) {
connectedDocs.delete(doc.guid);
const disposables = disposableMap.get(doc.guid);
if (disposables) {
disposables.forEach(dispose => dispose());
disposableMap.delete(doc.guid);
}
// also dispose all subdocs
doc.subdocs.forEach(disposeDoc);
}
function disposeAll() {
disposableMap.forEach(disposables => {
disposables.forEach(dispose => dispose());
});
disposableMap.clear();
}
/**
* Connects to the datasource and sets up event listeners for document updates.
*/
function connect() {
connected = true;
// root doc should be already loaded,
// but we want to populate the cache for later update events
connectDoc(rootDoc).catch(console.error);
setupDatasourceListeners();
}
async function disconnect() {
connected = false;
disposeAll();
datasourceUnsub?.();
datasourceUnsub = undefined;
}
return {
get connected() {
return connected;
},
passive: true,
connect,
disconnect,
};
};

View File

@@ -0,0 +1,19 @@
export interface DatasourceDocAdapter {
// request diff update from other clients
queryDocState: (
guid: string,
options?: {
stateVector?: Uint8Array;
targetClientId?: number;
}
) => Promise<Uint8Array | false>;
// send update to the datasource
sendDocUpdate: (guid: string, update: Uint8Array) => Promise<void>;
// listen to update from the datasource. Returns a function to unsubscribe.
// this is optional because some datasource might not support it
onDocUpdate?(
callback: (guid: string, update: Uint8Array) => void
): () => void;
}

View File

@@ -0,0 +1,14 @@
import type { Doc } from 'yjs';
export function getDoc(doc: Doc, guid: string): Doc | undefined {
if (doc.guid === guid) {
return doc;
}
for (const subdoc of doc.subdocs) {
const found = getDoc(subdoc, guid);
if (found) {
return found;
}
}
return undefined;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
}
}

View File

@@ -15,5 +15,5 @@
"@toeverything/plugin-infra": "workspace:*",
"link-preview-js": "^3.0.4"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/bookmark-plugin",
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"affinePlugin": {
"release": true,
"entry": {

View File

@@ -27,5 +27,5 @@
"react": "*",
"react-dom": "*"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/hello-world-plugin",
"private": true,
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"affinePlugin": {
"release": false,
"entry": {

View File

@@ -1,5 +1,5 @@
import { IconButton, Tooltip } from '@affine/component';
import { AffineLogoSBlue2_1Icon } from '@blocksuite/icons';
import { Logo1Icon } from '@blocksuite/icons';
import { useCallback } from 'react';
export const HeaderItem = () => {
@@ -10,7 +10,7 @@ export const HeaderItem = () => {
console.log('clicked hello world!');
}, [])}
>
<AffineLogoSBlue2_1Icon />
<Logo1Icon />
</IconButton>
</Tooltip>
);

View File

@@ -84,8 +84,6 @@ test('init page', async ({ page, context }) => {
await switchToNext();
await page.waitForTimeout(1000);
await page.goto('http://localhost:8081/');
await page.waitForTimeout(1000);
await page.goto('http://localhost:8081/');
await page.waitForSelector('v-line', {
timeout: 10000,
});

View File

@@ -21,5 +21,5 @@
"serve": "^14.2.0",
"vitest": "^0.33.0"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -9,5 +9,5 @@
"@affine-test/kit": "workspace:*",
"@playwright/test": "^1.35.1"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -3,5 +3,5 @@
"exports": {
"./*": "./*"
},
"version": "0.7.0-canary.49"
"version": "0.7.0-canary.51"
}

View File

@@ -1,7 +1,7 @@
{
"name": "@affine-test/kit",
"private": true,
"version": "0.7.0-canary.49",
"version": "0.7.0-canary.51",
"exports": {
"./playwright": "./playwright.ts",
"./utils/*": "./utils/*.ts"

View File

@@ -117,9 +117,15 @@
{
"path": "./plugins/bookmark-block"
},
{
"path": "./plugins/bookmark"
},
{
"path": "./plugins/copilot"
},
{
"path": "./plugins/hello-world"
},
// Packages
{
"path": "./packages/cli"

View File

@@ -636,6 +636,16 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/y-provider@workspace:packages/y-provider":
version: 0.0.0-use.local
resolution: "@affine/y-provider@workspace:packages/y-provider"
dependencies:
"@blocksuite/store": 0.0.0-20230719163314-76d863fc-nightly
peerDependencies:
yjs: ^13.5.51
languageName: unknown
linkType: soft
"@alloc/quick-lru@npm:^5.2.0":
version: 5.2.0
resolution: "@alloc/quick-lru@npm:5.2.0"