mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
refactor(core): desktop project struct (#8334)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
-19
@@ -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,
|
||||
});
|
||||
-34
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user