refactor(core): desktop project struct (#8334)

This commit is contained in:
EYHN
2024-11-05 11:00:33 +08:00
committed by GitHub
parent 89d09fd5e9
commit 902635e60f
343 changed files with 3846 additions and 3508 deletions
@@ -1,33 +0,0 @@
import { Entity, LiveData } from '@toeverything/infra';
import type { CreateWorkspaceCallbackPayload } from '../types';
export class CreateWorkspaceDialog extends Entity {
readonly mode$ = new LiveData<'new' | 'add'>('new');
readonly isOpen$ = new LiveData(false);
readonly callback$ = new LiveData<
(data: CreateWorkspaceCallbackPayload | undefined) => void
>(() => {});
open(
mode: 'new' | 'add',
callback?: (data: CreateWorkspaceCallbackPayload | undefined) => void
) {
this.callback(undefined);
this.mode$.next(mode);
this.isOpen$.next(true);
if (callback) {
this.callback$.next(callback);
}
}
callback(payload: CreateWorkspaceCallbackPayload | undefined) {
this.callback$.value(payload);
this.callback$.next(() => {});
}
close() {
this.isOpen$.next(false);
this.callback(undefined);
}
}
@@ -1,12 +0,0 @@
import type { Framework } from '@toeverything/infra';
import { CreateWorkspaceDialog } from './entities/dialog';
import { CreateWorkspaceDialogService } from './services/dialog';
export { CreateWorkspaceDialogService } from './services/dialog';
export type { CreateWorkspaceCallbackPayload } from './types';
export { CreateWorkspaceDialogProvider } from './views/dialog';
export function configureCreateWorkspaceModule(framework: Framework) {
framework.service(CreateWorkspaceDialogService).entity(CreateWorkspaceDialog);
}
@@ -1,7 +0,0 @@
import { Service } from '@toeverything/infra';
import { CreateWorkspaceDialog } from '../entities/dialog';
export class CreateWorkspaceDialogService extends Service {
dialog = this.framework.createEntity(CreateWorkspaceDialog);
}
@@ -1,7 +0,0 @@
import type { WorkspaceMetadata } from '@toeverything/infra';
export type CreateWorkspaceMode = 'add' | 'new';
export type CreateWorkspaceCallbackPayload = {
meta: WorkspaceMetadata;
defaultDocId?: string;
};
@@ -1,77 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const header = style({
position: 'relative',
marginTop: '44px',
});
export const subTitle = style({
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
fontWeight: 600,
});
export const avatarWrapper = style({
display: 'flex',
margin: '10px 0',
});
export const workspaceNameWrapper = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px 0',
});
export const affineCloudWrapper = style({
display: 'flex',
flexDirection: 'column',
gap: '6px',
paddingTop: '10px',
});
export const card = style({
padding: '12px',
display: 'flex',
alignItems: 'center',
borderRadius: '8px',
backgroundColor: cssVar('backgroundSecondaryColor'),
minHeight: '114px',
position: 'relative',
});
export const cardText = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
gap: '12px',
});
export const cardTitle = style({
fontSize: cssVar('fontBase'),
color: cssVar('textPrimaryColor'),
display: 'flex',
justifyContent: 'space-between',
});
export const cardDescription = style({
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
maxWidth: '288px',
});
export const cloudTips = style({
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
});
export const cloudSvgContainer = style({
width: '146px',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
position: 'absolute',
bottom: '0',
right: '0',
pointerEvents: 'none',
});
@@ -1,257 +0,0 @@
import { Avatar, ConfirmModal, Input, Switch, toast } from '@affine/component';
import type { ConfirmModalProps } from '@affine/component/ui/modal';
import { CloudSvg } from '@affine/core/components/affine/share-page-modal/cloud-svg';
import { authAtom } from '@affine/core/components/atoms';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DebugLogger } from '@affine/debug';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
FeatureFlagService,
useLiveData,
useService,
useServiceOptional,
WorkspacesService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useLayoutEffect, useState } from 'react';
import { AuthService } from '../../../modules/cloud';
import { _addLocalWorkspace } from '../../../modules/workspace-engine';
import { buildShowcaseWorkspace } from '../../../utils/first-app-data';
import { DesktopApiService } from '../../desktop-api';
import { CreateWorkspaceDialogService } from '../services/dialog';
import * as styles from './dialog.css';
const logger = new DebugLogger('CreateWorkspaceModal');
interface NameWorkspaceContentProps extends ConfirmModalProps {
loading: boolean;
onConfirmName: (
name: string,
workspaceFlavour: WorkspaceFlavour,
avatar?: File
) => void;
}
const NameWorkspaceContent = ({
loading,
onConfirmName,
...props
}: NameWorkspaceContentProps) => {
const t = useI18n();
const [workspaceName, setWorkspaceName] = useState('');
const featureFlagService = useService(FeatureFlagService);
const enableLocalWorkspace = useLiveData(
featureFlagService.flags.enable_local_workspace.$
);
const [enable, setEnable] = useState(!enableLocalWorkspace);
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
const setOpenSignIn = useSetAtom(authAtom);
const openSignInModal = useCallback(() => {
setOpenSignIn(state => ({
...state,
openModal: true,
}));
}, [setOpenSignIn]);
const onSwitchChange = useCallback(
(checked: boolean) => {
if (loginStatus !== 'authenticated') {
return openSignInModal();
}
return setEnable(checked);
},
[loginStatus, openSignInModal]
);
const handleCreateWorkspace = useCallback(() => {
onConfirmName(
workspaceName,
enable ? WorkspaceFlavour.AFFINE_CLOUD : WorkspaceFlavour.LOCAL
);
}, [enable, onConfirmName, workspaceName]);
const onEnter = useCallback(() => {
if (workspaceName) {
handleCreateWorkspace();
}
}, [handleCreateWorkspace, workspaceName]);
// Currently, when we create a new workspace and upload an avatar at the same time,
// an error occurs after the creation is successful: get blob 404 not found
return (
<ConfirmModal
defaultOpen={true}
title={t['com.affine.nameWorkspace.title']()}
description={t['com.affine.nameWorkspace.description']()}
cancelText={t['com.affine.nameWorkspace.button.cancel']()}
confirmText={t['com.affine.nameWorkspace.button.create']()}
confirmButtonOptions={{
variant: 'primary',
loading,
disabled: !workspaceName,
'data-testid': 'create-workspace-create-button',
}}
closeButtonOptions={{
['data-testid' as string]: 'create-workspace-close-button',
}}
onConfirm={handleCreateWorkspace}
{...props}
>
<div className={styles.avatarWrapper}>
<Avatar size={56} name={workspaceName} colorfulFallback />
</div>
<div className={styles.workspaceNameWrapper}>
<div className={styles.subTitle}>
{t['com.affine.nameWorkspace.subtitle.workspace-name']()}
</div>
<Input
autoFocus
data-testid="create-workspace-input"
onEnter={onEnter}
placeholder={t['com.affine.nameWorkspace.placeholder']()}
maxLength={64}
minLength={0}
onChange={setWorkspaceName}
size="large"
/>
</div>
<div className={styles.affineCloudWrapper}>
<div className={styles.subTitle}>{t['AFFiNE Cloud']()}</div>
<div className={styles.card}>
<div className={styles.cardText}>
<div className={styles.cardTitle}>
<span>{t['com.affine.nameWorkspace.affine-cloud.title']()}</span>
<Switch
checked={enable}
onChange={onSwitchChange}
disabled={!enableLocalWorkspace}
/>
</div>
<div className={styles.cardDescription}>
{t['com.affine.nameWorkspace.affine-cloud.description']()}
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
{!enableLocalWorkspace ? (
<a
className={styles.cloudTips}
href={BUILD_CONFIG.downloadUrl}
target="_blank"
rel="noreferrer"
>
{t['com.affine.nameWorkspace.affine-cloud.web-tips']()}
</a>
) : null}
</div>
</ConfirmModal>
);
};
const CreateWorkspaceDialog = () => {
const createWorkspaceDialogService = useService(CreateWorkspaceDialogService);
const mode = useLiveData(createWorkspaceDialogService.dialog.mode$);
const t = useI18n();
const workspacesService = useService(WorkspacesService);
const [loading, setLoading] = useState(false);
const electronApi = useServiceOptional(DesktopApiService);
// TODO(@Peng): maybe refactor using xstate?
useLayoutEffect(() => {
let canceled = false;
// if mode changed, reset step
if (mode === 'add') {
// a hack for now
// when adding a workspace, we will immediately let user select a db file
// after it is done, it will effectively add a new workspace to app-data folder
// so after that, we will be able to load it via importLocalWorkspace
(async () => {
if (!electronApi) {
return;
}
logger.info('load db file');
const result = await electronApi.handler.dialog.loadDBFile();
if (result.workspaceId && !canceled) {
_addLocalWorkspace(result.workspaceId);
workspacesService.list.revalidate();
createWorkspaceDialogService.dialog.callback({
meta: {
flavour: WorkspaceFlavour.LOCAL,
id: result.workspaceId,
},
});
} else if (result.error || result.canceled) {
if (result.error) {
toast(t[result.error]());
}
createWorkspaceDialogService.dialog.callback(undefined);
createWorkspaceDialogService.dialog.close();
}
})().catch(err => {
console.error(err);
});
}
return () => {
canceled = true;
};
}, [createWorkspaceDialogService, electronApi, mode, t, workspacesService]);
const onConfirmName = useAsyncCallback(
async (name: string, workspaceFlavour: WorkspaceFlavour) => {
track.$.$.$.createWorkspace({ flavour: workspaceFlavour });
if (loading) return;
setLoading(true);
// this will be the last step for web for now
// fix me later
const { meta, defaultDocId } = await buildShowcaseWorkspace(
workspacesService,
workspaceFlavour,
name
);
createWorkspaceDialogService.dialog.callback({ meta, defaultDocId });
createWorkspaceDialogService.dialog.close();
setLoading(false);
},
[createWorkspaceDialogService.dialog, loading, workspacesService]
);
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) {
createWorkspaceDialogService.dialog.close();
}
},
[createWorkspaceDialogService]
);
if (mode === 'new') {
return (
<NameWorkspaceContent
loading={loading}
open
onOpenChange={onOpenChange}
onConfirmName={onConfirmName}
/>
);
} else {
return null;
}
};
export const CreateWorkspaceDialogProvider = () => {
const createWorkspaceDialogService = useService(CreateWorkspaceDialogService);
const isOpen = useLiveData(createWorkspaceDialogService.dialog.isOpen$);
return isOpen ? <CreateWorkspaceDialog /> : null;
};
@@ -1,27 +1,16 @@
import {
DocsService,
type Framework,
WorkspaceScope,
} from '@toeverything/infra';
import { type Framework } from '@toeverything/infra';
import { WorkbenchService } from '../workbench/services/workbench';
import { DesktopApi } from './entities/electron-api';
import { ElectronApiImpl } from './impl';
import { DesktopApiProvider } from './provider';
import { DesktopApiService, WorkspaceDesktopApiService } from './service';
import { DesktopApiService } from './service/desktop-api';
export function configureDesktopApiModule(framework: Framework) {
framework
.impl(DesktopApiProvider, ElectronApiImpl)
.entity(DesktopApi, [DesktopApiProvider])
.service(DesktopApiService, [DesktopApi])
.scope(WorkspaceScope)
.service(WorkspaceDesktopApiService, [
DesktopApiService,
DocsService,
WorkbenchService,
]);
.service(DesktopApiService, [DesktopApi]);
}
export * from './service';
export { DesktopApiService } from './service/desktop-api';
export type { ClientEvents, TabViewsMetaSchema } from '@affine/electron-api';
@@ -1,2 +0,0 @@
export * from './desktop-api';
export * from './workspace-events';
@@ -1,41 +0,0 @@
import type { DocsService } from '@toeverything/infra';
import { OnEvent, Service, WorkspaceInitialized } from '@toeverything/infra';
import { EditorSettingService } from '../../editor-setting';
import type { WorkbenchService } from '../../workbench';
import type { DesktopApiService } from './desktop-api';
// setup desktop events for workspace scope
@OnEvent(WorkspaceInitialized, e => e.setupApplicationMenuEvents)
export class WorkspaceDesktopApiService extends Service {
constructor(
private readonly desktopApi: DesktopApiService,
private readonly docsService: DocsService,
private readonly workbenchService: WorkbenchService
) {
super();
}
async setupApplicationMenuEvents() {
this.desktopApi.events.applicationMenu.onNewPageAction(() => {
const editorSetting =
this.framework.get(EditorSettingService).editorSetting;
const docProps = {
note: editorSetting.get('affine:note'),
};
this.desktopApi.handler.ui
.isActiveTab()
.then(isActive => {
if (!isActive) {
return;
}
const page = this.docsService.createDoc({ docProps });
this.workbenchService.workbench.openDoc(page.id);
})
.catch(err => {
console.error(err);
});
});
}
}
@@ -0,0 +1,55 @@
/* eslint-disable @typescript-eslint/ban-types */
import type { DocMode } from '@blocksuite/affine/blocks';
import type { WorkspaceMetadata } from '@toeverything/infra';
export type SettingTab =
| 'shortcuts'
| 'appearance'
| 'about'
| 'plans'
| 'billing'
| 'experimental-features'
| 'editor'
| 'account'
| `workspace:${'preference' | 'properties'}`;
export type GLOBAL_DIALOG_SCHEMA = {
'create-workspace': () => {
metadata: WorkspaceMetadata;
defaultDocId?: string;
};
'import-workspace': () => {
workspace: WorkspaceMetadata;
};
'import-template': (props: {
templateName: string;
templateMode: DocMode;
snapshotUrl: string;
}) => void;
import: () => void;
setting: (props: {
activeTab?: SettingTab;
workspaceMetadata?: WorkspaceMetadata | null;
scrollAnchor?: string;
}) => void;
};
export type WORKSPACE_DIALOG_SCHEMA = {
'doc-info': (props: { docId: string }) => void;
'doc-selector': (props: {
init: string[];
onBeforeConfirm?: (ids: string[], cb: () => void) => void;
}) => string[];
'collection-selector': (props: {
init: string[];
onBeforeConfirm?: (ids: string[], cb: () => void) => void;
}) => string[];
'collection-editor': (props: {
collectionId: string;
mode?: 'page' | 'rule';
}) => void;
'tag-selector': (props: {
init: string[];
onBeforeConfirm?: (ids: string[], cb: () => void) => void;
}) => string[];
};
@@ -0,0 +1,17 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceScope } from '@toeverything/infra';
import { GlobalDialogService } from './services/dialog';
import { WorkspaceDialogService } from './services/workspace-dialog';
export type { GLOBAL_DIALOG_SCHEMA, WORKSPACE_DIALOG_SCHEMA } from './constant';
export { GlobalDialogService } from './services/dialog';
export { WorkspaceDialogService } from './services/workspace-dialog';
export type { DialogComponentProps } from './types';
export function configureDialogModule(framework: Framework) {
framework
.service(GlobalDialogService)
.scope(WorkspaceScope)
.service(WorkspaceDialogService);
}
@@ -0,0 +1,41 @@
import { LiveData, Service } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import type { GLOBAL_DIALOG_SCHEMA } from '../constant';
import type { DialogProps, DialogResult, OpenedDialog } from '../types';
export class GlobalDialogService extends Service {
readonly dialogs$ = new LiveData<OpenedDialog<GLOBAL_DIALOG_SCHEMA>[]>([]);
open<T extends keyof GLOBAL_DIALOG_SCHEMA>(
type: T,
props: DialogProps<GLOBAL_DIALOG_SCHEMA[T]>,
callback?: (result?: DialogResult<GLOBAL_DIALOG_SCHEMA[T]>) => void
): string {
const id = nanoid();
this.dialogs$.next([
...this.dialogs$.value,
{
type,
props,
callback,
id,
} as OpenedDialog<GLOBAL_DIALOG_SCHEMA>,
]);
return id;
}
close(id: string, result?: unknown) {
this.dialogs$.next(
this.dialogs$.value.filter(dialog => {
if (dialog.id === id) {
if (dialog.callback) {
dialog.callback(result as any);
}
return false;
}
return true;
})
);
}
}
@@ -0,0 +1,41 @@
import { LiveData, Service } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import type { WORKSPACE_DIALOG_SCHEMA } from '../constant';
import type { DialogProps, DialogResult, OpenedDialog } from '../types';
export class WorkspaceDialogService extends Service {
readonly dialogs$ = new LiveData<OpenedDialog<WORKSPACE_DIALOG_SCHEMA>[]>([]);
open<T extends keyof WORKSPACE_DIALOG_SCHEMA>(
type: T,
props: DialogProps<WORKSPACE_DIALOG_SCHEMA[T]>,
callback?: (result?: DialogResult<WORKSPACE_DIALOG_SCHEMA[T]>) => void
): string {
const id = nanoid();
this.dialogs$.next([
...this.dialogs$.value,
{
type,
props,
callback,
id,
} as OpenedDialog<WORKSPACE_DIALOG_SCHEMA>,
]);
return id;
}
close(id: string, result?: unknown) {
this.dialogs$.next(
this.dialogs$.value.filter(dialog => {
if (dialog.id === id) {
if (dialog.callback) {
dialog.callback(result as any);
}
return false;
}
return true;
})
);
}
}
@@ -0,0 +1,16 @@
export type OpenedDialog<T> = {
[key in keyof T]: {
type: key;
props: DialogProps<T[key]>;
callback: (result?: DialogResult<T[key]>) => void;
};
}[keyof T] & { id: string };
export type DialogProps<T> = T extends (props: infer P) => void ? P : undefined;
export type DialogResult<T> = T extends (...args: any[]) => infer R
? R
: undefined;
export type DialogComponentProps<T> = DialogProps<T> & {
close: (result?: DialogResult<T>) => void;
};
@@ -1,22 +0,0 @@
import { Entity, LiveData } from '@toeverything/infra';
export class DocInfoModal extends Entity {
public readonly docId$ = new LiveData<string | null>(null);
public readonly open$ = LiveData.computed(get => !!get(this.docId$));
public open(docId?: string) {
if (docId) {
this.docId$.next(docId);
} else {
this.docId$.next(null);
}
}
public close() {
this.docId$.next(null);
}
public onOpenChange(open: boolean) {
if (!open) this.docId$.next(null);
}
}
@@ -5,18 +5,12 @@ import {
} from '@toeverything/infra';
import { DocsSearchService } from '../docs-search';
import { DocInfoModal } from './entities/modal';
import { DocDatabaseBacklinksService } from './services/doc-database-backlinks';
import { DocInfoService } from './services/doc-info';
export { DocDatabaseBacklinkInfo } from './views/database-properties/doc-database-backlink-info';
export { DocInfoService };
export function configureDocInfoModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(DocInfoService)
.service(DocDatabaseBacklinksService, [DocsService, DocsSearchService])
.entity(DocInfoModal);
.service(DocDatabaseBacklinksService, [DocsService, DocsSearchService]);
}
@@ -1,7 +0,0 @@
import { Service } from '@toeverything/infra';
import { DocInfoModal } from '../entities/modal';
export class DocInfoService extends Service {
public readonly modal = this.framework.createEntity(DocInfoModal);
}
@@ -5,11 +5,9 @@ import {
MenuItem,
toast,
} from '@affine/component';
import {
filterPage,
useEditCollection,
} from '@affine/core/components/page-list';
import { filterPage } from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { AffineDNDData } from '@affine/core/types/dnd';
@@ -59,10 +57,10 @@ export const ExplorerCollectionNode = ({
collectionId: string;
} & GenericExplorerNode) => {
const t = useI18n();
const { globalContextService } = useServices({
const { globalContextService, workspaceDialogService } = useServices({
GlobalContextService,
WorkspaceDialogService,
});
const { open: openEditCollectionModal } = useEditCollection();
const active =
useLiveData(globalContextService.globalContext.collectionId.$) ===
collectionId;
@@ -170,17 +168,10 @@ export const ExplorerCollectionNode = ({
if (!collection) {
return;
}
openEditCollectionModal(collection)
.then(collection => {
return collectionService.updateCollection(
collection.id,
() => collection
);
})
.catch(err => {
console.error(err);
});
}, [collection, collectionService, openEditCollectionModal]);
workspaceDialogService.open('collection-editor', {
collectionId: collection.id,
});
}, [collection, workspaceDialogService]);
const collectionOperations = useExplorerCollectionNodeOperations(
collectionId,
@@ -6,8 +6,8 @@ import {
Tooltip,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocInfoService } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -187,15 +187,15 @@ export const ExplorerDocNode = ({
[canDrop]
);
const docInfoModal = useService(DocInfoService).modal;
const workspaceDialogService = useService(WorkspaceDialogService);
const operations = useExplorerDocNodeOperations(
docId,
useMemo(
() => ({
openInfoModal: () => docInfoModal.open(docId),
openInfoModal: () => workspaceDialogService.open('doc-info', { docId }),
openNodeCollapsed: () => setCollapsed(false),
}),
[docId, docInfoModal]
[docId, workspaceDialogService]
)
);
@@ -10,11 +10,7 @@ import {
notify,
} from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import {
useSelectCollection,
useSelectDoc,
useSelectTag,
} from '@affine/core/components/page-list/selector';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import {
type FolderNode,
@@ -186,14 +182,13 @@ const ExplorerFolderNodeFolder = ({
node: FolderNode;
} & GenericExplorerNode) => {
const t = useI18n();
const { workspaceService, featureFlagService } = useServices({
WorkspaceService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
});
const openDocsSelector = useSelectDoc();
const openTagsSelector = useSelectTag();
const openCollectionsSelector = useSelectCollection();
const { workspaceService, featureFlagService, workspaceDialogService } =
useServices({
WorkspaceService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
WorkspaceDialogService,
});
const name = useLiveData(node.name$);
const enableEmojiIcon = useLiveData(
featureFlagService.flags.enable_emoji_folder_icon.$
@@ -575,12 +570,19 @@ const ExplorerFolderNodeFolder = ({
.filter(Boolean) as string[];
const selector =
type === 'doc'
? openDocsSelector
? 'doc-selector'
: type === 'collection'
? openCollectionsSelector
: openTagsSelector;
selector(initialIds)
.then(selectedIds => {
? 'collection-selector'
: 'tag-selector';
workspaceDialogService.open(
selector,
{
init: initialIds,
},
selectedIds => {
if (selectedIds === undefined) {
return;
}
const newItemIds = difference(selectedIds, initialIds);
const removedItemIds = difference(initialIds, selectedIds);
const removedItems = children.filter(
@@ -594,22 +596,14 @@ const ExplorerFolderNodeFolder = ({
removedItems.forEach(node => node.delete());
const updated = newItemIds.length + removedItems.length;
updated && setCollapsed(false);
})
.catch(err => {
console.error(`Unexpected error while selecting ${type}`, err);
});
}
);
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: type,
});
},
[
children,
node,
openCollectionsSelector,
openDocsSelector,
openTagsSelector,
]
[children, node, workspaceDialogService]
);
const folderOperations = useMemo(() => {
@@ -0,0 +1,8 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const createTips = style({
color: cssVar('textSecondaryColor'),
fontSize: 12,
lineHeight: '20px',
});
@@ -1,5 +1,4 @@
import { IconButton } from '@affine/component';
import { useEditCollectionName } from '@affine/core/components/page-list';
import { IconButton, usePromptModal } from '@affine/component';
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
import { CollectionService } from '@affine/core/modules/collection';
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
@@ -15,6 +14,7 @@ import { ExplorerService } from '../../../services/explorer';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerCollectionNode } from '../../nodes/collection';
import { RootEmpty } from './empty';
import * as styles from './index.css';
export const ExplorerCollections = () => {
const t = useI18n();
@@ -25,14 +25,26 @@ export const ExplorerCollections = () => {
});
const explorerSection = explorerService.sections.collections;
const collections = useLiveData(collectionService.collections$);
const { open: openCreateCollectionModel } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const { openPromptModal } = usePromptModal();
const handleCreateCollection = useCallback(() => {
openCreateCollectionModel('')
.then(name => {
openPromptModal({
title: t['com.affine.editCollection.saveCollection'](),
label: t['com.affine.editCollectionName.name'](),
inputOptions: {
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
},
children: (
<div className={styles.createTips}>
{t['com.affine.editCollectionName.createTips']()}
</div>
),
confirmText: t['com.affine.editCollection.save'](),
cancelText: t['com.affine.editCollection.button.cancel'](),
confirmButtonOptions: {
variant: 'primary',
},
onConfirm(name) {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
track.$.navigationPanel.organize.createOrganizeItem({
@@ -40,14 +52,13 @@ export const ExplorerCollections = () => {
});
workbenchService.workbench.openCollection(id);
explorerSection.setCollapsed(false);
})
.catch(err => {
console.error(err);
});
},
});
}, [
collectionService,
explorerSection,
openCreateCollectionModel,
openPromptModal,
t,
workbenchService.workbench,
]);
@@ -4,6 +4,8 @@ import { DesktopApiService } from '../desktop-api';
import { FindInPage } from './entities/find-in-page';
import { FindInPageService } from './services/find-in-page';
export { FindInPageService } from './services/find-in-page';
export function configureFindInPageModule(framework: Framework) {
framework.service(FindInPageService).entity(FindInPage, [DesktopApiService]);
}
@@ -1,152 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, keyframes, style } from '@vanilla-extract/css';
export const animationTimeout = createVar();
const contentShow = keyframes({
from: {
opacity: 0,
transform: 'translateY(-2%) scale(0.96)',
},
to: {
opacity: 1,
transform: 'translateY(0) scale(1)',
},
});
const contentHide = keyframes({
to: {
opacity: 0,
transform: 'translateY(-2%) scale(0.96)',
},
from: {
opacity: 1,
transform: 'translateY(0) scale(1)',
},
});
export const modalOverlay = style({
position: 'fixed',
inset: 0,
backgroundColor: 'transparent',
zIndex: cssVar('zIndexModal'),
});
export const modalContentWrapper = style({
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
zIndex: cssVar('zIndexModal'),
right: '28px',
top: '80px',
});
export const modalContent = style({
width: 400,
height: 48,
backgroundColor: cssVar('backgroundOverlayPanelColor'),
borderRadius: '8px',
boxShadow: cssVar('shadow3'),
minHeight: 48,
// :focus-visible will set outline
outline: 'none',
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'space-between',
border: `0.5px solid ${cssVar('borderColor')}`,
padding: '8px 12px 8px 8px',
zIndex: cssVar('zIndexModal'),
willChange: 'transform, opacity',
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animation: `${contentShow} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
},
'&[data-state=exited], &[data-state=exiting]': {
animation: `${contentHide} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
},
},
});
export const leftContent = style({
display: 'flex',
alignItems: 'center',
flex: 1,
});
export const searchIcon = style({
fontSize: '20px',
color: cssVar('iconColor'),
verticalAlign: 'middle',
});
export const inputContainer = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
flex: 1,
height: '32px',
position: 'relative',
padding: '0 8px',
borderRadius: '4px',
background: cssVar('white10'),
border: `1px solid ${cssVar('borderColor')}`,
selectors: {
'&.active': {
borderColor: cssVar('primaryColor'),
},
},
});
export const inputMain = style({
display: 'flex',
alignItems: 'center',
flex: 1,
height: '32px',
position: 'relative',
});
export const input = style({
position: 'absolute',
padding: '0',
inset: 0,
height: '100%',
width: '100%',
color: 'transparent',
});
export const inputHack = style([
input,
{
'::placeholder': {
color: cssVar('textPrimaryColor'),
},
pointerEvents: 'none',
},
]);
export const count = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
userSelect: 'none',
});
export const arrowButton = style({
width: 32,
height: 32,
flexShrink: 0,
border: '1px solid',
borderColor: cssVarV2('layer/insideBorder/border'),
selectors: {
'&.backward': {
borderRadius: '4px 0 0 4px',
},
'&.forward': {
borderLeft: 'none',
borderRadius: '0 4px 4px 0',
},
},
});
@@ -1,285 +0,0 @@
import { IconButton, observeResize, RowInput } from '@affine/component';
import {
ArrowDownSmallIcon,
ArrowUpSmallIcon,
CloseIcon,
SearchIcon,
} from '@blocksuite/icons/rc';
import * as Dialog from '@radix-ui/react-dialog';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import {
type CompositionEventHandler,
type KeyboardEventHandler,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTransition } from 'react-transition-state';
import { FindInPageService } from '../services/find-in-page';
import * as styles from './find-in-page-modal.css';
const animationTimeout = 120;
const drawText = (
canvas: HTMLCanvasElement,
text: string,
scrollLeft: number
) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.getBoundingClientRect().width * dpr;
canvas.height = canvas.getBoundingClientRect().height * dpr;
const rootStyles = getComputedStyle(document.documentElement);
const textColor = rootStyles
.getPropertyValue('--affine-text-primary-color')
.trim();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = textColor;
ctx.font = '15px Inter';
const offsetX = -scrollLeft; // Offset based on scrollLeft
ctx.fillText(text, offsetX, 22);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
};
const CanvasText = ({
text,
scrollLeft,
className,
}: {
text: string;
scrollLeft: number;
className: string;
}) => {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) {
return;
}
drawText(canvas, text, scrollLeft);
return observeResize(canvas, () => drawText(canvas, text, scrollLeft));
}, [text, scrollLeft]);
return <canvas className={className} ref={ref} />;
};
export const FindInPageModal = () => {
const [value, setValue] = useState('');
const findInPage = useService(FindInPageService).findInPage;
const visible = useLiveData(findInPage.visible$);
const result = useLiveData(findInPage.result$);
const isSearching = useLiveData(findInPage.isSearching$);
const inputRef = useRef<HTMLInputElement>(null);
const [active, setActive] = useState(false);
const [scrollLeft, setScrollLeft] = useState(0);
const [composing, setComposing] = useState(false);
const [{ status }, toggle] = useTransition({
timeout: animationTimeout,
});
useEffect(() => {
toggle(visible);
}, [visible]);
const handleValueChange = useCallback(
(value: string) => {
setValue(value);
if (!composing) {
findInPage.findInPage(value);
}
if (value.length === 0) {
findInPage.clear();
}
inputRef.current?.focus();
},
[composing, findInPage]
);
const handleFocus = useCallback(() => {
setActive(true);
}, []);
const handleBlur = useCallback(() => {
setActive(false);
}, []);
useEffect(() => {
if (visible) {
setValue(findInPage.searchText$.value || '');
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
findInPage.onChangeVisible(false);
}
};
window.addEventListener('keydown', onEsc);
return () => {
window.removeEventListener('keydown', onEsc);
};
}
return () => {};
}, [findInPage, findInPage.searchText$.value, visible]);
useEffect(() => {
const unsub = findInPage.isSearching$.subscribe(() => {
inputRef.current?.focus();
setTimeout(() => {
inputRef.current?.focus();
});
});
return () => {
unsub.unsubscribe();
};
}, [findInPage.isSearching$]);
const handleBackWard = useCallback(() => {
findInPage.backward();
}, [findInPage]);
const handleForward = useCallback(() => {
findInPage.forward();
}, [findInPage]);
const onChangeVisible = useCallback(
(visible: boolean) => {
if (!visible) {
findInPage.clear();
}
findInPage.onChangeVisible(visible);
},
[findInPage]
);
const handleDone = useCallback(() => {
onChangeVisible(false);
}, [onChangeVisible]);
const handleKeydown: KeyboardEventHandler = useCallback(
e => {
if (e.key === 'Enter' || e.key === 'ArrowDown') {
handleForward();
}
if (e.key === 'ArrowUp') {
handleBackWard();
}
},
[handleBackWard, handleForward]
);
const handleScroll = useCallback(
(e: { currentTarget: { scrollLeft: SetStateAction<number> } }) => {
setScrollLeft(e.currentTarget.scrollLeft);
},
[]
);
const handleCompositionStart: CompositionEventHandler<HTMLInputElement> =
useCallback(() => {
setComposing(true);
}, []);
const handleCompositionEnd: CompositionEventHandler<HTMLInputElement> =
useCallback(
e => {
setComposing(false);
findInPage.findInPage(e.currentTarget.value);
},
[findInPage]
);
return (
<Dialog.Root open={status !== 'exited'}>
<Dialog.Portal>
<Dialog.Overlay className={styles.modalOverlay} />
<div className={styles.modalContentWrapper}>
<Dialog.Content
style={assignInlineVars({
[styles.animationTimeout]: `${animationTimeout}ms`,
})}
className={styles.modalContent}
data-state={status}
>
<div
className={clsx(styles.inputContainer, {
active: active || isSearching,
})}
>
<SearchIcon className={styles.searchIcon} />
<div className={styles.inputMain}>
<RowInput
type="text"
autoFocus
value={value}
ref={inputRef}
style={{
visibility: isSearching ? 'hidden' : 'visible',
}}
onBlur={handleBlur}
onFocus={handleFocus}
className={styles.input}
onKeyDown={handleKeydown}
onChange={handleValueChange}
onScroll={handleScroll}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<CanvasText
className={styles.inputHack}
text={value}
scrollLeft={scrollLeft}
/>
</div>
<div className={styles.count}>
{value.length > 0 && result && result.matches !== 0 ? (
<>
<span>{result?.activeMatchOrdinal || 0}</span>
<span>/</span>
<span>{result?.matches || 0}</span>
</>
) : value.length ? (
<span>No matches</span>
) : null}
</div>
</div>
<div>
<IconButton
size="24"
className={clsx(styles.arrowButton, 'backward')}
onClick={handleBackWard}
icon={<ArrowUpSmallIcon />}
/>
<IconButton
size="24"
className={clsx(styles.arrowButton, 'forward')}
onClick={handleForward}
icon={<ArrowDownSmallIcon />}
/>
</div>
<IconButton onClick={handleDone} icon={<CloseIcon />} />
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
};
@@ -3,17 +3,15 @@ import { type Framework, WorkspacesService } from '@toeverything/infra';
import { FetchService } from '../cloud';
import { ImportTemplateDialog } from './entities/dialog';
import { TemplateDownloader } from './entities/downloader';
import { ImportTemplateDialogService } from './services/dialog';
import { TemplateDownloaderService } from './services/downloader';
import { ImportTemplateService } from './services/import';
import { TemplateDownloaderStore } from './store/downloader';
export { ImportTemplateDialogService } from './services/dialog';
export { ImportTemplateDialogProvider } from './views/dialog';
export { TemplateDownloaderService } from './services/downloader';
export { ImportTemplateService } from './services/import';
export function configureImportTemplateModule(framework: Framework) {
framework
.service(ImportTemplateDialogService)
.entity(ImportTemplateDialog)
.service(TemplateDownloaderService)
.entity(TemplateDownloader, [TemplateDownloaderStore])
@@ -1,7 +0,0 @@
import { Service } from '@toeverything/infra';
import { ImportTemplateDialog } from '../entities/dialog';
export class ImportTemplateDialogService extends Service {
dialog = this.framework.createEntity(ImportTemplateDialog);
}
@@ -1,48 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const dialogContainer = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
color: cssVarV2('text/primary'),
padding: '16px',
});
export const mainIcon = style({
width: 36,
height: 36,
color: cssVarV2('icon/primary'),
});
export const mainTitle = style({
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
marginTop: '16px',
fontWeight: 600,
});
export const desc = style({
textAlign: 'center',
color: cssVarV2('text/secondary'),
marginBottom: '20px',
});
export const mainButton = style({
width: '100%',
fontSize: '14px',
height: '42px',
});
export const modal = style({
maxWidth: '400px',
});
export const workspaceSelector = style({
margin: '0 -16px',
width: 'calc(100% + 32px)',
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
padding: '0 16px',
});
@@ -1,253 +0,0 @@
import { Button, Modal } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { useWorkspaceName } from '@affine/core/components/hooks/use-workspace-info';
import { WorkspaceSelector } from '@affine/core/components/workspace-selector';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/blocks';
import { AllDocsIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
type WorkspaceMetadata,
WorkspacesService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useState } from 'react';
import { AuthService } from '../../cloud';
import type { CreateWorkspaceCallbackPayload } from '../../create-workspace';
import { ImportTemplateDialogService } from '../services/dialog';
import { TemplateDownloaderService } from '../services/downloader';
import { ImportTemplateService } from '../services/import';
import * as styles from './dialog.css';
const Dialog = ({
templateName,
templateMode,
snapshotUrl,
onClose,
}: {
templateName: string;
templateMode: DocMode;
snapshotUrl: string;
onClose?: () => void;
}) => {
const t = useI18n();
const session = useService(AuthService).session;
const notLogin = useLiveData(session.status$) === 'unauthenticated';
const isSessionRevalidating = useLiveData(session.isRevalidating$);
const [importing, setImporting] = useState(false);
const [importingError, setImportingError] = useState<any>(null);
const workspacesService = useService(WorkspacesService);
const templateDownloaderService = useService(TemplateDownloaderService);
const importTemplateService = useService(ImportTemplateService);
const templateDownloader = templateDownloaderService.downloader;
const isDownloading = useLiveData(templateDownloader.isDownloading$);
const downloadError = useLiveData(templateDownloader.error$);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const [rawSelectedWorkspace, setSelectedWorkspace] =
useState<WorkspaceMetadata | null>(null);
const selectedWorkspace =
rawSelectedWorkspace ??
workspaces.find(w => w.flavour === WorkspaceFlavour.AFFINE_CLOUD) ??
workspaces.at(0);
const selectedWorkspaceName = useWorkspaceName(selectedWorkspace);
const { openPage, jumpToSignIn } = useNavigateHelper();
const noWorkspace = workspaces.length === 0;
useEffect(() => {
workspacesService.list.revalidate();
}, [workspacesService]);
useEffect(() => {
session.revalidate();
}, [session]);
useEffect(() => {
if (!isSessionRevalidating && notLogin) {
jumpToSignIn(
'/template/import?' +
'&name=' +
templateName +
'&mode=' +
templateMode +
'&snapshotUrl=' +
snapshotUrl
);
onClose?.();
}
}, [
isSessionRevalidating,
jumpToSignIn,
notLogin,
onClose,
snapshotUrl,
templateName,
templateMode,
]);
useEffect(() => {
templateDownloader.download({ snapshotUrl });
}, [snapshotUrl, templateDownloader]);
const handleSelectedWorkspace = useCallback(
(workspaceMetadata: WorkspaceMetadata) => {
return setSelectedWorkspace(workspaceMetadata);
},
[]
);
const handleCreatedWorkspace = useCallback(
(payload: CreateWorkspaceCallbackPayload) => {
return setSelectedWorkspace(payload.meta);
},
[]
);
const handleImportToSelectedWorkspace = useAsyncCallback(async () => {
if (templateDownloader.data$.value && selectedWorkspace) {
setImporting(true);
try {
const docId = await importTemplateService.importToWorkspace(
selectedWorkspace,
templateDownloader.data$.value,
templateMode
);
openPage(selectedWorkspace.id, docId);
onClose?.();
} catch (err) {
setImportingError(err);
} finally {
setImporting(false);
}
}
}, [
importTemplateService,
onClose,
openPage,
selectedWorkspace,
templateDownloader.data$.value,
templateMode,
]);
const handleImportToNewWorkspace = useAsyncCallback(async () => {
if (!templateDownloader.data$.value) {
return;
}
setImporting(true);
try {
const { workspaceId, docId } =
await importTemplateService.importToNewWorkspace(
WorkspaceFlavour.AFFINE_CLOUD,
'Workspace',
templateDownloader.data$.value
);
openPage(workspaceId, docId);
onClose?.();
} catch (err) {
setImportingError(err);
} finally {
setImporting(false);
}
}, [
importTemplateService,
onClose,
openPage,
templateDownloader.data$.value,
]);
const disabled = isDownloading || importing || notLogin;
return (
<>
<div className={styles.dialogContainer}>
<AllDocsIcon className={styles.mainIcon} />
<h6 className={styles.mainTitle}>
{t['com.affine.import-template.dialog.createDocWithTemplate']({
templateName,
})}
</h6>
{noWorkspace ? (
<p className={styles.desc}>A new workspace will be created.</p>
) : (
<>
<p className={styles.desc}>Choose a workspace.</p>
<WorkspaceSelector
workspaceMetadata={selectedWorkspace}
onSelectWorkspace={handleSelectedWorkspace}
onCreatedWorkspace={handleCreatedWorkspace}
className={styles.workspaceSelector}
showArrowDownIcon
disable={disabled}
/>
</>
)}
</div>
{importingError && (
<span style={{ color: cssVar('warningColor') }}>
{t['com.affine.import-template.dialog.errorImport']()}
</span>
)}
{downloadError ? (
<span style={{ color: cssVar('warningColor') }}>
{t['com.affine.import-template.dialog.errorLoad']()}
</span>
) : selectedWorkspace ? (
<Button
className={styles.mainButton}
variant={disabled ? 'secondary' : 'primary'}
loading={disabled}
disabled={disabled}
onClick={handleImportToSelectedWorkspace}
>
{selectedWorkspaceName &&
t['com.affine.import-template.dialog.createDocToWorkspace']({
workspace: selectedWorkspaceName,
})}
</Button>
) : (
<Button
className={styles.mainButton}
variant="primary"
loading={disabled}
disabled={disabled}
onClick={handleImportToNewWorkspace}
>
{t['com.affine.import-template.dialog.createDocToNewWorkspace']()}
</Button>
)}
</>
);
};
export const ImportTemplateDialogProvider = () => {
const importTemplateDialogService = useService(ImportTemplateDialogService);
const isOpen = useLiveData(importTemplateDialogService.dialog.isOpen$);
const template = useLiveData(importTemplateDialogService.dialog.template$);
return (
<Modal
open={isOpen}
modal={true}
persistent
withoutCloseButton
contentOptions={{
className: styles.modal,
}}
onOpenChange={() => importTemplateDialogService.dialog.close()}
>
{template && (
<Dialog
templateName={template.templateName}
templateMode={template.templateMode}
snapshotUrl={template.snapshotUrl}
onClose={() => importTemplateDialogService.dialog.close()}
/>
)}
</Modal>
);
};
+5 -3
View File
@@ -4,7 +4,7 @@ import { configureInfraModules, type Framework } from '@toeverything/infra';
import { configureAppSidebarModule } from './app-sidebar';
import { configureCloudModule } from './cloud';
import { configureCollectionModule } from './collection';
import { configureCreateWorkspaceModule } from './create-workspace';
import { configureDialogModule } from './dialogs';
import { configureDocDisplayMetaModule } from './doc-display-meta';
import { configureDocInfoModule } from './doc-info';
import { configureDocLinksModule } from './doc-link';
@@ -17,6 +17,7 @@ import { configureI18nModule } from './i18n';
import { configureImportTemplateModule } from './import-template';
import { configureJournalModule } from './journal';
import { configureNavigationModule } from './navigation';
import { configureOpenInApp } from './open-in-app';
import { configureOrganizeModule } from './organize';
import { configurePeekViewModule } from './peek-view';
import { configurePermissionsModule } from './permissions';
@@ -56,11 +57,12 @@ export function configureCommonModules(framework: Framework) {
configureSystemFontFamilyModule(framework);
configureEditorSettingModule(framework);
configureImportTemplateModule(framework);
configureCreateWorkspaceModule(framework);
configureUserspaceModule(framework);
configureDocInfoModule(framework);
configureAppSidebarModule(framework);
configureJournalModule(framework);
configureUrlModule(framework);
configureAppThemeModule(framework);
configureDialogModule(framework);
configureDocInfoModule(framework);
configureOpenInApp(framework);
}
@@ -6,7 +6,7 @@ import {
import { OpenInAppService } from './services';
export * from './services';
export { OpenInAppService, OpenLinkMode } from './services';
export * from './utils';
export * from './views/open-in-app-guard';
@@ -1,13 +1,13 @@
import { Button } from '@affine/component/ui/button';
import { openSettingModalAtom } from '@affine/core/components/atoms';
import { resolveLinkToDoc } from '@affine/core/modules/navigation';
import { appIconMap, appNames } from '@affine/core/utils';
import { appIconMap, appNames } from '@affine/core/utils/channel';
import { Trans, useI18n } from '@affine/i18n';
import { Logo1Icon } from '@blocksuite/icons/rc';
import { useSetAtom } from 'jotai';
import { useService } from '@toeverything/infra';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { GlobalDialogService } from '../../dialogs';
import { getOpenUrlInDesktopAppLink } from '../utils';
import * as styles from './open-in-app-page.css';
@@ -21,6 +21,7 @@ interface OpenAppProps {
export const OpenInAppPage = ({ urlToOpen, openHereClicked }: OpenAppProps) => {
// default to open the current page in desktop app
urlToOpen ??= getOpenUrlInDesktopAppLink(window.location.href, true);
const globalDialogService = useService(GlobalDialogService);
const t = useI18n();
const channel = BUILD_CONFIG.appBuildType;
const openDownloadLink = useCallback(() => {
@@ -45,17 +46,14 @@ export const OpenInAppPage = ({ urlToOpen, openHereClicked }: OpenAppProps) => {
[maybeDocLink, openHereClicked]
);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const goToAppearanceSetting = useCallback(
(e: MouseEvent) => {
openHereClicked?.(e);
setSettingModalAtom({
open: true,
globalDialogService.open('setting', {
activeTab: 'appearance',
});
},
[openHereClicked, setSettingModalAtom]
[globalDialogService, openHereClicked]
);
if (urlToOpen && lastOpened !== urlToOpen) {
@@ -30,21 +30,15 @@ export const modalContentContainer = style({
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
'@media': {
// mobile:
'screen and (width <= 640px)': {
selectors: {
[`${modalContentWrapper}:is([data-mode="max"], [data-mode="fit"]) &`]: {
height: '60%',
width: 'calc(100% - 32px)',
paddingRight: 0,
paddingBottom: 32,
alignSelf: 'flex-end',
},
},
},
},
selectors: {
[`${modalContentWrapper}:is([data-mode="max"], [data-mode="fit"], [data-mobile]) &`]:
{
height: '60%',
width: 'calc(100% - 32px)',
paddingRight: 0,
paddingBottom: 32,
alignSelf: 'flex-end',
},
[`${modalContentWrapper}[data-mode="max"] &`]: {
width: 'calc(100% - 64px)',
height: 'calc(100% - 64px)',
@@ -321,10 +321,12 @@ export const PeekViewModalContainer = forwardRef<
data-mode={mode}
data-peek-view-wrapper
className={styles.modalContentWrapper}
data-mobile={BUILD_CONFIG.isMobileEdition ? '' : undefined}
>
<div
data-anime-state={animeState}
data-full-width-layout={fullWidthLayout}
data-mobile={BUILD_CONFIG.isMobileEdition}
ref={contentClipRef}
className={styles.modalContentContainer}
>
@@ -18,7 +18,7 @@ import {
useMemo,
} from 'react';
import { DocInfoService } from '../../doc-info';
import { WorkspaceDialogService } from '../../dialogs';
import { WorkbenchService } from '../../workbench';
import type { DocReferenceInfo } from '../entities/peek-view';
import { PeekViewService } from '../services/peek-view';
@@ -98,7 +98,7 @@ export const DocPeekViewControls = ({
const peekView = useService(PeekViewService).peekView;
const workbench = useService(WorkbenchService).workbench;
const t = useI18n();
const docInfoService = useService(DocInfoService);
const workspaceDialogService = useService(WorkspaceDialogService);
const controls = useMemo(() => {
return [
{
@@ -139,13 +139,11 @@ export const DocPeekViewControls = ({
nameKey: 'info',
name: t['com.affine.peek-view-controls.open-info'](),
onClick: () => {
docInfoService.modal.open(
typeof docRef === 'string' ? docRef : docRef.docId
);
workspaceDialogService.open('doc-info', { docId: docRef.docId });
},
},
].filter((opt): opt is ControlButtonProps => Boolean(opt));
}, [t, peekView, workbench, docRef, docInfoService.modal]);
}, [t, peekView, workbench, docRef, workspaceDialogService]);
return (
<div {...rest} className={clsx(styles.root, className)}>
{controls.map(option => (
@@ -2,8 +2,6 @@ import { type Framework, GlobalState } from '@toeverything/infra';
import { ThemeEditorService } from './services/theme-editor';
export { CustomThemeModifier, useCustomTheme } from './views/custom-theme';
export { ThemeEditor } from './views/theme-editor';
export { ThemeEditorService };
export function configureThemeEditorModule(framework: Framework) {
@@ -1,73 +0,0 @@
import { IconButton, Input, Menu, MenuItem } from '@affine/component';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { useCallback, useState } from 'react';
import * as styles from '../theme-editor.css';
import { SimpleColorPicker } from './simple-color-picker';
export const ColorCell = ({
value,
custom,
onValueChange,
}: {
value: string;
custom?: string;
onValueChange?: (color?: string) => void;
}) => {
const [inputValue, setInputValue] = useState(value);
const onInput = useCallback(
(color: string) => {
onValueChange?.(color);
setInputValue(color);
},
[onValueChange]
);
return (
<div className={styles.colorCell}>
<div>
<div data-override={!!custom} className={styles.colorCellRow}>
<div
className={styles.colorCellColor}
style={{ backgroundColor: value }}
/>
<div className={styles.colorCellValue}>{value}</div>
</div>
<div data-empty={!custom} data-custom className={styles.colorCellRow}>
<div
className={styles.colorCellColor}
style={{ backgroundColor: custom }}
/>
<div className={styles.colorCellValue}>{custom}</div>
</div>
</div>
<Menu
contentOptions={{ style: { background: cssVar('white') } }}
items={
<ul style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<SimpleColorPicker
value={inputValue}
setValue={onInput}
className={styles.colorCellInput}
/>
<Input
value={inputValue}
onChange={onInput}
placeholder="Input color"
/>
{custom ? (
<MenuItem type="danger" onClick={() => onValueChange?.()}>
Recover
</MenuItem>
) : null}
</ul>
}
>
<IconButton size="14" icon={<MoreHorizontalIcon />} />
</Menu>
</div>
);
};
@@ -1,11 +0,0 @@
import { Empty } from '@affine/component';
export const ThemeEmpty = () => {
return (
<div
style={{ width: 0, flex: 1, display: 'flex', justifyContent: 'center' }}
>
<Empty description="Select a variable to edit" />
</div>
);
};
@@ -1,19 +0,0 @@
import { style } from '@vanilla-extract/css';
export const wrapper = style({
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
border: `1px solid rgba(125,125,125, 0.3)`,
cursor: 'pointer',
});
export const input = style({
position: 'absolute',
pointerEvents: 'none',
width: '100%',
height: '100%',
top: 0,
left: 0,
opacity: 0,
});
@@ -1,34 +0,0 @@
import clsx from 'clsx';
import { type HTMLAttributes, useId } from 'react';
import * as styles from './simple-color-picker.css';
export const SimpleColorPicker = ({
value,
setValue,
className,
...attrs
}: HTMLAttributes<HTMLDivElement> & {
value: string;
setValue: (value: string) => void;
}) => {
const id = useId();
return (
<label htmlFor={id}>
<div
style={{ backgroundColor: value }}
className={clsx(styles.wrapper, className)}
{...attrs}
>
<input
className={styles.input}
type="color"
name={id}
id={id}
value={value}
onChange={e => setValue(e.target.value)}
/>
</div>
</label>
);
};
@@ -1,9 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const row = style({
background: 'rgba(125,125,125,0.1)',
borderRadius: 4,
fontSize: cssVar('fontXs'),
padding: '4px 8px',
});
@@ -1,36 +0,0 @@
import { Input } from '@affine/component';
import { useCallback, useState } from 'react';
import * as styles from './string-cell.css';
export const StringCell = ({
value,
custom,
onValueChange,
}: {
value: string;
custom?: string;
onValueChange?: (color?: string) => void;
}) => {
const [inputValue, setInputValue] = useState(custom ?? '');
const onInput = useCallback(
(color: string) => {
onValueChange?.(color || undefined);
setInputValue(color);
},
[onValueChange]
);
return (
<div style={{ display: 'flex', gap: 8, flexDirection: 'column' }}>
<div className={styles.row}>{value}</div>
<Input
placeholder="Input value to override"
style={{ width: '100%' }}
value={inputValue}
onChange={onInput}
/>
</div>
);
};
@@ -1,66 +0,0 @@
import { ArrowDownSmallIcon, PaletteIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useCallback, useState } from 'react';
import { type TreeNode } from '../resource';
import * as styles from '../theme-editor.css';
export const ThemeTreeNode = ({
node,
checked,
setActive,
isActive,
isCustomized,
}: {
node: TreeNode;
checked?: TreeNode;
setActive: (vs: TreeNode) => void;
isActive?: (node: TreeNode) => boolean;
isCustomized?: (node: TreeNode) => boolean;
}) => {
const isLeaf = !node.children && node.variables;
const [open, setOpen] = useState(false);
const onClick = useCallback(() => {
if (isLeaf || node.variables?.length) setActive(node);
if (node.children) setOpen(prev => !prev);
}, [isLeaf, node, setActive]);
return (
<Collapsible.Root open={open}>
<div
data-checked={node === checked}
data-active={isActive?.(node)}
data-customized={isCustomized?.(node)}
className={styles.treeNode}
onClick={onClick}
>
<div className={styles.treeNodeIconWrapper}>
{isLeaf ? (
<PaletteIcon width={16} height={16} />
) : (
<ArrowDownSmallIcon
data-open={open}
className={styles.treeNodeCollapseIcon}
width={20}
height={20}
/>
)}
</div>
<span>{node.label}</span>
</div>
<Collapsible.Content className={styles.treeNodeContent}>
{node.children?.map(child => (
<ThemeTreeNode
key={child.id}
node={child}
checked={checked}
isActive={isActive}
setActive={setActive}
isCustomized={isCustomized}
/>
))}
</Collapsible.Content>
</Collapsible.Root>
);
};
@@ -1,68 +0,0 @@
import { Scrollable } from '@affine/component';
import { useLiveData, useService } from '@toeverything/infra';
import { ThemeEditorService } from '../../services/theme-editor';
import type { TreeNode } from '../resource';
import * as styles from '../theme-editor.css';
import { isColor } from '../utils';
import { ColorCell } from './color-cell';
import { StringCell } from './string-cell';
export const VariableList = ({ node }: { node: TreeNode }) => {
const themeEditor = useService(ThemeEditorService);
const customTheme = useLiveData(themeEditor.customTheme$);
const variables = node.variables ?? [];
return (
<main className={styles.content}>
<header>
<ul className={styles.row}>
<li>Name</li>
<li>Light</li>
<li>Dark</li>
</ul>
</header>
<Scrollable.Root className={styles.mainScrollable}>
<Scrollable.Viewport className={styles.mainViewport}>
{variables.map(variable => (
<ul className={styles.row} key={variable.variableName}>
<li
style={{
textDecoration:
customTheme?.light?.[variable.variableName] ||
customTheme?.dark?.[variable.variableName]
? 'underline'
: 'none',
}}
>
{variable.name}
</li>
{(['light', 'dark'] as const).map(mode => {
const Renderer = isColor(variable[mode])
? ColorCell
: StringCell;
return (
<li key={mode}>
<Renderer
value={variable[mode]}
custom={customTheme?.[mode]?.[variable.variableName]}
onValueChange={color =>
themeEditor.updateCustomTheme(
mode,
variable.variableName,
color
)
}
/>
</li>
);
})}
</ul>
))}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</main>
);
};
@@ -1,57 +0,0 @@
import {
FeatureFlagService,
useLiveData,
useServices,
} from '@toeverything/infra';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import { ThemeEditorService } from '../services/theme-editor';
let _provided = false;
export const useCustomTheme = (target: HTMLElement) => {
const { themeEditorService, featureFlagService } = useServices({
ThemeEditorService,
FeatureFlagService,
});
const enableThemeEditor = useLiveData(
featureFlagService.flags.enable_theme_editor.$
);
const { resolvedTheme } = useTheme();
useEffect(() => {
if (!enableThemeEditor) return;
if (_provided) return;
_provided = true;
const sub = themeEditorService.customTheme$.subscribe(themeObj => {
if (!themeObj) return;
const mode = resolvedTheme === 'dark' ? 'dark' : 'light';
const valueMap = themeObj[mode];
// remove previous style
// TOOD(@CatsJuice): find better way to remove previous style
target.style.cssText = '';
// recover color scheme set by next-themes
target.style.colorScheme = mode;
Object.entries(valueMap).forEach(([key, value]) => {
value && target.style.setProperty(key, value);
});
});
return () => {
_provided = false;
sub.unsubscribe();
};
}, [resolvedTheme, target.style, enableThemeEditor, themeEditorService]);
};
export const CustomThemeModifier = () => {
useCustomTheme(document.documentElement);
return null;
};
@@ -1,122 +0,0 @@
import { darkCssVariables, lightCssVariables } from '@toeverything/theme';
import {
darkCssVariablesV2,
lightCssVariablesV2,
} from '@toeverything/theme/v2';
import { partsToVariableName, variableNameToParts } from './utils';
export type Variable = {
id: string;
name: string;
variableName: string;
light: string;
dark: string;
ancestors: string[];
};
export interface TreeNode {
id: string;
label: string;
parentId?: string;
children?: TreeNode[];
variables?: Variable[];
}
export interface ThemeInfo {
tree: TreeNode[];
nodeMap: Map<string, TreeNode>;
variableMap: Map<string, Variable>;
}
const sortTree = (tree: TreeNode[]) => {
const compare = (a: TreeNode, b: TreeNode) => {
if (a.children && !b.children) return -1;
if (!a.children && b.children) return 1;
return a.label.localeCompare(b.label);
};
const walk = (node: TreeNode) => {
node.children?.sort(compare);
node.children?.forEach(walk);
};
tree.sort(compare).forEach(walk);
return tree;
};
const getTree = (
light: Record<string, string>,
dark: Record<string, string>
): ThemeInfo => {
const lightKeys = Object.keys(light);
const darkKeys = Object.keys(dark);
const allKeys = Array.from(new Set([...lightKeys, ...darkKeys])).map(name =>
variableNameToParts(name)
);
const rootNodesSet = new Set<TreeNode>();
const nodeMap = new Map<string, TreeNode>();
const variableMap = new Map<string, Variable>();
allKeys.forEach(parts => {
let id = '';
let node: TreeNode | undefined;
const ancestors: string[] = [];
parts.slice(0, -1).forEach((part, index) => {
const isLeaf = index === parts.length - 2;
const parentId = id ? id : undefined;
id += `/${part}`;
ancestors.push(id);
if (!nodeMap.has(id)) {
nodeMap.set(id, {
id,
parentId,
label: part,
children: isLeaf ? undefined : [],
variables: isLeaf ? [] : undefined,
});
}
node = nodeMap.get(id);
if (!node) return; // should never reach
if (parentId) {
const parent = nodeMap.get(parentId);
if (!parent) return; // should never reach
if (parent.children?.includes(node)) return;
parent.children?.push(node);
}
if (index === 0) rootNodesSet.add(node);
});
if (node) {
const variableName = partsToVariableName(parts);
// for the case that a node should have both children & variables
if (!node.variables) {
node.variables = [];
}
const variable = {
id: variableName,
name: parts[parts.length - 1],
variableName,
light: light[variableName],
dark: dark[variableName],
ancestors,
};
node.variables.push(variable);
variableMap.set(variableName, variable);
}
});
return {
tree: sortTree(Array.from(rootNodesSet)),
nodeMap,
variableMap,
};
};
export const affineThemes = {
v1: getTree(lightCssVariables, darkCssVariables),
v2: getTree(lightCssVariablesV2, darkCssVariablesV2),
};
@@ -1,183 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
width: '100vw',
height: '100vh',
display: 'flex',
background: cssVarV2('layer/background/primary'),
color: cssVar('textPrimaryColor'),
});
globalStyle(`${root} *`, {
boxSizing: 'border-box',
});
export const sidebar = style({
flexShrink: 0,
width: 240,
borderRight: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
display: 'flex',
flexDirection: 'column',
userSelect: 'none',
});
export const content = style({
width: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
});
export const sidebarHeader = style({
padding: '8px 48px',
background: cssVarV2('layer/background/primary'),
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const sidebarScrollable = style({
height: 0,
flex: 1,
padding: '8px 8px 0px 8px',
});
export const mainHeader = style({});
export const mainScrollable = style({
height: 0,
flex: 1,
});
export const mainViewport = style({
display: 'flex',
flexDirection: 'column',
});
export const row = style({
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 0,
selectors: {
'header &': {
fontWeight: 500,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
padding: '9px 0',
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
},
[`${mainViewport} &`]: {
padding: '4px 0',
},
[`${mainViewport} &:not(:last-child)`]: {
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
},
});
globalStyle(`${row} > li`, {
width: 0,
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px 8px',
});
export const treeNode = style({
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 16px',
borderRadius: 8,
color: cssVar('textPrimaryColor'),
cursor: 'pointer',
selectors: {
'&[data-active="true"]': {
color: cssVar('brandColor'),
},
'&[data-checked="true"], &:hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},
'&[data-customized="true"]': {
textDecoration: 'underline',
},
},
});
export const treeNodeContent = style({
display: 'flex',
flexDirection: 'column',
gap: 2,
paddingLeft: 32,
paddingTop: 4,
paddingBottom: 4,
});
export const treeNodeIconWrapper = style({
color: cssVarV2('icon/secondary'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 20,
height: 20,
});
export const treeNodeCollapseIcon = style({
transition: 'transform 0.2s',
transform: 'rotate(-90deg)',
selectors: {
'&[data-open="true"]': {
transform: 'rotate(0deg)',
},
},
});
export const colorCell = style({
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: cssVar('fontXs'),
width: 220,
});
export const colorCellRow = style({
display: 'flex',
alignItems: 'center',
gap: 8,
position: 'relative',
minHeight: 23,
selectors: {
'&[data-empty="true"]': {
display: 'none',
},
'&[data-override="true"]': {
opacity: 0.1,
textDecoration: 'line-through',
},
},
});
export const colorCellColor = style({
flexShrink: 0,
width: 16,
height: 16,
borderRadius: 4,
position: 'relative',
':before': {
width: 16,
height: 16,
position: 'absolute',
content: '""',
borderRadius: 'inherit',
left: 0,
top: 0,
border: `1px solid rgba(0,0,0,0.1)`,
boxSizing: 'border-box',
},
selectors: {
[`${colorCellRow}[data-custom] &`]: {
borderRadius: '50%',
},
},
});
export const colorCellValue = style({
padding: '4px 8px',
borderRadius: 4,
background: 'rgba(125,125,125,0.1)',
});
export const colorCellInput = style({
width: '100%',
height: 32,
});
@@ -1,101 +0,0 @@
import { RadioGroup, Scrollable } from '@affine/component';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { ThemeEditorService } from '../services/theme-editor';
import { ThemeEmpty } from './components/empty';
import { ThemeTreeNode } from './components/tree-node';
import { VariableList } from './components/variable-list';
import { affineThemes, type TreeNode } from './resource';
import * as styles from './theme-editor.css';
export const ThemeEditor = () => {
const themeEditor = useService(ThemeEditorService);
const [version, setVersion] = useState<'v1' | 'v2'>('v1');
const [activeNode, setActiveNode] = useState<TreeNode | null>();
const { nodeMap, variableMap, tree } = affineThemes[version];
const [customizedNodeIds, setCustomizedNodeIds] = useState<Set<string>>(
new Set()
);
// workaround for the performance issue of using `useLiveData(themeEditor.customTheme$)` here
useEffect(() => {
const sub = themeEditor.customTheme$.subscribe(customTheme => {
const ids = Array.from(
new Set([
...Object.keys(customTheme?.light ?? {}),
...Object.keys(customTheme?.dark ?? {}),
])
).reduce((acc, name) => {
const variable = variableMap.get(name);
if (!variable) return acc;
variable.ancestors.forEach(id => acc.add(id));
return acc;
}, new Set<string>());
setCustomizedNodeIds(prev => {
const isSame =
Array.from(ids).every(id => prev.has(id)) &&
Array.from(prev).every(id => ids.has(id));
return isSame ? prev : ids;
});
});
return () => sub.unsubscribe();
}, [themeEditor.customTheme$, variableMap]);
const onToggleVersion = useCallback((v: 'v1' | 'v2') => {
setVersion(v);
setActiveNode(null);
}, []);
const isActive = useCallback(
(node: TreeNode) => {
let pointer = activeNode;
while (pointer) {
if (!pointer) return false;
if (pointer === node) return true;
pointer = pointer.parentId ? nodeMap.get(pointer.parentId) : undefined;
}
return false;
},
[activeNode, nodeMap]
);
const isCustomized = useCallback(
(node: TreeNode) => customizedNodeIds.has(node.id),
[customizedNodeIds]
);
return (
<div className={styles.root}>
<div className={styles.sidebar}>
<header className={styles.sidebarHeader}>
<RadioGroup
width="100%"
value={version}
onChange={onToggleVersion}
items={['v1', 'v2']}
/>
</header>
<Scrollable.Root className={styles.sidebarScrollable} key={version}>
<Scrollable.Viewport>
{tree.map(node => (
<ThemeTreeNode
key={node.id}
node={node}
checked={activeNode ?? undefined}
setActive={setActiveNode}
isActive={isActive}
isCustomized={isCustomized}
/>
))}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</div>
{activeNode ? <VariableList node={activeNode} /> : <ThemeEmpty />}
</div>
);
};
@@ -1,8 +0,0 @@
export const variableNameToParts = (name: string) => name.slice(9).split('-');
export const partsToVariableName = (parts: string[]) =>
`--affine-${parts.join('-')}`;
export const isColor = (value: string) => {
return value.startsWith('#') || value.startsWith('rgb');
};
@@ -13,13 +13,13 @@ import {
} from '@dnd-kit/sortable';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import type { HTMLAttributes, PropsWithChildren, RefObject } from 'react';
import type { HTMLAttributes, RefObject } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { View } from '../../entities/view';
import { WorkbenchService } from '../../services/workbench';
import { SplitViewPanel, SplitViewPanelContainer } from './panel';
import { SplitViewPanel } from './panel';
import { ResizeHandle } from './resize-handle';
import * as styles from './split-view.css';
@@ -141,20 +141,3 @@ export const SplitView = ({
</div>
);
};
export const SplitViewFallback = ({
children,
className,
}: PropsWithChildren<{ className?: string }>) => {
const { appSettings } = useAppSettingHelper();
return (
<div
className={clsx(styles.splitViewRoot, className)}
data-client-border={appSettings.clientBorder}
>
{/* todo: support multiple split views */}
<SplitViewPanelContainer>{children}</SplitViewPanelContainer>
</div>
);
};