feat(electron): multi tabs support (#7440)
use https://www.electronjs.org/docs/latest/api/web-contents-view to serve different tab views added tabs view manager in electron to handle multi-view actions and events. fix AF-1111 fix AF-999 fix PD-1459 fix AF-964 PD-1458
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const _appConfigSchema = z.object({
|
export const appConfigSchema = z.object({
|
||||||
/** whether to show onboarding first */
|
/** whether to show onboarding first */
|
||||||
onBoarding: z.boolean().optional().default(true),
|
onBoarding: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
export type AppConfigSchema = z.infer<typeof _appConfigSchema>;
|
|
||||||
export const defaultAppConfig = _appConfigSchema.parse({});
|
export type AppConfigSchema = z.infer<typeof appConfigSchema>;
|
||||||
|
export const defaultAppConfig = appConfigSchema.parse({});
|
||||||
|
|
||||||
const _storage: Record<number, any> = {};
|
const _storage: Record<number, any> = {};
|
||||||
let _inMemoryId = 0;
|
let _inMemoryId = 0;
|
||||||
@@ -48,7 +49,7 @@ class Storage<T extends object> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(): T;
|
get(): T;
|
||||||
get(key: keyof T): T[keyof T];
|
get<K extends keyof T>(key: K): T[K];
|
||||||
/**
|
/**
|
||||||
* get config, if key is provided, return the value of the key
|
* get config, if key is provided, return the value of the key
|
||||||
* @param key
|
* @param key
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function setupEditorFlags(docCollection: DocCollection) {
|
|||||||
|
|
||||||
type SetStateAction<Value> = Value | ((prev: Value) => Value);
|
type SetStateAction<Value> = Value | ((prev: Value) => Value);
|
||||||
|
|
||||||
|
// todo(@pengx17): use global state instead
|
||||||
const appSettingEffect = atomEffect(get => {
|
const appSettingEffect = atomEffect(get => {
|
||||||
const settings = get(appSettingBaseAtom);
|
const settings = get(appSettingBaseAtom);
|
||||||
// some values in settings should be synced into electron side
|
// some values in settings should be synced into electron side
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ export { Framework } from './framework';
|
|||||||
export { createIdentifier } from './identifier';
|
export { createIdentifier } from './identifier';
|
||||||
export type { ResolveOptions } from './provider';
|
export type { ResolveOptions } from './provider';
|
||||||
export { FrameworkProvider } from './provider';
|
export { FrameworkProvider } from './provider';
|
||||||
export type { GeneralIdentifier } from './types';
|
export type { GeneralIdentifier, Identifier } from './types';
|
||||||
|
|||||||
@@ -100,11 +100,9 @@ export const ScrollableLayout = ({
|
|||||||
export const OnboardingPage = ({
|
export const OnboardingPage = ({
|
||||||
user,
|
user,
|
||||||
onOpenAffine,
|
onOpenAffine,
|
||||||
windowControl,
|
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
onOpenAffine: () => void;
|
onOpenAffine: () => void;
|
||||||
windowControl?: React.ReactNode;
|
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -150,8 +148,6 @@ export const OnboardingPage = ({
|
|||||||
return (
|
return (
|
||||||
<ScrollableLayout
|
<ScrollableLayout
|
||||||
headerItems={
|
headerItems={
|
||||||
<>
|
|
||||||
{isWindowsDesktop ? windowControl : null}
|
|
||||||
<Button
|
<Button
|
||||||
className={clsx(styles.button, {
|
className={clsx(styles.button, {
|
||||||
[styles.disableButton]: questionIdx === 0,
|
[styles.disableButton]: questionIdx === 0,
|
||||||
@@ -162,7 +158,6 @@ export const OnboardingPage = ({
|
|||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
isMacosDesktop={isMacosDesktop}
|
isMacosDesktop={isMacosDesktop}
|
||||||
isWindowsDesktop={isWindowsDesktop}
|
isWindowsDesktop={isWindowsDesktop}
|
||||||
@@ -265,7 +260,6 @@ export const OnboardingPage = ({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ScrollableLayout
|
<ScrollableLayout
|
||||||
headerItems={isWindowsDesktop ? windowControl : null}
|
|
||||||
isMacosDesktop={isMacosDesktop}
|
isMacosDesktop={isMacosDesktop}
|
||||||
isWindowsDesktop={isWindowsDesktop}
|
isWindowsDesktop={isWindowsDesktop}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -288,6 +288,20 @@ textarea
|
|||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-active='false'] {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-active='true']:has([data-blur-background='true']) {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-active='false'] * {
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export const NotificationCard = ({ notification }: NotificationCardProps) => {
|
|||||||
data-float={!!thumb}
|
data-float={!!thumb}
|
||||||
className={clsx(styles.headAlignWrapper, styles.closeButton)}
|
className={clsx(styles.headAlignWrapper, styles.closeButton)}
|
||||||
>
|
>
|
||||||
<IconButton onClick={onDismiss}>
|
<IconButton
|
||||||
|
data-testid="notification-close-button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
>
|
||||||
<CloseIcon className={styles.closeIcon} width={16} height={16} />
|
<CloseIcon className={styles.closeIcon} width={16} height={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
import { useHasScrollTop } from './use-has-scroll-top';
|
import { useHasScrollTop } from './use-has-scroll-top';
|
||||||
@@ -24,8 +23,7 @@ export const ScrollableContainer = ({
|
|||||||
viewPortClassName,
|
viewPortClassName,
|
||||||
scrollBarClassName,
|
scrollBarClassName,
|
||||||
}: PropsWithChildren<ScrollableContainerProps>) => {
|
}: PropsWithChildren<ScrollableContainerProps>) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const [setContainer, hasScrollTop] = useHasScrollTop();
|
||||||
const hasScrollTop = useHasScrollTop(ref);
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea.Root
|
<ScrollArea.Root
|
||||||
style={_styles}
|
style={_styles}
|
||||||
@@ -37,7 +35,7 @@ export const ScrollableContainer = ({
|
|||||||
/>
|
/>
|
||||||
<ScrollArea.Viewport
|
<ScrollArea.Viewport
|
||||||
className={clsx([styles.scrollableViewport, viewPortClassName])}
|
className={clsx([styles.scrollableViewport, viewPortClassName])}
|
||||||
ref={ref}
|
ref={setContainer}
|
||||||
>
|
>
|
||||||
<div className={styles.scrollableContainer}>{children}</div>
|
<div className={styles.scrollableContainer}>{children}</div>
|
||||||
</ScrollArea.Viewport>
|
</ScrollArea.Viewport>
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
import type { RefObject } from 'react';
|
import { debounce } from 'lodash-es';
|
||||||
import { useEffect, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
export function useHasScrollTop(ref: RefObject<HTMLElement> | null) {
|
export function useHasScrollTop() {
|
||||||
const [hasScrollTop, setHasScrollTop] = useState(false);
|
const [hasScrollTop, setHasScrollTop] = useState(false);
|
||||||
|
const containerRefFn = useMemo(() => {
|
||||||
useEffect(() => {
|
let unsub: (() => void) | null = null;
|
||||||
if (!ref?.current) {
|
return (container: HTMLElement | null) => {
|
||||||
return;
|
unsub?.();
|
||||||
}
|
const updateScrollTop = debounce(() => {
|
||||||
|
|
||||||
const container = ref.current;
|
|
||||||
|
|
||||||
function updateScrollTop() {
|
|
||||||
if (container) {
|
if (container) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const hasScrollTop = container.scrollTop > 0;
|
const hasScrollTop = container.scrollTop > 0;
|
||||||
setHasScrollTop(hasScrollTop);
|
setHasScrollTop(hasScrollTop);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, 50);
|
||||||
|
container?.addEventListener('scroll', updateScrollTop);
|
||||||
container.addEventListener('scroll', updateScrollTop);
|
|
||||||
updateScrollTop();
|
updateScrollTop();
|
||||||
return () => {
|
unsub = () => {
|
||||||
container.removeEventListener('scroll', updateScrollTop);
|
container?.removeEventListener('scroll', updateScrollTop);
|
||||||
};
|
};
|
||||||
}, [ref]);
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return hasScrollTop;
|
return [containerRefFn, hasScrollTop] as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
private _renderAIOnboarding() {
|
private _renderAIOnboarding() {
|
||||||
return this.isLoading ||
|
return this.isLoading ||
|
||||||
!this.host.doc.awarenessStore.getFlag('enable_ai_onboarding')
|
!this.host?.doc.awarenessStore.getFlag('enable_ai_onboarding')
|
||||||
? nothing
|
? nothing
|
||||||
: html`<div
|
: html`<div
|
||||||
style=${styleMap({
|
style=${styleMap({
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const tableHeaderTimestamp = style({
|
|||||||
|
|
||||||
export const tableHeaderDivider = style({
|
export const tableHeaderDivider = style({
|
||||||
height: 0,
|
height: 0,
|
||||||
borderTop: `1px solid ${cssVar('borderColor')}`,
|
borderTop: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
margin: '8px 0',
|
margin: '8px 0',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
export const floatingMaxWidth = 768;
|
export const floatingMaxWidth = 768;
|
||||||
export const navWrapperStyle = style({
|
export const navWrapperStyle = style({
|
||||||
paddingBottom: '8px',
|
paddingBottom: '8px',
|
||||||
@@ -11,7 +11,7 @@ export const navWrapperStyle = style({
|
|||||||
},
|
},
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-has-border=true]': {
|
'&[data-has-border=true]': {
|
||||||
borderRight: `1px solid ${cssVar('borderColor')}`,
|
borderRight: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
},
|
},
|
||||||
'&[data-is-floating="true"]': {
|
'&[data-is-floating="true"]': {
|
||||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||||
@@ -45,14 +45,6 @@ export const navHeaderStyle = style({
|
|||||||
['WebkitAppRegion' as string]: 'drag',
|
['WebkitAppRegion' as string]: 'drag',
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(
|
|
||||||
`html[data-fullscreen="false"]
|
|
||||||
${navHeaderStyle}[data-is-macos-electron="true"]`,
|
|
||||||
{
|
|
||||||
paddingLeft: '90px',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const navBodyStyle = style({
|
export const navBodyStyle = style({
|
||||||
flex: '1 1 auto',
|
flex: '1 1 auto',
|
||||||
height: 'calc(100% - 52px)',
|
height: 'calc(100% - 52px)',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { atom } from 'jotai';
|
|||||||
import { atomWithStorage } from 'jotai/utils';
|
import { atomWithStorage } from 'jotai/utils';
|
||||||
|
|
||||||
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
|
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
|
||||||
export const isMobile = window.innerWidth < 768;
|
export const isMobile = window.innerWidth < 768 && !environment.isDesktop;
|
||||||
|
|
||||||
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, !isMobile);
|
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, !isMobile);
|
||||||
export const appSidebarFloatingAtom = atom(isMobile);
|
export const appSidebarFloatingAtom = atom(isMobile);
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const MIN_WIDTH = 248;
|
|||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
children,
|
children,
|
||||||
clientBorder,
|
clientBorder,
|
||||||
translucentUI,
|
|
||||||
}: AppSidebarProps): ReactElement {
|
}: AppSidebarProps): ReactElement {
|
||||||
const [open, setOpen] = useAtom(appSidebarOpenAtom);
|
const [open, setOpen] = useAtom(appSidebarOpenAtom);
|
||||||
const [width, setWidth] = useAtom(appSidebarWidthAtom);
|
const [width, setWidth] = useAtom(appSidebarWidthAtom);
|
||||||
@@ -49,6 +48,11 @@ export function AppSidebar({
|
|||||||
const [resizing, setResizing] = useAtom(appSidebarResizingAtom);
|
const [resizing, setResizing] = useAtom(appSidebarResizingAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// do not float app sidebar on desktop
|
||||||
|
if (environment.isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
function onResize() {
|
function onResize() {
|
||||||
const isFloatingMaxWidth = window.matchMedia(
|
const isFloatingMaxWidth = window.matchMedia(
|
||||||
`(max-width: ${floatingMaxWidth}px)`
|
`(max-width: ${floatingMaxWidth}px)`
|
||||||
@@ -75,10 +79,8 @@ export function AppSidebar({
|
|||||||
};
|
};
|
||||||
}, [open, setFloating, setOpen, width]);
|
}, [open, setFloating, setOpen, width]);
|
||||||
|
|
||||||
|
const hasRightBorder = !environment.isDesktop && !clientBorder;
|
||||||
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
||||||
const hasRightBorder =
|
|
||||||
!environment.isDesktop || (!clientBorder && !translucentUI);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResizePanel
|
<ResizePanel
|
||||||
@@ -96,12 +98,13 @@ export function AppSidebar({
|
|||||||
resizeHandleOffset={clientBorder ? 8 : 0}
|
resizeHandleOffset={clientBorder ? 8 : 0}
|
||||||
resizeHandleVerticalPadding={clientBorder ? 16 : 0}
|
resizeHandleVerticalPadding={clientBorder ? 16 : 0}
|
||||||
data-transparent
|
data-transparent
|
||||||
|
data-open={open}
|
||||||
data-has-border={hasRightBorder}
|
data-has-border={hasRightBorder}
|
||||||
data-testid="app-sidebar-wrapper"
|
data-testid="app-sidebar-wrapper"
|
||||||
data-is-macos-electron={isMacosDesktop}
|
data-is-macos-electron={isMacosDesktop}
|
||||||
>
|
>
|
||||||
<nav className={navStyle} data-testid="app-sidebar">
|
<nav className={navStyle} data-testid="app-sidebar">
|
||||||
<SidebarHeader />
|
{!environment.isDesktop && <SidebarHeader />}
|
||||||
<div className={navBodyStyle} data-testid="sliderBar-inner">
|
<div className={navBodyStyle} data-testid="sliderBar-inner">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useHasScrollTop } from '@affine/component';
|
import { useHasScrollTop } from '@affine/component';
|
||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
|
|
||||||
@@ -11,8 +10,7 @@ export function SidebarContainer({ children }: PropsWithChildren) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const [setContainer, hasScrollTop] = useHasScrollTop();
|
||||||
const hasScrollTop = useHasScrollTop(ref);
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
||||||
<div
|
<div
|
||||||
@@ -21,7 +19,7 @@ export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
|||||||
/>
|
/>
|
||||||
<ScrollArea.Viewport
|
<ScrollArea.Viewport
|
||||||
className={clsx([styles.scrollableViewport])}
|
className={clsx([styles.scrollableViewport])}
|
||||||
ref={ref}
|
ref={setContainer}
|
||||||
>
|
>
|
||||||
<div className={clsx([styles.scrollableContainer])}>{children}</div>
|
<div className={clsx([styles.scrollableContainer])}>{children}</div>
|
||||||
</ScrollArea.Viewport>
|
</ScrollArea.Viewport>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
import { NavigationButtons } from '../../../modules/navigation';
|
|
||||||
import { navHeaderStyle } from '../index.css';
|
import { navHeaderStyle } from '../index.css';
|
||||||
import { appSidebarOpenAtom } from '../index.jotai';
|
import { appSidebarOpenAtom } from '../index.jotai';
|
||||||
import { SidebarSwitch } from './sidebar-switch';
|
import { SidebarSwitch } from './sidebar-switch';
|
||||||
@@ -9,13 +8,8 @@ export const SidebarHeader = () => {
|
|||||||
const open = useAtomValue(appSidebarOpenAtom);
|
const open = useAtomValue(appSidebarOpenAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={navHeaderStyle} data-open={open}>
|
||||||
className={navHeaderStyle}
|
|
||||||
data-open={open}
|
|
||||||
data-is-macos-electron={environment.isDesktop && environment.isMacOs}
|
|
||||||
>
|
|
||||||
<SidebarSwitch show={open} />
|
<SidebarSwitch show={open} />
|
||||||
<NavigationButtons />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useRef,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { usePageHeaderColsDef } from './header-col-def';
|
import { usePageHeaderColsDef } from './header-col-def';
|
||||||
@@ -156,8 +155,7 @@ export const ListScrollContainer = forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
PropsWithChildren<ListScrollContainerProps>
|
PropsWithChildren<ListScrollContainerProps>
|
||||||
>(({ className, children, style }, ref) => {
|
>(({ className, children, style }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const [setContainer, hasScrollTop] = useHasScrollTop();
|
||||||
const hasScrollTop = useHasScrollTop(containerRef);
|
|
||||||
|
|
||||||
const setNodeRef = useCallback(
|
const setNodeRef = useCallback(
|
||||||
(r: HTMLDivElement) => {
|
(r: HTMLDivElement) => {
|
||||||
@@ -168,9 +166,9 @@ export const ListScrollContainer = forwardRef<
|
|||||||
ref.current = r;
|
ref.current = r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
containerRef.current = r;
|
return setContainer(r);
|
||||||
},
|
},
|
||||||
[ref]
|
[ref, setContainer]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const tableHeader = style({
|
|||||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||||
});
|
});
|
||||||
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
||||||
boxShadow: `0 1px ${cssVar('borderColor')}`,
|
boxShadow: `0 0.5px ${cssVar('borderColor')}`,
|
||||||
});
|
});
|
||||||
export const headerTitleSelectionIconWrapper = style({
|
export const headerTitleSelectionIconWrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const headerSideContainer = style({
|
|||||||
|
|
||||||
export const windowAppControlsWrapper = style({
|
export const windowAppControlsWrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexShrink: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const windowAppControl = style({
|
export const windowAppControl = style({
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { openSettingModalAtom } from '@affine/core/atoms';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
|
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||||
import {
|
import {
|
||||||
ExplorerCollections,
|
ExplorerCollections,
|
||||||
ExplorerFavorites,
|
ExplorerFavorites,
|
||||||
@@ -7,17 +9,19 @@ import {
|
|||||||
ExplorerOrganize,
|
ExplorerOrganize,
|
||||||
} from '@affine/core/modules/explorer';
|
} from '@affine/core/modules/explorer';
|
||||||
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
|
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
|
||||||
|
import { CMDKQuickSearchService } from '@affine/core/modules/quicksearch/services/cmdk';
|
||||||
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
||||||
|
import { pathGenerator } from '@affine/core/shared';
|
||||||
import { mixpanel } from '@affine/core/utils';
|
import { mixpanel } from '@affine/core/utils';
|
||||||
import { apis, events } from '@affine/electron-api';
|
import { apis, events } from '@affine/electron-api';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc';
|
import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc';
|
||||||
import type { Doc } from '@blocksuite/store';
|
import type { Doc } from '@blocksuite/store';
|
||||||
import type { Workspace } from '@toeverything/infra';
|
import type { Workspace } from '@toeverything/infra';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { memo, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||||
import { WorkbenchService } from '../../modules/workbench';
|
import { WorkbenchService } from '../../modules/workbench';
|
||||||
@@ -33,6 +37,7 @@ import {
|
|||||||
SidebarContainer,
|
SidebarContainer,
|
||||||
SidebarScrollableContainer,
|
SidebarScrollableContainer,
|
||||||
} from '../app-sidebar';
|
} from '../app-sidebar';
|
||||||
|
import { usePageHelper } from '../blocksuite/block-suite-page-list/utils';
|
||||||
import { WorkspaceSelector } from '../workspace-selector';
|
import { WorkspaceSelector } from '../workspace-selector';
|
||||||
import ImportPage from './import-page';
|
import ImportPage from './import-page';
|
||||||
import {
|
import {
|
||||||
@@ -64,18 +69,11 @@ export type RootAppSidebarProps = {
|
|||||||
* This is for the whole affine app sidebar.
|
* This is for the whole affine app sidebar.
|
||||||
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
||||||
*
|
*
|
||||||
* @todo(himself65): rewrite all styled component into @vanilla-extract/css
|
|
||||||
*/
|
*/
|
||||||
export const RootAppSidebar = memo(
|
export const RootAppSidebar = (): ReactElement => {
|
||||||
({
|
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||||
currentWorkspace,
|
|
||||||
openPage,
|
|
||||||
createPage,
|
|
||||||
paths,
|
|
||||||
onOpenQuickSearchModal,
|
|
||||||
onOpenSettingModal,
|
|
||||||
}: RootAppSidebarProps): ReactElement => {
|
|
||||||
const currentWorkspaceId = currentWorkspace.id;
|
const currentWorkspaceId = currentWorkspace.id;
|
||||||
|
const { openPage } = useNavigateHelper();
|
||||||
const { appSettings } = useAppSettingHelper();
|
const { appSettings } = useAppSettingHelper();
|
||||||
const docCollection = currentWorkspace.docCollection;
|
const docCollection = currentWorkspace.docCollection;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -84,15 +82,28 @@ export const RootAppSidebar = memo(
|
|||||||
location => location.pathname
|
location => location.pathname
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
||||||
|
const onOpenQuickSearchModal = useCallback(() => {
|
||||||
|
cmdkQuickSearchService.toggle();
|
||||||
|
mixpanel.track('QuickSearchOpened', {
|
||||||
|
segment: 'navigation panel',
|
||||||
|
control: 'search button',
|
||||||
|
});
|
||||||
|
}, [cmdkQuickSearchService]);
|
||||||
|
|
||||||
const telemetry = useService(TelemetryWorkspaceContextService);
|
const telemetry = useService(TelemetryWorkspaceContextService);
|
||||||
|
|
||||||
const allPageActive = currentPath === '/all';
|
const allPageActive = currentPath === '/all';
|
||||||
|
|
||||||
|
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
||||||
|
const createPage = useCallback(() => {
|
||||||
|
return pageHelper.createPage();
|
||||||
|
}, [pageHelper]);
|
||||||
|
|
||||||
const onClickNewPage = useAsyncCallback(async () => {
|
const onClickNewPage = useAsyncCallback(async () => {
|
||||||
const page = createPage();
|
const page = createPage();
|
||||||
page.load();
|
page.load();
|
||||||
openPage(page.id);
|
openPage(currentWorkspaceId, page.id);
|
||||||
mixpanel.track('DocCreated', {
|
mixpanel.track('DocCreated', {
|
||||||
page: telemetry.getPageContext(),
|
page: telemetry.getPageContext(),
|
||||||
segment: 'navigation panel',
|
segment: 'navigation panel',
|
||||||
@@ -101,7 +112,7 @@ export const RootAppSidebar = memo(
|
|||||||
category: 'page',
|
category: 'page',
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
});
|
});
|
||||||
}, [createPage, openPage, telemetry]);
|
}, [createPage, currentWorkspaceId, openPage, telemetry]);
|
||||||
|
|
||||||
// Listen to the "New Page" action from the menu
|
// Listen to the "New Page" action from the menu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,6 +122,21 @@ export const RootAppSidebar = memo(
|
|||||||
return;
|
return;
|
||||||
}, [onClickNewPage]);
|
}, [onClickNewPage]);
|
||||||
|
|
||||||
|
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||||
|
|
||||||
|
const onOpenSettingModal = useCallback(() => {
|
||||||
|
setOpenSettingModalAtom({
|
||||||
|
activeTab: 'appearance',
|
||||||
|
open: true,
|
||||||
|
});
|
||||||
|
mixpanel.track('SettingsViewed', {
|
||||||
|
// page:
|
||||||
|
segment: 'navigation panel',
|
||||||
|
module: 'general list',
|
||||||
|
control: 'settings button',
|
||||||
|
});
|
||||||
|
}, [setOpenSettingModalAtom]);
|
||||||
|
|
||||||
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (environment.isDesktop) {
|
if (environment.isDesktop) {
|
||||||
@@ -143,7 +169,7 @@ export const RootAppSidebar = memo(
|
|||||||
<MenuLinkItem
|
<MenuLinkItem
|
||||||
icon={<FolderIcon />}
|
icon={<FolderIcon />}
|
||||||
active={allPageActive}
|
active={allPageActive}
|
||||||
to={paths.all(currentWorkspaceId)}
|
to={pathGenerator.all(currentWorkspaceId)}
|
||||||
>
|
>
|
||||||
<span data-testid="all-pages">
|
<span data-testid="all-pages">
|
||||||
{t['com.affine.workspaceSubPath.all']()}
|
{t['com.affine.workspaceSubPath.all']()}
|
||||||
@@ -184,7 +210,6 @@ export const RootAppSidebar = memo(
|
|||||||
</SidebarContainer>
|
</SidebarContainer>
|
||||||
</AppSidebar>
|
</AppSidebar>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
|
||||||
RootAppSidebar.displayName = 'memo(RootAppSidebar)';
|
RootAppSidebar.displayName = 'memo(RootAppSidebar)';
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ export const appStyle = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
display: 'flex',
|
|
||||||
flexGrow: '1',
|
flexGrow: '1',
|
||||||
flexDirection: 'row',
|
display: 'flex',
|
||||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-is-resizing="true"]': {
|
'&[data-is-resizing="true"]': {
|
||||||
@@ -42,11 +41,11 @@ globalStyle(`html[data-theme="dark"] ${appStyle}`, {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mainContainerStyle = style({
|
export const mainContainerStyle = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
// it will create stacking context to limit layer of child elements and be lower than after auto zIndex
|
width: '100%',
|
||||||
width: 0,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'clip',
|
overflow: 'clip',
|
||||||
@@ -71,14 +70,16 @@ export const mainContainerStyle = style({
|
|||||||
'&[data-client-border="true"][data-side-bar-open="true"]': {
|
'&[data-client-border="true"][data-side-bar-open="true"]': {
|
||||||
marginLeft: 0,
|
marginLeft: 0,
|
||||||
},
|
},
|
||||||
'&[data-client-border="true"]:before': {
|
'&[data-client-border="true"][data-is-desktop="true"]': {
|
||||||
content: '""',
|
marginTop: 0,
|
||||||
position: 'absolute',
|
},
|
||||||
height: '8px',
|
'&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]':
|
||||||
width: '100%',
|
{
|
||||||
top: '-8px',
|
borderTopLeftRadius: 6,
|
||||||
left: 0,
|
},
|
||||||
['WebkitAppRegion' as string]: 'drag',
|
'&[data-client-border="false"][data-is-desktop="true"]': {
|
||||||
|
borderTop: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
|
borderLeft: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
},
|
},
|
||||||
'&[data-transparent=true]': {
|
'&[data-transparent=true]': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
import {
|
import {
|
||||||
DocsService,
|
DocsService,
|
||||||
GlobalContextService,
|
GlobalContextService,
|
||||||
@@ -26,45 +27,44 @@ export const AppContainer = ({
|
|||||||
...rest
|
...rest
|
||||||
}: WorkspaceRootProps) => {
|
}: WorkspaceRootProps) => {
|
||||||
const noisyBackground = useNoisyBackground && environment.isDesktop;
|
const noisyBackground = useNoisyBackground && environment.isDesktop;
|
||||||
|
const blurBackground = environment.isDesktop && useBlurBackground;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
className={clsx(appStyle, {
|
className={clsx(appStyle, {
|
||||||
'noisy-background': noisyBackground,
|
'noisy-background': noisyBackground,
|
||||||
'blur-background': environment.isDesktop && useBlurBackground,
|
'blur-background': blurBackground,
|
||||||
})}
|
})}
|
||||||
data-noise-background={noisyBackground}
|
data-noise-background={noisyBackground}
|
||||||
data-is-resizing={resizing}
|
data-is-resizing={resizing}
|
||||||
|
data-blur-background={blurBackground}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MainContainerProps extends HTMLAttributes<HTMLDivElement> {
|
export interface MainContainerProps extends HTMLAttributes<HTMLDivElement> {}
|
||||||
clientBorder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MainContainer = forwardRef<
|
export const MainContainer = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
PropsWithChildren<MainContainerProps>
|
PropsWithChildren<MainContainerProps>
|
||||||
>(function MainContainer(
|
>(function MainContainer({ className, children, ...props }, ref): ReactElement {
|
||||||
{ className, children, clientBorder, ...props },
|
|
||||||
ref
|
|
||||||
): ReactElement {
|
|
||||||
const appSideBarOpen = useAtomValue(appSidebarOpenAtom);
|
const appSideBarOpen = useAtomValue(appSidebarOpenAtom);
|
||||||
|
const { appSettings } = useAppSettingHelper();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx(mainContainerStyle, className)}
|
className={clsx(mainContainerStyle, className)}
|
||||||
data-is-macos={environment.isDesktop && environment.isMacOs}
|
data-is-desktop={environment.isDesktop}
|
||||||
data-transparent={false}
|
data-transparent={false}
|
||||||
data-client-border={clientBorder}
|
data-client-border={appSettings.clientBorder}
|
||||||
data-side-bar-open={appSideBarOpen}
|
data-side-bar-open={appSideBarOpen}
|
||||||
data-testid="main-container"
|
data-testid="main-container"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{children}
|
<div className={mainContainerStyle}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ export enum RouteLogic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function defaultNavigate(to: To, option?: { replace?: boolean }) {
|
function defaultNavigate(to: To, option?: { replace?: boolean }) {
|
||||||
console.log(to, option);
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.navigate(to, option).catch(err => {
|
router?.navigate(to, option).catch(err => {
|
||||||
console.error('Failed to navigate', err);
|
console.error('Failed to navigate', err);
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|||||||
45
packages/frontend/core/src/layouts/styles.css.ts
Normal file
45
packages/frontend/core/src/layouts/styles.css.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const browserAppViewContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexFlow: 'row',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const desktopAppViewContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexFlow: 'column',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const desktopAppViewMain = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexFlow: 'row',
|
||||||
|
width: '100%',
|
||||||
|
height: 'calc(100% - 52px)',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const desktopTabsHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexFlow: 'row',
|
||||||
|
height: '52px',
|
||||||
|
zIndex: 1,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const desktopTabsHeaderTopLeft = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexFlow: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'width 0.3s, padding 0.3s',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginRight: -8, // make room for tab's padding
|
||||||
|
padding: '0 16px',
|
||||||
|
flexShrink: 0,
|
||||||
|
['WebkitAppRegion' as string]: 'drag',
|
||||||
|
});
|
||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
} from '@affine/component/global-loading';
|
} from '@affine/component/global-loading';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { ZipTransformer } from '@blocksuite/blocks';
|
import { ZipTransformer } from '@blocksuite/blocks';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
|
||||||
import {
|
import {
|
||||||
type DocMode,
|
type DocMode,
|
||||||
DocsService,
|
DocsService,
|
||||||
effect,
|
effect,
|
||||||
fromPromise,
|
fromPromise,
|
||||||
|
LiveData,
|
||||||
onStart,
|
onStart,
|
||||||
throwIfAborted,
|
throwIfAborted,
|
||||||
useLiveData,
|
useLiveData,
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
EMPTY,
|
EMPTY,
|
||||||
@@ -30,33 +30,35 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { Map as YMap } from 'yjs';
|
import { Map as YMap } from 'yjs';
|
||||||
|
|
||||||
import { openSettingModalAtom } from '../atoms';
|
|
||||||
import { AIProvider } from '../blocksuite/presets/ai';
|
import { AIProvider } from '../blocksuite/presets/ai';
|
||||||
import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding';
|
import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding';
|
||||||
import { AppContainer } from '../components/affine/app-container';
|
import { AppContainer } from '../components/affine/app-container';
|
||||||
import { SyncAwareness } from '../components/affine/awareness';
|
import { SyncAwareness } from '../components/affine/awareness';
|
||||||
import { appSidebarResizingAtom } from '../components/app-sidebar';
|
import {
|
||||||
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
|
appSidebarFloatingAtom,
|
||||||
|
appSidebarOpenAtom,
|
||||||
|
appSidebarResizingAtom,
|
||||||
|
SidebarSwitch,
|
||||||
|
} from '../components/app-sidebar';
|
||||||
|
import { appSidebarWidthAtom } from '../components/app-sidebar/index.jotai';
|
||||||
import { AIIsland } from '../components/pure/ai-island';
|
import { AIIsland } from '../components/pure/ai-island';
|
||||||
import { RootAppSidebar } from '../components/root-app-sidebar';
|
import { RootAppSidebar } from '../components/root-app-sidebar';
|
||||||
import { MainContainer } from '../components/workspace';
|
import { MainContainer } from '../components/workspace';
|
||||||
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
|
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
|
||||||
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
|
|
||||||
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
|
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
|
||||||
import { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify';
|
import { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify';
|
||||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
|
||||||
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
|
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
|
||||||
|
import { AppTabsHeader } from '../modules/app-tabs-header';
|
||||||
|
import { NavigationButtons } from '../modules/navigation';
|
||||||
import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands';
|
import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands';
|
||||||
import { QuickSearchContainer } from '../modules/quicksearch';
|
import { QuickSearchContainer } from '../modules/quicksearch';
|
||||||
import { CMDKQuickSearchService } from '../modules/quicksearch/services/cmdk';
|
|
||||||
import { WorkbenchService } from '../modules/workbench';
|
import { WorkbenchService } from '../modules/workbench';
|
||||||
import {
|
import {
|
||||||
AllWorkspaceModals,
|
AllWorkspaceModals,
|
||||||
CurrentWorkspaceModals,
|
CurrentWorkspaceModals,
|
||||||
} from '../providers/modal-provider';
|
} from '../providers/modal-provider';
|
||||||
import { SWRConfigProvider } from '../providers/swr-config-provider';
|
import { SWRConfigProvider } from '../providers/swr-config-provider';
|
||||||
import { pathGenerator } from '../shared';
|
import * as styles from './styles.css';
|
||||||
import { mixpanel } from '../utils';
|
|
||||||
|
|
||||||
export const WorkspaceLayout = function WorkspaceLayout({
|
export const WorkspaceLayout = function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
@@ -74,26 +76,14 @@ export const WorkspaceLayout = function WorkspaceLayout({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const pushGlobalLoadingEvent = useSetAtom(pushGlobalLoadingEventAtom);
|
const pushGlobalLoadingEvent = useSetAtom(pushGlobalLoadingEventAtom);
|
||||||
const resolveGlobalLoadingEvent = useSetAtom(resolveGlobalLoadingEventAtom);
|
const resolveGlobalLoadingEvent = useSetAtom(resolveGlobalLoadingEventAtom);
|
||||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||||
const docsList = useService(DocsService).list;
|
const docsList = useService(DocsService).list;
|
||||||
const { openPage } = useNavigateHelper();
|
|
||||||
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
|
||||||
|
|
||||||
const upgrading = useLiveData(currentWorkspace.upgrade.upgrading$);
|
|
||||||
const needUpgrade = useLiveData(currentWorkspace.upgrade.needUpgrade$);
|
|
||||||
|
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
|
||||||
const basename = useLiveData(workbench.basename$);
|
|
||||||
|
|
||||||
const currentPath = useLiveData(
|
|
||||||
workbench.location$.map(location => basename + location.pathname)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const insertTemplate = effect(
|
const insertTemplate = effect(
|
||||||
switchMap(({ template, mode }: { template: string; mode: string }) => {
|
switchMap(({ template, mode }: { template: string; mode: string }) => {
|
||||||
@@ -180,64 +170,89 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
|||||||
}
|
}
|
||||||
}, [currentWorkspace.docCollection.doc]);
|
}, [currentWorkspace.docCollection.doc]);
|
||||||
|
|
||||||
const handleCreatePage = useCallback(() => {
|
|
||||||
return pageHelper.createPage();
|
|
||||||
}, [pageHelper]);
|
|
||||||
|
|
||||||
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
|
||||||
const handleOpenQuickSearchModal = useCallback(() => {
|
|
||||||
cmdkQuickSearchService.toggle();
|
|
||||||
mixpanel.track('QuickSearchOpened', {
|
|
||||||
segment: 'navigation panel',
|
|
||||||
control: 'search button',
|
|
||||||
});
|
|
||||||
}, [cmdkQuickSearchService]);
|
|
||||||
|
|
||||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
|
||||||
|
|
||||||
const handleOpenSettingModal = useCallback(() => {
|
|
||||||
setOpenSettingModalAtom({
|
|
||||||
activeTab: 'appearance',
|
|
||||||
open: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
mixpanel.track('SettingsViewed', {
|
|
||||||
// page:
|
|
||||||
segment: 'navigation panel',
|
|
||||||
module: 'general list',
|
|
||||||
control: 'settings button',
|
|
||||||
});
|
|
||||||
}, [setOpenSettingModalAtom]);
|
|
||||||
|
|
||||||
const resizing = useAtomValue(appSidebarResizingAtom);
|
|
||||||
|
|
||||||
const { appSettings } = useAppSettingHelper();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppContainer data-current-path={currentPath} resizing={resizing}>
|
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
|
||||||
<RootAppSidebar
|
{children}
|
||||||
isPublicWorkspace={false}
|
|
||||||
onOpenQuickSearchModal={handleOpenQuickSearchModal}
|
|
||||||
onOpenSettingModal={handleOpenSettingModal}
|
|
||||||
currentWorkspace={currentWorkspace}
|
|
||||||
openPage={useCallback(
|
|
||||||
(pageId: string) => {
|
|
||||||
assertExists(currentWorkspace);
|
|
||||||
return openPage(currentWorkspace.id, pageId);
|
|
||||||
},
|
|
||||||
[currentWorkspace, openPage]
|
|
||||||
)}
|
|
||||||
createPage={handleCreatePage}
|
|
||||||
paths={pathGenerator}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MainContainer clientBorder={appSettings.clientBorder}>
|
|
||||||
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
|
|
||||||
</MainContainer>
|
|
||||||
</AppContainer>
|
|
||||||
<QuickSearchContainer />
|
<QuickSearchContainer />
|
||||||
<SyncAwareness />
|
<SyncAwareness />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DesktopLayout = ({ children }: PropsWithChildren) => {
|
||||||
|
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||||
|
const sidebarWidth = useAtomValue(appSidebarWidthAtom);
|
||||||
|
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||||
|
const sidebarFloating = useAtomValue(appSidebarFloatingAtom);
|
||||||
|
const sidebarResizing = useAtomValue(appSidebarResizingAtom);
|
||||||
|
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.desktopAppViewContainer}>
|
||||||
|
<div className={styles.desktopTabsHeader}>
|
||||||
|
<div
|
||||||
|
className={styles.desktopTabsHeaderTopLeft}
|
||||||
|
style={{
|
||||||
|
transition: sidebarResizing ? 'none' : undefined,
|
||||||
|
paddingLeft:
|
||||||
|
isMacosDesktop && sidebarOpen && !sidebarFloating ? 90 : 16,
|
||||||
|
width: sidebarOpen && !sidebarFloating ? sidebarWidth : 130,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarSwitch show />
|
||||||
|
<NavigationButtons />
|
||||||
|
</div>
|
||||||
|
<AppTabsHeader reportBoundingUpdate={!resizing} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.desktopAppViewMain}>
|
||||||
|
<RootAppSidebar />
|
||||||
|
<MainContainer>{children}</MainContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrowserLayout = ({ children }: PropsWithChildren) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.browserAppViewContainer}>
|
||||||
|
<RootAppSidebar />
|
||||||
|
<MainContainer>{children}</MainContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the workspace layout main router view
|
||||||
|
*/
|
||||||
|
const WorkspaceLayoutUIContainer = ({ children }: PropsWithChildren) => {
|
||||||
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
const currentPath = useLiveData(
|
||||||
|
LiveData.computed(get => {
|
||||||
|
return get(workbench.basename$) + get(workbench.location$).pathname;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||||
|
const LayoutComponent = environment.isDesktop ? DesktopLayout : BrowserLayout;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer data-current-path={currentPath} resizing={resizing}>
|
||||||
|
<LayoutComponent>{children}</LayoutComponent>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
||||||
|
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||||
|
|
||||||
|
const upgrading = useLiveData(currentWorkspace.upgrade.upgrading$);
|
||||||
|
const needUpgrade = useLiveData(currentWorkspace.upgrade.needUpgrade$);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceLayoutProviders>
|
||||||
|
<WorkspaceLayoutUIContainer>
|
||||||
|
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
|
||||||
|
</WorkspaceLayoutUIContainer>
|
||||||
|
</WorkspaceLayoutProviders>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { AppTabsHeaderService } from './services/app-tabs-header-service';
|
||||||
|
|
||||||
|
export { AppTabsHeader } from './views/app-tabs-header';
|
||||||
|
|
||||||
|
export function configureAppTabsHeaderModule(framework: Framework) {
|
||||||
|
framework.service(AppTabsHeaderService);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { apis, events } from '@affine/electron-api';
|
||||||
|
import { LiveData, Service } from '@toeverything/infra';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export type TabStatus = Parameters<
|
||||||
|
Parameters<NonNullable<typeof events>['ui']['onTabsStatusChange']>[0]
|
||||||
|
>[0][number];
|
||||||
|
|
||||||
|
export class AppTabsHeaderService extends Service {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabsStatus$ = LiveData.from<TabStatus[]>(
|
||||||
|
new Observable(subscriber => {
|
||||||
|
let unsub: (() => void) | undefined;
|
||||||
|
apis?.ui
|
||||||
|
.getTabsStatus()
|
||||||
|
.then(tabs => {
|
||||||
|
subscriber.next(tabs);
|
||||||
|
unsub = events?.ui.onTabsStatusChange(tabs => {
|
||||||
|
subscriber.next(tabs);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsub?.();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
showContextMenu = async (workbenchId: string, viewIdx: number) => {
|
||||||
|
await apis?.ui.showTabContextMenu(workbenchId, viewIdx);
|
||||||
|
};
|
||||||
|
|
||||||
|
activateView = async (workbenchId: string, viewIdx: number) => {
|
||||||
|
await apis?.ui.activateView(workbenchId, viewIdx);
|
||||||
|
};
|
||||||
|
|
||||||
|
closeTab = async (workbenchId: string) => {
|
||||||
|
await apis?.ui.closeTab(workbenchId);
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddTab = async () => {
|
||||||
|
await apis?.ui.addTab();
|
||||||
|
};
|
||||||
|
|
||||||
|
onToggleRightSidebar = async () => {
|
||||||
|
await apis?.ui.toggleRightSidebar();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { IconButton, Loading, observeResize } from '@affine/component';
|
||||||
|
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||||
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
|
import { DesktopStateSynchronizer } from '@affine/core/modules/workbench/services/desktop-state-synchronizer';
|
||||||
|
import type { WorkbenchMeta } from '@affine/electron-api';
|
||||||
|
import { apis } from '@affine/electron-api';
|
||||||
|
import {
|
||||||
|
CloseIcon,
|
||||||
|
DeleteIcon,
|
||||||
|
EdgelessIcon,
|
||||||
|
FolderIcon,
|
||||||
|
PageIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RightSidebarIcon,
|
||||||
|
TagIcon,
|
||||||
|
TodayIcon,
|
||||||
|
ViewLayersIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import {
|
||||||
|
useLiveData,
|
||||||
|
useService,
|
||||||
|
useServiceOptional,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { debounce, partition } from 'lodash-es';
|
||||||
|
import {
|
||||||
|
Fragment,
|
||||||
|
type MouseEventHandler,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppTabsHeaderService,
|
||||||
|
type TabStatus,
|
||||||
|
} from '../services/app-tabs-header-service';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
type ModuleName = NonNullable<WorkbenchMeta['views'][0]['moduleName']>;
|
||||||
|
|
||||||
|
const moduleNameToIcon = {
|
||||||
|
all: <FolderIcon />,
|
||||||
|
collection: <ViewLayersIcon />,
|
||||||
|
doc: <PageIcon />,
|
||||||
|
page: <PageIcon />,
|
||||||
|
edgeless: <EdgelessIcon />,
|
||||||
|
journal: <TodayIcon />,
|
||||||
|
tag: <TagIcon />,
|
||||||
|
trash: <DeleteIcon />,
|
||||||
|
} satisfies Record<ModuleName, ReactNode>;
|
||||||
|
|
||||||
|
const WorkbenchTab = ({
|
||||||
|
workbench,
|
||||||
|
active: tabActive,
|
||||||
|
tabsLength,
|
||||||
|
}: {
|
||||||
|
workbench: TabStatus;
|
||||||
|
active: boolean;
|
||||||
|
tabsLength: number;
|
||||||
|
}) => {
|
||||||
|
const tabsHeaderService = useService(AppTabsHeaderService);
|
||||||
|
const activeViewIndex = workbench.activeViewIndex ?? 0;
|
||||||
|
const onContextMenu = useAsyncCallback(
|
||||||
|
async (viewIdx: number) => {
|
||||||
|
await tabsHeaderService.showContextMenu(workbench.id, viewIdx);
|
||||||
|
},
|
||||||
|
[tabsHeaderService, workbench.id]
|
||||||
|
);
|
||||||
|
const onActivateView = useAsyncCallback(
|
||||||
|
async (viewIdx: number) => {
|
||||||
|
await tabsHeaderService.activateView(workbench.id, viewIdx);
|
||||||
|
},
|
||||||
|
[tabsHeaderService, workbench.id]
|
||||||
|
);
|
||||||
|
const onCloseTab: MouseEventHandler = useAsyncCallback(
|
||||||
|
async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
await tabsHeaderService.closeTab(workbench.id);
|
||||||
|
},
|
||||||
|
[tabsHeaderService, workbench.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={workbench.id}
|
||||||
|
data-active={tabActive}
|
||||||
|
data-pinned={workbench.pinned}
|
||||||
|
className={styles.tab}
|
||||||
|
>
|
||||||
|
{workbench.views.map((view, viewIdx) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={view.id}>
|
||||||
|
<button
|
||||||
|
key={view.id}
|
||||||
|
className={styles.splitViewLabel}
|
||||||
|
data-active={activeViewIndex === viewIdx && tabActive}
|
||||||
|
onContextMenu={() => {
|
||||||
|
onContextMenu(viewIdx);
|
||||||
|
}}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onActivateView(viewIdx);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.labelIcon}>
|
||||||
|
{workbench.ready || !workbench.loaded ? (
|
||||||
|
moduleNameToIcon[view.moduleName ?? 'all']
|
||||||
|
) : (
|
||||||
|
<Loading />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{workbench.pinned || !view.title ? null : (
|
||||||
|
<div title={view.title} className={styles.splitViewLabelText}>
|
||||||
|
{view.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{viewIdx !== workbench.views.length - 1 ? (
|
||||||
|
<div className={styles.splitViewSeparator} />
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!workbench.pinned && tabsLength > 1 ? (
|
||||||
|
<div className={styles.tabCloseButtonWrapper}>
|
||||||
|
<button className={styles.tabCloseButton} onClick={onCloseTab}>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppTabsHeader = ({
|
||||||
|
style,
|
||||||
|
reportBoundingUpdate,
|
||||||
|
}: {
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
reportBoundingUpdate?: boolean;
|
||||||
|
}) => {
|
||||||
|
const tabsHeaderService = useService(AppTabsHeaderService);
|
||||||
|
const tabs = useLiveData(tabsHeaderService.tabsStatus$);
|
||||||
|
|
||||||
|
const [pinned, unpinned] = partition(tabs, tab => tab.pinned);
|
||||||
|
|
||||||
|
const onAddTab = useAsyncCallback(async () => {
|
||||||
|
await tabsHeaderService.onAddTab();
|
||||||
|
}, [tabsHeaderService]);
|
||||||
|
|
||||||
|
const onToggleRightSidebar = useAsyncCallback(async () => {
|
||||||
|
await tabsHeaderService.onToggleRightSidebar();
|
||||||
|
}, [tabsHeaderService]);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useServiceOptional(DesktopStateSynchronizer);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current && reportBoundingUpdate) {
|
||||||
|
return observeResize(
|
||||||
|
ref.current,
|
||||||
|
debounce(() => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (!rect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toInt = (value: number) => Math.round(value);
|
||||||
|
const boundRect = {
|
||||||
|
height: toInt(rect.height),
|
||||||
|
width: toInt(rect.width),
|
||||||
|
x: toInt(rect.x),
|
||||||
|
y: toInt(rect.y),
|
||||||
|
};
|
||||||
|
apis?.ui.updateTabsBoundingRect(boundRect).catch(console.error);
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [reportBoundingUpdate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.root}
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
data-is-windows={environment.isDesktop && environment.isWindows}
|
||||||
|
>
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
{pinned.map(tab => {
|
||||||
|
return (
|
||||||
|
<WorkbenchTab
|
||||||
|
tabsLength={pinned.length}
|
||||||
|
key={tab.id}
|
||||||
|
workbench={tab}
|
||||||
|
active={tab.active}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pinned.length > 0 && unpinned.length > 0 && (
|
||||||
|
<div className={styles.pinSeparator} />
|
||||||
|
)}
|
||||||
|
{unpinned.map(workbench => {
|
||||||
|
return (
|
||||||
|
<WorkbenchTab
|
||||||
|
tabsLength={tabs.length}
|
||||||
|
key={workbench.id}
|
||||||
|
workbench={workbench}
|
||||||
|
active={workbench.active}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<IconButton onClick={onAddTab}>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className={styles.spacer} />
|
||||||
|
<IconButton size="large" onClick={onToggleRightSidebar}>
|
||||||
|
<RightSidebarIcon />
|
||||||
|
</IconButton>
|
||||||
|
{environment.isDesktop && environment.isWindows ? (
|
||||||
|
<WindowsAppControls />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '52px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: '8px',
|
||||||
|
overflow: 'clip',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
['WebkitAppRegion' as string]: 'drag',
|
||||||
|
selectors: {
|
||||||
|
'&[data-is-windows="false"]': {
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabs = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 8px',
|
||||||
|
gap: '8px',
|
||||||
|
overflow: 'clip',
|
||||||
|
height: '100%',
|
||||||
|
selectors: {
|
||||||
|
'&[data-pinned="true"]': {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pinSeparator = style({
|
||||||
|
background: cssVar('iconSecondary'),
|
||||||
|
width: 1,
|
||||||
|
height: 16,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const splitViewSeparator = style({
|
||||||
|
background: cssVar('borderColor'),
|
||||||
|
width: 1,
|
||||||
|
height: '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tab = style({
|
||||||
|
height: 32,
|
||||||
|
minWidth: 32,
|
||||||
|
overflow: 'clip',
|
||||||
|
background: cssVar('backgroundSecondaryColor'),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
userSelect: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
position: 'relative',
|
||||||
|
['WebkitAppRegion' as string]: 'no-drag',
|
||||||
|
selectors: {
|
||||||
|
'&[data-active="true"]': {
|
||||||
|
background: cssVar('backgroundPrimaryColor'),
|
||||||
|
boxShadow: cssVar('shadow1'),
|
||||||
|
},
|
||||||
|
'&[data-pinned="false"]': {
|
||||||
|
paddingRight: 20,
|
||||||
|
},
|
||||||
|
'&[data-pinned="true"]': {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const splitViewLabel = style({
|
||||||
|
minWidth: 32,
|
||||||
|
padding: '0 8px',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
fontWeight: 500,
|
||||||
|
alignItems: 'center',
|
||||||
|
maxWidth: 180,
|
||||||
|
cursor: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const splitViewLabelText = style({
|
||||||
|
minWidth: 0,
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'clip',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
selectors: {
|
||||||
|
[`${splitViewLabel}:hover &`]: {
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
},
|
||||||
|
[`${splitViewLabel}[data-active="true"] &`]: {
|
||||||
|
color: cssVar('primaryColor'),
|
||||||
|
},
|
||||||
|
[`${splitViewLabel}:last-of-type &`]: {
|
||||||
|
textOverflow: 'clip',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabIcon = style({
|
||||||
|
color: cssVar('iconSecondary'),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const labelIcon = style([
|
||||||
|
tabIcon,
|
||||||
|
{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
flexShrink: 0,
|
||||||
|
selectors: {
|
||||||
|
[`${tab}[data-active=true] &`]: {
|
||||||
|
color: cssVar('primaryColor'),
|
||||||
|
},
|
||||||
|
[`${splitViewLabel}[data-active=false]:hover &`]: {
|
||||||
|
color: cssVar('iconColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const tabCloseButtonWrapper = style({
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 16,
|
||||||
|
overflow: 'clip',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingRight: 4,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
selectors: {
|
||||||
|
[`${tab}:is([data-active=true], :hover) &`]: {
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
[`${tab}[data-active=true] &`]: {
|
||||||
|
background: `linear-gradient(270deg, ${cssVar('backgroundPrimaryColor')} 52.86%, rgba(255, 255, 255, 0.00) 100%)`,
|
||||||
|
},
|
||||||
|
[`${tab}[data-active=false] &`]: {
|
||||||
|
background: `linear-gradient(270deg, ${cssVar('backgroundSecondaryColor')} 65.71%, rgba(244, 244, 245, 0.00) 100%)`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabCloseButton = style([
|
||||||
|
tabIcon,
|
||||||
|
{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
width: 16,
|
||||||
|
height: '100%',
|
||||||
|
display: 'none',
|
||||||
|
color: cssVar('iconColor'),
|
||||||
|
selectors: {
|
||||||
|
[`${tab}:is([data-active=true], :hover) &`]: {
|
||||||
|
display: 'flex',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const spacer = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
});
|
||||||
@@ -16,14 +16,12 @@ import { configureQuickSearchModule } from './quicksearch';
|
|||||||
import { configureShareDocsModule } from './share-doc';
|
import { configureShareDocsModule } from './share-doc';
|
||||||
import { configureTagModule } from './tag';
|
import { configureTagModule } from './tag';
|
||||||
import { configureTelemetryModule } from './telemetry';
|
import { configureTelemetryModule } from './telemetry';
|
||||||
import { configureWorkbenchModule } from './workbench';
|
|
||||||
|
|
||||||
export function configureCommonModules(framework: Framework) {
|
export function configureCommonModules(framework: Framework) {
|
||||||
configureInfraModules(framework);
|
configureInfraModules(framework);
|
||||||
configureCollectionModule(framework);
|
configureCollectionModule(framework);
|
||||||
configureNavigationModule(framework);
|
configureNavigationModule(framework);
|
||||||
configureTagModule(framework);
|
configureTagModule(framework);
|
||||||
configureWorkbenchModule(framework);
|
|
||||||
configureWorkspacePropertiesModule(framework);
|
configureWorkspacePropertiesModule(framework);
|
||||||
configureCloudModule(framework);
|
configureCloudModule(framework);
|
||||||
configureQuotaModule(framework);
|
configureQuotaModule(framework);
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ export const resolveRouteLinkMeta = (href: string) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// http://xxx/workspace/all/yyy
|
const hash = url.hash;
|
||||||
// to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j', blockId: 'xxxx' }
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// http://---/workspace/{workspaceid}/xxx/yyy
|
||||||
|
// http://---/workspace/{workspaceid}/xxx
|
||||||
const [_, workspaceId, moduleName, subModuleName] =
|
const [_, workspaceId, moduleName, subModuleName] =
|
||||||
url.toString().match(/\/workspace\/([^/]+)\/([^#]+)(?:#(.+))?/) || [];
|
pathname.match(/\/workspace\/([^/]+)\/([^/]+)(?:\/([^/]+))?/) || [];
|
||||||
|
|
||||||
if (isRouteModulePath(moduleName)) {
|
if (isRouteModulePath(moduleName)) {
|
||||||
return {
|
return {
|
||||||
@@ -36,7 +38,7 @@ export const resolveRouteLinkMeta = (href: string) => {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
moduleName: 'doc' as const,
|
moduleName: 'doc' as const,
|
||||||
docId: moduleName,
|
docId: moduleName,
|
||||||
blockId: subModuleName,
|
blockId: hash.slice(1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -48,7 +50,8 @@ export const resolveRouteLinkMeta = (href: string) => {
|
|||||||
/**
|
/**
|
||||||
* @see /packages/frontend/core/src/router.tsx
|
* @see /packages/frontend/core/src/router.tsx
|
||||||
*/
|
*/
|
||||||
const routeModulePaths = ['all', 'collection', 'tag', 'trash'] as const;
|
export const routeModulePaths = ['all', 'collection', 'tag', 'trash'] as const;
|
||||||
|
export type RouteModulePath = (typeof routeModulePaths)[number];
|
||||||
|
|
||||||
const isRouteModulePath = (
|
const isRouteModulePath = (
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ export class View extends Entity<{
|
|||||||
scope = this.framework.createScope(ViewScope, {
|
scope = this.framework.createScope(ViewScope, {
|
||||||
view: this as View,
|
view: this as View,
|
||||||
});
|
});
|
||||||
id = this.props.id;
|
|
||||||
|
get id() {
|
||||||
|
return this.props.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
set id(id: string) {
|
||||||
|
this.props.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
sidebarTabs$ = new LiveData<SidebarTab[]>([]);
|
sidebarTabs$ = new LiveData<SidebarTab[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Entity, LiveData } from '@toeverything/infra';
|
|||||||
import type { To } from 'history';
|
import type { To } from 'history';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import type { WorkbenchDefaultState } from '../services/workbench-view-state';
|
||||||
import { View } from './view';
|
import { View } from './view';
|
||||||
|
|
||||||
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
|
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
|
||||||
@@ -13,17 +14,27 @@ interface WorkbenchOpenOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Workbench extends Entity {
|
export class Workbench extends Entity {
|
||||||
readonly views$ = new LiveData([
|
constructor(private readonly defaultState: WorkbenchDefaultState) {
|
||||||
this.framework.createEntity(View, { id: nanoid() }),
|
super();
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
readonly activeViewIndex$ = new LiveData(this.defaultState.activeViewIndex);
|
||||||
|
readonly basename$ = new LiveData(this.defaultState.basename);
|
||||||
|
|
||||||
|
readonly views$: LiveData<View[]> = new LiveData(
|
||||||
|
this.defaultState.views.map(meta => {
|
||||||
|
return this.framework.createEntity(View, {
|
||||||
|
id: meta.id,
|
||||||
|
defaultLocation: meta.path,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
activeViewIndex$ = new LiveData(0);
|
|
||||||
activeView$ = LiveData.computed(get => {
|
activeView$ = LiveData.computed(get => {
|
||||||
const activeIndex = get(this.activeViewIndex$);
|
const activeIndex = get(this.activeViewIndex$);
|
||||||
const views = get(this.views$);
|
const views = get(this.views$);
|
||||||
return views[activeIndex];
|
return views[activeIndex]; // todo: this could be null
|
||||||
});
|
});
|
||||||
basename$ = new LiveData('/');
|
|
||||||
location$ = LiveData.computed(get => {
|
location$ = LiveData.computed(get => {
|
||||||
return get(get(this.activeView$).location$);
|
return get(get(this.activeView$).location$);
|
||||||
});
|
});
|
||||||
@@ -34,6 +45,10 @@ export class Workbench extends Entity {
|
|||||||
this.activeViewIndex$.next(index);
|
this.activeViewIndex$.next(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBasename(basename: string) {
|
||||||
|
this.basename$.next(basename);
|
||||||
|
}
|
||||||
|
|
||||||
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
|
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
|
||||||
const view = this.framework.createEntity(View, {
|
const view = this.framework.createEntity(View, {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
|
|||||||
@@ -6,22 +6,55 @@ export { ViewBody, ViewHeader, ViewSidebarTab } from './view/view-islands';
|
|||||||
export { WorkbenchLink } from './view/workbench-link';
|
export { WorkbenchLink } from './view/workbench-link';
|
||||||
export { WorkbenchRoot } from './view/workbench-root';
|
export { WorkbenchRoot } from './view/workbench-root';
|
||||||
|
|
||||||
import { type Framework, WorkspaceScope } from '@toeverything/infra';
|
import {
|
||||||
|
DocsService,
|
||||||
|
type Framework,
|
||||||
|
GlobalStateService,
|
||||||
|
WorkspaceScope,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { WorkspacePropertiesAdapter } from '../properties';
|
||||||
import { SidebarTab } from './entities/sidebar-tab';
|
import { SidebarTab } from './entities/sidebar-tab';
|
||||||
import { View } from './entities/view';
|
import { View } from './entities/view';
|
||||||
import { Workbench } from './entities/workbench';
|
import { Workbench } from './entities/workbench';
|
||||||
import { ViewScope } from './scopes/view';
|
import { ViewScope } from './scopes/view';
|
||||||
|
import { DesktopStateSynchronizer } from './services/desktop-state-synchronizer';
|
||||||
import { ViewService } from './services/view';
|
import { ViewService } from './services/view';
|
||||||
import { WorkbenchService } from './services/workbench';
|
import { WorkbenchService } from './services/workbench';
|
||||||
|
import {
|
||||||
|
DesktopWorkbenchDefaultState,
|
||||||
|
InMemoryWorkbenchDefaultState,
|
||||||
|
WorkbenchDefaultState,
|
||||||
|
} from './services/workbench-view-state';
|
||||||
|
|
||||||
export function configureWorkbenchModule(services: Framework) {
|
export function configureWorkbenchCommonModule(services: Framework) {
|
||||||
services
|
services
|
||||||
.scope(WorkspaceScope)
|
.scope(WorkspaceScope)
|
||||||
.service(WorkbenchService)
|
.service(WorkbenchService)
|
||||||
.entity(Workbench)
|
.entity(Workbench, [WorkbenchDefaultState])
|
||||||
.entity(View)
|
.entity(View)
|
||||||
.scope(ViewScope)
|
.scope(ViewScope)
|
||||||
.service(ViewService, [ViewScope])
|
.service(ViewService, [ViewScope])
|
||||||
.entity(SidebarTab);
|
.entity(SidebarTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function configureBrowserWorkbenchModule(services: Framework) {
|
||||||
|
configureWorkbenchCommonModule(services);
|
||||||
|
services
|
||||||
|
.scope(WorkspaceScope)
|
||||||
|
.impl(WorkbenchDefaultState, InMemoryWorkbenchDefaultState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureDesktopWorkbenchModule(services: Framework) {
|
||||||
|
configureWorkbenchCommonModule(services);
|
||||||
|
services
|
||||||
|
.scope(WorkspaceScope)
|
||||||
|
.impl(WorkbenchDefaultState, DesktopWorkbenchDefaultState, [
|
||||||
|
GlobalStateService,
|
||||||
|
])
|
||||||
|
.service(DesktopStateSynchronizer, [
|
||||||
|
WorkbenchService,
|
||||||
|
WorkspacePropertiesAdapter,
|
||||||
|
DocsService,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
apis,
|
||||||
|
appInfo,
|
||||||
|
events,
|
||||||
|
type WorkbenchViewMeta,
|
||||||
|
} from '@affine/electron-api';
|
||||||
|
import { I18n, type I18nKeys, i18nTime } from '@affine/i18n';
|
||||||
|
import type { DocsService } from '@toeverything/infra';
|
||||||
|
import { Service } from '@toeverything/infra';
|
||||||
|
import { combineLatest, filter, map, of, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
import { resolveRouteLinkMeta } from '../../navigation';
|
||||||
|
import type { RouteModulePath } from '../../navigation/utils';
|
||||||
|
import type { WorkspacePropertiesAdapter } from '../../properties';
|
||||||
|
import type { WorkbenchService } from '../../workbench';
|
||||||
|
|
||||||
|
const routeModuleToI18n = {
|
||||||
|
all: 'All pages',
|
||||||
|
collection: 'Collections',
|
||||||
|
tag: 'Tags',
|
||||||
|
trash: 'Trash',
|
||||||
|
} satisfies Record<RouteModulePath, I18nKeys>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronize workbench state with state stored in main process
|
||||||
|
*/
|
||||||
|
export class DesktopStateSynchronizer extends Service {
|
||||||
|
constructor(
|
||||||
|
private readonly workbenchService: WorkbenchService,
|
||||||
|
private readonly workspaceProperties: WorkspacePropertiesAdapter,
|
||||||
|
private readonly docsService: DocsService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.startSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
startSync = () => {
|
||||||
|
if (!environment.isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbench = this.workbenchService.workbench;
|
||||||
|
|
||||||
|
events?.ui.onTabAction(event => {
|
||||||
|
if (
|
||||||
|
event.type === 'open-in-split-view' &&
|
||||||
|
event.payload.tabId === appInfo?.viewId
|
||||||
|
) {
|
||||||
|
const activeView = workbench.activeView$.value;
|
||||||
|
if (activeView) {
|
||||||
|
workbench.open(activeView.location$.value, {
|
||||||
|
at: 'beside',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === 'separate-view' &&
|
||||||
|
event.payload.tabId === appInfo?.viewId
|
||||||
|
) {
|
||||||
|
const view = workbench.viewAt(event.payload.viewIndex);
|
||||||
|
if (view) {
|
||||||
|
workbench.close(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === 'activate-view' &&
|
||||||
|
event.payload.tabId === appInfo?.viewId
|
||||||
|
) {
|
||||||
|
workbench.active(event.payload.viewIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
events?.ui.onToggleRightSidebar(tabId => {
|
||||||
|
if (tabId === appInfo?.viewId) {
|
||||||
|
workbench.sidebarOpen$.next(!workbench.sidebarOpen$.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// sync workbench state with main process
|
||||||
|
// also fill tab view meta with title & moduleName
|
||||||
|
this.workspaceProperties.workspace.engine.rootDocState$
|
||||||
|
.pipe(
|
||||||
|
filter(v => v.ready),
|
||||||
|
switchMap(() => workbench.views$),
|
||||||
|
switchMap(views => {
|
||||||
|
return combineLatest(
|
||||||
|
views.map(view =>
|
||||||
|
view.location$.map(location => {
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
map(viewLocations => {
|
||||||
|
if (!apis || !appInfo?.viewId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewMetas = viewLocations.map(({ view, location }) => {
|
||||||
|
return {
|
||||||
|
id: view.id,
|
||||||
|
path: location,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return viewMetas.map(viewMeta => this.fillTabViewMeta(viewMeta));
|
||||||
|
}),
|
||||||
|
filter(v => !!v),
|
||||||
|
switchMap(viewMetas => {
|
||||||
|
return this.docsService.list.docs$.pipe(
|
||||||
|
switchMap(docs => {
|
||||||
|
return combineLatest(
|
||||||
|
viewMetas.map(vm => {
|
||||||
|
return (
|
||||||
|
docs
|
||||||
|
.find(doc => doc.id === vm.docId)
|
||||||
|
?.mode$.asObservable() ?? of('page')
|
||||||
|
).pipe(
|
||||||
|
map(mode => ({
|
||||||
|
...vm,
|
||||||
|
moduleName:
|
||||||
|
vm.moduleName === 'page' ? mode : vm.moduleName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(viewMetas => {
|
||||||
|
if (!apis || !appInfo?.viewId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apis.ui
|
||||||
|
.updateWorkbenchMeta(appInfo.viewId, {
|
||||||
|
views: viewMetas,
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
workbench.activeViewIndex$.subscribe(activeViewIndex => {
|
||||||
|
if (!apis || !appInfo?.viewId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apis.ui
|
||||||
|
.updateWorkbenchMeta(appInfo.viewId, {
|
||||||
|
activeViewIndex: activeViewIndex,
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
workbench.basename$.subscribe(basename => {
|
||||||
|
if (!apis || !appInfo?.viewId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apis.ui
|
||||||
|
.updateWorkbenchMeta(appInfo.viewId, {
|
||||||
|
basename: basename,
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private toFullUrl(
|
||||||
|
basename: string,
|
||||||
|
location: { hash?: string; pathname?: string; search?: string }
|
||||||
|
) {
|
||||||
|
return basename + location.pathname + location.search + location.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill tab view meta with title & moduleName
|
||||||
|
private fillTabViewMeta(
|
||||||
|
view: WorkbenchViewMeta
|
||||||
|
): WorkbenchViewMeta & { docId?: string } {
|
||||||
|
if (!view.path) {
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this.toFullUrl(
|
||||||
|
this.workbenchService.workbench.basename$.value,
|
||||||
|
view.path
|
||||||
|
);
|
||||||
|
const linkMeta = resolveRouteLinkMeta(url);
|
||||||
|
|
||||||
|
if (!linkMeta) {
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalString =
|
||||||
|
linkMeta.moduleName === 'doc'
|
||||||
|
? this.workspaceProperties.getJournalPageDateString(linkMeta.docId)
|
||||||
|
: undefined;
|
||||||
|
const isJournal = !!journalString;
|
||||||
|
|
||||||
|
const title = (() => {
|
||||||
|
// todo: resolve more module types like collections?
|
||||||
|
if (linkMeta?.moduleName === 'doc') {
|
||||||
|
if (journalString) {
|
||||||
|
return i18nTime(journalString, { absolute: { accuracy: 'day' } });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.workspaceProperties.workspace.docCollection.meta.getDocMeta(
|
||||||
|
linkMeta.docId
|
||||||
|
)?.title || I18n['Untitled']()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return I18n[routeModuleToI18n[linkMeta.moduleName]]();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...view,
|
||||||
|
title: title,
|
||||||
|
docId: linkMeta.docId,
|
||||||
|
moduleName: isJournal ? 'journal' : linkMeta.moduleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { appInfo, type TabViewsMetaSchema } from '@affine/electron-api';
|
||||||
|
import type { GlobalStateService } from '@toeverything/infra';
|
||||||
|
import { createIdentifier, Service } from '@toeverything/infra';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export type WorkbenchDefaultState = {
|
||||||
|
basename: string;
|
||||||
|
views: {
|
||||||
|
id: string;
|
||||||
|
path?: { pathname?: string; hash?: string; search?: string };
|
||||||
|
}[];
|
||||||
|
activeViewIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkbenchDefaultState = createIdentifier<WorkbenchDefaultState>(
|
||||||
|
'WorkbenchDefaultState'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InMemoryWorkbenchDefaultState: WorkbenchDefaultState = {
|
||||||
|
basename: '/',
|
||||||
|
views: [
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeViewIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DesktopWorkbenchDefaultState
|
||||||
|
extends Service
|
||||||
|
implements WorkbenchDefaultState
|
||||||
|
{
|
||||||
|
constructor(private readonly globalStateService: GlobalStateService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get value() {
|
||||||
|
const tabViewsMeta =
|
||||||
|
this.globalStateService.globalState.get<TabViewsMetaSchema>(
|
||||||
|
'tabViewsMetaSchema'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
tabViewsMeta?.workbenches.find(w => w.id === appInfo?.viewId) ||
|
||||||
|
InMemoryWorkbenchDefaultState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get basename() {
|
||||||
|
return this.value.basename;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeViewIndex() {
|
||||||
|
return this.value.activeViewIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
get views() {
|
||||||
|
return this.value.views;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ export function useBindWorkbenchToDesktopRouter(
|
|||||||
basename: string
|
basename: string
|
||||||
) {
|
) {
|
||||||
const browserLocation = useLocation();
|
const browserLocation = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newLocation = browserLocationToViewLocation(
|
const newLocation = browserLocationToViewLocation(
|
||||||
browserLocation,
|
browserLocation,
|
||||||
@@ -37,7 +36,6 @@ export function useBindWorkbenchToDesktopRouter(
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
workbench.open(newLocation);
|
workbench.open(newLocation);
|
||||||
}, [basename, browserLocation, workbench]);
|
}, [basename, browserLocation, workbench]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const header = style({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: cssVar('backgroundPrimaryColor'),
|
background: cssVar('backgroundPrimaryColor'),
|
||||||
padding: '0 16px',
|
padding: '0 16px',
|
||||||
['WebkitAppRegion' as string]: 'drag',
|
|
||||||
'@media': {
|
'@media': {
|
||||||
print: {
|
print: {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
@@ -62,10 +61,3 @@ export const viewHeaderContainer = style({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minWidth: 12,
|
minWidth: 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const windowsAppControlsContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
height: '100%',
|
|
||||||
marginRight: '-16px',
|
|
||||||
paddingLeft: '16px',
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IconButton } from '@affine/component';
|
import { IconButton } from '@affine/component';
|
||||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
|
||||||
import { RightSidebarIcon } from '@blocksuite/icons/rc';
|
import { RightSidebarIcon } from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
@@ -50,12 +49,11 @@ export const RouteContainer = ({ route }: Props) => {
|
|||||||
const handleToggleSidebar = useCallback(() => {
|
const handleToggleSidebar = useCallback(() => {
|
||||||
workbench.toggleSidebar();
|
workbench.toggleSidebar();
|
||||||
}, [workbench]);
|
}, [workbench]);
|
||||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{viewPosition.isFirst && (
|
{viewPosition.isFirst && !environment.isDesktop && (
|
||||||
<SidebarSwitch
|
<SidebarSwitch
|
||||||
show={!leftSidebarOpen}
|
show={!leftSidebarOpen}
|
||||||
className={styles.leftSidebarButton}
|
className={styles.leftSidebarButton}
|
||||||
@@ -65,19 +63,12 @@ export const RouteContainer = ({ route }: Props) => {
|
|||||||
viewId={view.id}
|
viewId={view.id}
|
||||||
className={styles.viewHeaderContainer}
|
className={styles.viewHeaderContainer}
|
||||||
/>
|
/>
|
||||||
{viewPosition.isLast && (
|
{viewPosition.isLast && !environment.isDesktop && (
|
||||||
<>
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
show={!sidebarOpen}
|
show={!sidebarOpen}
|
||||||
className={styles.rightSidebarButton}
|
className={styles.rightSidebarButton}
|
||||||
onToggle={handleToggleSidebar}
|
onToggle={handleToggleSidebar}
|
||||||
/>
|
/>
|
||||||
{isWindowsDesktop && !sidebarOpen && (
|
|
||||||
<div className={styles.windowsAppControlsContainer}>
|
|
||||||
<WindowsAppControls />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ export const sidebarBodyTarget = style({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderTop: `1px solid ${cssVar('borderColor')}`,
|
});
|
||||||
|
|
||||||
|
export const borderTop = style({
|
||||||
|
borderTop: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sidebarBodyNoSelection = style({
|
export const sidebarBodyNoSelection = style({
|
||||||
|
|||||||
@@ -24,16 +24,11 @@ export const SidebarContainer = ({
|
|||||||
workbench.toggleSidebar();
|
workbench.toggleSidebar();
|
||||||
}, [workbench]);
|
}, [workbench]);
|
||||||
|
|
||||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.sidebarContainerInner, className)} {...props}>
|
<div className={clsx(styles.sidebarContainerInner, className)} {...props}>
|
||||||
<Header floating={false} onToggle={handleToggleOpen}>
|
<Header floating={false} onToggle={handleToggleOpen}>
|
||||||
{!isWindowsDesktop && sidebarTabs.length > 0 && (
|
|
||||||
<SidebarHeaderSwitcher />
|
<SidebarHeaderSwitcher />
|
||||||
)}
|
|
||||||
</Header>
|
</Header>
|
||||||
{isWindowsDesktop && sidebarTabs.length > 0 && <SidebarHeaderSwitcher />}
|
|
||||||
{sidebarTabs.length > 0 ? (
|
{sidebarTabs.length > 0 ? (
|
||||||
sidebarTabs.map(sidebar => (
|
sidebarTabs.map(sidebar => (
|
||||||
<ViewSidebarTabBodyTarget
|
<ViewSidebarTabBodyTarget
|
||||||
@@ -41,7 +36,10 @@ export const SidebarContainer = ({
|
|||||||
key={sidebar.id}
|
key={sidebar.id}
|
||||||
style={{ display: activeSidebarTab === sidebar ? 'block' : 'none' }}
|
style={{ display: activeSidebarTab === sidebar ? 'block' : 'none' }}
|
||||||
viewId={view.id}
|
viewId={view.id}
|
||||||
className={styles.sidebarBodyTarget}
|
className={clsx(
|
||||||
|
styles.sidebarBodyTarget,
|
||||||
|
!environment.isDesktop && styles.borderTop
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ export const header = style({
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
background: cssVar('backgroundPrimaryColor'),
|
background: cssVar('backgroundPrimaryColor'),
|
||||||
selectors: {
|
|
||||||
'&[data-sidebar-floating="false"]': {
|
|
||||||
['WebkitAppRegion' as string]: 'drag',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'@media': {
|
'@media': {
|
||||||
print: {
|
print: {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
@@ -36,9 +31,3 @@ export const standaloneExtensionSwitcherWrapper = style({
|
|||||||
height: '52px',
|
height: '52px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const windowsAppControlsContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
height: '100%',
|
|
||||||
marginRight: '-16px',
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IconButton } from '@affine/component';
|
import { IconButton } from '@affine/component';
|
||||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
|
||||||
import { RightSidebarIcon } from '@blocksuite/icons/rc';
|
import { RightSidebarIcon } from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
import * as styles from './sidebar-header.css';
|
import * as styles from './sidebar-header.css';
|
||||||
@@ -41,28 +40,16 @@ const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Windows = ({ floating, onToggle, children }: HeaderProps) => {
|
export const Header = ({ floating, children, onToggle }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<Container className={styles.header} floating={floating}>
|
<Container className={styles.header} floating={floating}>
|
||||||
{children}
|
{children}
|
||||||
|
{!environment.isDesktop && (
|
||||||
|
<>
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
<ToggleButton onToggle={onToggle} />
|
<ToggleButton onToggle={onToggle} />
|
||||||
<div className={styles.windowsAppControlsContainer}>
|
</>
|
||||||
<WindowsAppControls />
|
)}
|
||||||
</div>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NonWindows = ({ floating, children, onToggle }: HeaderProps) => {
|
|
||||||
return (
|
|
||||||
<Container className={styles.header} floating={floating}>
|
|
||||||
{children}
|
|
||||||
<div className={styles.spacer} />
|
|
||||||
<ToggleButton onToggle={onToggle} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Header =
|
|
||||||
environment.isDesktop && environment.isWindows ? Windows : NonWindows;
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { popupWindow } from '@affine/core/utils';
|
import { popupWindow } from '@affine/core/utils';
|
||||||
|
import { apis } from '@affine/electron-api';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import type { To } from 'history';
|
import { parsePath, type To } from 'history';
|
||||||
import { forwardRef, type MouseEvent, useCallback } from 'react';
|
import { forwardRef, type MouseEvent } from 'react';
|
||||||
|
|
||||||
import { WorkbenchService } from '../services/workbench';
|
import { WorkbenchService } from '../services/workbench';
|
||||||
|
|
||||||
@@ -21,8 +23,8 @@ export const WorkbenchLink = forwardRef<
|
|||||||
const link =
|
const link =
|
||||||
basename +
|
basename +
|
||||||
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
||||||
const handleClick = useCallback(
|
const handleClick = useAsyncCallback(
|
||||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (onClick?.(event) === false) {
|
if (onClick?.(event) === false) {
|
||||||
@@ -30,8 +32,16 @@ export const WorkbenchLink = forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
if (appSettings.enableMultiView && environment.isDesktop) {
|
if (environment.isDesktop) {
|
||||||
workbench.open(to, { at: 'beside' });
|
if (event.altKey && appSettings.enableMultiView) {
|
||||||
|
workbench.open(to, { at: 'tail' });
|
||||||
|
} else {
|
||||||
|
const path = typeof to === 'string' ? parsePath(to) : to;
|
||||||
|
await apis?.ui.addTab({
|
||||||
|
basename,
|
||||||
|
view: { path },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (!environment.isDesktop) {
|
} else if (!environment.isDesktop) {
|
||||||
popupWindow(link);
|
popupWindow(link);
|
||||||
}
|
}
|
||||||
@@ -39,7 +49,7 @@ export const WorkbenchLink = forwardRef<
|
|||||||
workbench.open(to);
|
workbench.open(to);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appSettings.enableMultiView, link, onClick, to, workbench]
|
[appSettings.enableMultiView, basename, link, onClick, to, workbench]
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint suspicious runtime error
|
// eslint suspicious runtime error
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const workbenchSidebar = style({
|
|||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
},
|
},
|
||||||
[`&[data-client-border=false]`]: {
|
[`&[data-client-border=false]`]: {
|
||||||
borderLeft: `1px solid ${cssVar('borderColor')}`,
|
borderLeft: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export const WorkbenchRoot = memo(() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
workbench.basename$.next(basename);
|
workbench.updateBasename(basename);
|
||||||
}, [basename, workbench.basename$]);
|
}, [basename, workbench]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ViewIslandRegistryProvider>
|
<ViewIslandRegistryProvider>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { apis } from '@affine/electron-api';
|
import { apis } from '@affine/electron-api';
|
||||||
import type { ByteKV, ByteKVBehavior, DocStorage } from '@toeverything/infra';
|
import type { ByteKV, ByteKVBehavior, DocStorage } from '@toeverything/infra';
|
||||||
import { AsyncLock, MemoryDocEventBus } from '@toeverything/infra';
|
import { AsyncLock } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { BroadcastChannelDocEventBus } from './doc-broadcast-channel';
|
||||||
|
|
||||||
export class SqliteDocStorage implements DocStorage {
|
export class SqliteDocStorage implements DocStorage {
|
||||||
constructor(private readonly workspaceId: string) {}
|
constructor(private readonly workspaceId: string) {}
|
||||||
eventBus = new MemoryDocEventBus();
|
eventBus = new BroadcastChannelDocEventBus(this.workspaceId);
|
||||||
readonly doc = new Doc(this.workspaceId);
|
readonly doc = new Doc(this.workspaceId);
|
||||||
readonly syncMetadata = new SyncMetadataKV(this.workspaceId);
|
readonly syncMetadata = new SyncMetadataKV(this.workspaceId);
|
||||||
readonly serverClock = new ServerClockKV(this.workspaceId);
|
readonly serverClock = new ServerClockKV(this.workspaceId);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCallback, useState } from 'react';
|
|||||||
|
|
||||||
import { SignOutModal } from '../components/affine/sign-out-modal';
|
import { SignOutModal } from '../components/affine/sign-out-modal';
|
||||||
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
|
import { AppTabsHeader } from '../modules/app-tabs-header';
|
||||||
import { AuthService } from '../modules/cloud';
|
import { AuthService } from '../modules/cloud';
|
||||||
import { SignIn } from './sign-in';
|
import { SignIn } from './sign-in';
|
||||||
|
|
||||||
@@ -38,6 +39,14 @@ export const PageNotFound = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{environment.isDesktop ? (
|
||||||
|
<AppTabsHeader
|
||||||
|
style={{
|
||||||
|
paddingLeft: environment.isMacOs ? 80 : 0,
|
||||||
|
}}
|
||||||
|
reportBoundingUpdate
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{noPermission ? (
|
{noPermission ? (
|
||||||
<NoPermissionOrNotFound
|
<NoPermissionOrNotFound
|
||||||
user={account}
|
user={account}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import type { LoaderFunction } from 'react-router-dom';
|
|||||||
import { redirect, useParams, useSearchParams } from 'react-router-dom';
|
import { redirect, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { WindowsAppControls } from '../components/pure/header/windows-app-controls';
|
|
||||||
import { useMutation } from '../hooks/use-mutation';
|
import { useMutation } from '../hooks/use-mutation';
|
||||||
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
import { AuthService, ServerConfigService } from '../modules/cloud';
|
import { AuthService, ServerConfigService } from '../modules/cloud';
|
||||||
@@ -106,13 +105,7 @@ export const AuthPage = (): ReactElement | null => {
|
|||||||
|
|
||||||
switch (authType) {
|
switch (authType) {
|
||||||
case 'onboarding':
|
case 'onboarding':
|
||||||
return (
|
return <OnboardingPage user={account} onOpenAffine={onOpenAffine} />;
|
||||||
<OnboardingPage
|
|
||||||
user={account}
|
|
||||||
onOpenAffine={onOpenAffine}
|
|
||||||
windowControl={<WindowsAppControls />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'signUp': {
|
case 'signUp': {
|
||||||
return (
|
return (
|
||||||
<SignUpPage
|
<SignUpPage
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { AppFallback } from '../components/affine/app-container';
|
import { AppFallback } from '../components/affine/app-container';
|
||||||
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
|
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
|
||||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
|
import { AppTabsHeader } from '../modules/app-tabs-header';
|
||||||
import { AuthService } from '../modules/cloud';
|
import { AuthService } from '../modules/cloud';
|
||||||
import { WorkspaceSubPath } from '../shared';
|
import { WorkspaceSubPath } from '../shared';
|
||||||
|
|
||||||
@@ -147,6 +148,14 @@ export const Component = () => {
|
|||||||
// TODO(@eyhn): We need a no workspace page
|
// TODO(@eyhn): We need a no workspace page
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{environment.isDesktop ? (
|
||||||
|
<AppTabsHeader
|
||||||
|
style={{
|
||||||
|
paddingLeft: environment.isMacOs ? 80 : 0,
|
||||||
|
}}
|
||||||
|
reportBoundingUpdate
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ export const mainContainer = style({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderTop: `1px solid ${cssVar('borderColor')}`,
|
borderTop: `0.5px solid transparent`,
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
selectors: {
|
||||||
|
'&[data-dynamic-top-border="false"]': {
|
||||||
|
borderColor: cssVar('borderColor'),
|
||||||
|
},
|
||||||
|
'&[data-has-scroll-top="true"]': {
|
||||||
|
borderColor: cssVar('borderColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const editorContainer = style({
|
export const editorContainer = style({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Scrollable } from '@affine/component';
|
import { Scrollable, useHasScrollTop } from '@affine/component';
|
||||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
||||||
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
@@ -212,18 +212,26 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
view.activeSidebarTab('outline');
|
view.activeSidebarTab('outline');
|
||||||
}, [workbench, view]);
|
}, [workbench, view]);
|
||||||
|
|
||||||
|
const [refCallback, hasScrollTop] = useHasScrollTop();
|
||||||
|
const dynamicTopBorder = environment.isDesktop;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewHeader>
|
<ViewHeader>
|
||||||
<DetailPageHeader page={doc.blockSuiteDoc} workspace={workspace} />
|
<DetailPageHeader page={doc.blockSuiteDoc} workspace={workspace} />
|
||||||
</ViewHeader>
|
</ViewHeader>
|
||||||
<ViewBody>
|
<ViewBody>
|
||||||
<div className={styles.mainContainer}>
|
<div
|
||||||
|
className={styles.mainContainer}
|
||||||
|
data-dynamic-top-border={dynamicTopBorder}
|
||||||
|
data-has-scroll-top={hasScrollTop}
|
||||||
|
>
|
||||||
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
|
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
|
||||||
<AffineErrorBoundary key={doc.id}>
|
<AffineErrorBoundary key={doc.id}>
|
||||||
<TopTip pageId={doc.id} workspace={workspace} />
|
<TopTip pageId={doc.id} workspace={workspace} />
|
||||||
<Scrollable.Root>
|
<Scrollable.Root>
|
||||||
<Scrollable.Viewport
|
<Scrollable.Viewport
|
||||||
|
ref={refCallback}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'affine-page-viewport',
|
'affine-page-viewport',
|
||||||
styles.affineDocViewport,
|
styles.affineDocViewport,
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export const useDetailPageHeaderResponsive = (availableWidth: number) => {
|
|||||||
const hideToday = availableWidth < 300;
|
const hideToday = availableWidth < 300;
|
||||||
|
|
||||||
const showDivider =
|
const showDivider =
|
||||||
viewPosition.isLast && !rightSidebarOpen && !(hidePresent && hideShare);
|
viewPosition.isLast &&
|
||||||
|
!rightSidebarOpen &&
|
||||||
|
!(hidePresent && hideShare) &&
|
||||||
|
!environment.isDesktop;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hideShare,
|
hideShare,
|
||||||
|
|||||||
@@ -45,3 +45,9 @@ export const sharedStorage = (globalThis as any).sharedStorage as
|
|||||||
| null;
|
| null;
|
||||||
|
|
||||||
export type { UpdateMeta } from '@affine/electron/main/updater/event';
|
export type { UpdateMeta } from '@affine/electron/main/updater/event';
|
||||||
|
export {
|
||||||
|
type TabViewsMetaSchema,
|
||||||
|
type WorkbenchMeta,
|
||||||
|
type WorkbenchViewMeta,
|
||||||
|
type WorkbenchViewModule,
|
||||||
|
} from '@affine/electron/main/windows-manager/tab-views-meta-schema';
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import '@affine/component/theme/theme.css';
|
|||||||
import { NotificationCenter } from '@affine/component';
|
import { NotificationCenter } from '@affine/component';
|
||||||
import { AffineContext } from '@affine/component/context';
|
import { AffineContext } from '@affine/component/context';
|
||||||
import { GlobalLoading } from '@affine/component/global-loading';
|
import { GlobalLoading } from '@affine/component/global-loading';
|
||||||
|
import { registerAffineCommand } from '@affine/core/commands';
|
||||||
import { AppFallback } from '@affine/core/components/affine/app-container';
|
import { AppFallback } from '@affine/core/components/affine/app-container';
|
||||||
import { configureCommonModules } from '@affine/core/modules';
|
import { configureCommonModules } from '@affine/core/modules';
|
||||||
|
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||||
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
|
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
|
||||||
|
import { configureDesktopWorkbenchModule } from '@affine/core/modules/workbench';
|
||||||
import {
|
import {
|
||||||
configureBrowserWorkspaceFlavours,
|
configureBrowserWorkspaceFlavours,
|
||||||
configureSqliteWorkspaceEngineStorageProvider,
|
configureSqliteWorkspaceEngineStorageProvider,
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
import { Telemetry } from '@affine/core/telemetry';
|
import { Telemetry } from '@affine/core/telemetry';
|
||||||
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
|
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
|
||||||
import { createI18n, setUpLanguage } from '@affine/i18n';
|
import { createI18n, setUpLanguage } from '@affine/i18n';
|
||||||
|
import { SettingsIcon } from '@blocksuite/icons/rc';
|
||||||
import { CacheProvider } from '@emotion/react';
|
import { CacheProvider } from '@emotion/react';
|
||||||
import {
|
import {
|
||||||
Framework,
|
Framework,
|
||||||
@@ -85,6 +89,8 @@ configureCommonModules(framework);
|
|||||||
configureElectronStateStorageImpls(framework);
|
configureElectronStateStorageImpls(framework);
|
||||||
configureBrowserWorkspaceFlavours(framework);
|
configureBrowserWorkspaceFlavours(framework);
|
||||||
configureSqliteWorkspaceEngineStorageProvider(framework);
|
configureSqliteWorkspaceEngineStorageProvider(framework);
|
||||||
|
configureDesktopWorkbenchModule(framework);
|
||||||
|
configureAppTabsHeaderModule(framework);
|
||||||
const frameworkProvider = framework.provider();
|
const frameworkProvider = framework.provider();
|
||||||
|
|
||||||
// setup application lifecycle events, and emit application start event
|
// setup application lifecycle events, and emit application start event
|
||||||
@@ -121,3 +127,14 @@ export function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerAffineCommand({
|
||||||
|
id: 'affine:reload',
|
||||||
|
category: 'affine:general',
|
||||||
|
label: 'Reload current tab',
|
||||||
|
icon: <SettingsIcon />,
|
||||||
|
keyBinding: '$mod+R',
|
||||||
|
run() {
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import '@affine/core/bootstrap/preload';
|
|||||||
|
|
||||||
import { appConfigProxy } from '@affine/core/hooks/use-app-config-storage';
|
import { appConfigProxy } from '@affine/core/hooks/use-app-config-storage';
|
||||||
import { performanceLogger } from '@affine/core/shared';
|
import { performanceLogger } from '@affine/core/shared';
|
||||||
import { apis, events } from '@affine/electron-api';
|
import { apis, appInfo, events } from '@affine/electron-api';
|
||||||
import {
|
import {
|
||||||
init,
|
init,
|
||||||
reactRouterV6BrowserTracingIntegration,
|
reactRouterV6BrowserTracingIntegration,
|
||||||
@@ -74,6 +74,23 @@ function main() {
|
|||||||
events?.ui.onMaximized(handleMaximized);
|
events?.ui.onMaximized(handleMaximized);
|
||||||
events?.ui.onFullScreen(handleFullscreen);
|
events?.ui.onFullScreen(handleFullscreen);
|
||||||
|
|
||||||
|
const tabId = appInfo?.viewId;
|
||||||
|
const handleActiveTabChange = (active: boolean) => {
|
||||||
|
document.documentElement.dataset.active = String(active);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tabId) {
|
||||||
|
apis?.ui
|
||||||
|
.isActiveTab()
|
||||||
|
.then(active => {
|
||||||
|
handleActiveTabChange(active);
|
||||||
|
events?.ui.onActiveTabChanged(id => {
|
||||||
|
handleActiveTabChange(id === tabId);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
const handleResize = debounce(() => {
|
const handleResize = debounce(() => {
|
||||||
apis?.ui.handleWindowResize().catch(console.error);
|
apis?.ui.handleWindowResize().catch(console.error);
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|||||||
56
packages/frontend/electron/renderer/shell/index.tsx
Normal file
56
packages/frontend/electron/renderer/shell/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import 'setimmediate';
|
||||||
|
import '@affine/component/theme/global.css';
|
||||||
|
import '@affine/component/theme/theme.css';
|
||||||
|
|
||||||
|
import { ThemeProvider } from '@affine/component/theme-provider';
|
||||||
|
import { appConfigProxy } from '@affine/core/hooks/use-app-config-storage';
|
||||||
|
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||||
|
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
|
||||||
|
import { performanceLogger } from '@affine/core/shared';
|
||||||
|
import {
|
||||||
|
configureGlobalStorageModule,
|
||||||
|
Framework,
|
||||||
|
FrameworkRoot,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { ShellRoot } from './shell';
|
||||||
|
|
||||||
|
const framework = new Framework();
|
||||||
|
configureGlobalStorageModule(framework);
|
||||||
|
configureElectronStateStorageImpls(framework);
|
||||||
|
configureAppTabsHeaderModule(framework);
|
||||||
|
const frameworkProvider = framework.provider();
|
||||||
|
|
||||||
|
const logger = performanceLogger.namespace('shell');
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
appConfigProxy
|
||||||
|
.getSync()
|
||||||
|
.catch(() => console.error('failed to load app config'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountApp() {
|
||||||
|
const root = document.getElementById('app');
|
||||||
|
if (!root) {
|
||||||
|
throw new Error('Root element not found');
|
||||||
|
}
|
||||||
|
logger.info('render app');
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<FrameworkRoot framework={frameworkProvider}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<ShellRoot />
|
||||||
|
</ThemeProvider>
|
||||||
|
</FrameworkRoot>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
mountApp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to bootstrap app', err);
|
||||||
|
}
|
||||||
22
packages/frontend/electron/renderer/shell/shell.css.ts
Normal file
22
packages/frontend/electron/renderer/shell/shell.css.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'opacity 0.1s',
|
||||||
|
background: cssVar('backgroundPrimaryColor'),
|
||||||
|
selectors: {
|
||||||
|
'&[data-active="false"]': {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
'&[data-translucent="true"]': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${root}[data-active="false"] *`, {
|
||||||
|
['WebkitAppRegion' as string]: 'no-drag !important',
|
||||||
|
});
|
||||||
86
packages/frontend/electron/renderer/shell/shell.tsx
Normal file
86
packages/frontend/electron/renderer/shell/shell.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
|
import { AppTabsHeader } from '@affine/core/modules/app-tabs-header';
|
||||||
|
import { apis, events } from '@affine/electron-api';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './shell.css';
|
||||||
|
|
||||||
|
const useIsShellActive = () => {
|
||||||
|
const [active, setActive] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = events?.ui.onTabShellViewActiveChange(active => {
|
||||||
|
setActive(active);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsub?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return active;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useTabsBoundingRect = () => {
|
||||||
|
const [rect, setRect] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>({
|
||||||
|
x: environment.isDesktop && environment.isMacOs ? 80 : 0,
|
||||||
|
y: 0,
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: 52,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsub: (() => void) | undefined;
|
||||||
|
apis?.ui
|
||||||
|
.getTabsBoundingRect()
|
||||||
|
.then(rect => {
|
||||||
|
if (rect) {
|
||||||
|
setRect(rect);
|
||||||
|
}
|
||||||
|
unsub = events?.ui.onTabsBoundingRectChanged(rect => {
|
||||||
|
if (rect) {
|
||||||
|
setRect(rect);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsub?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return rect;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShellRoot() {
|
||||||
|
const active = useIsShellActive();
|
||||||
|
const { appSettings } = useAppSettingHelper();
|
||||||
|
const rect = useTabsBoundingRect();
|
||||||
|
const translucent =
|
||||||
|
environment.isDesktop &&
|
||||||
|
environment.isMacOs &&
|
||||||
|
appSettings.enableBlurBackground;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.root}
|
||||||
|
data-translucent={translucent}
|
||||||
|
data-active={active}
|
||||||
|
>
|
||||||
|
<AppTabsHeader
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: rect.y,
|
||||||
|
left: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { app, Menu } from 'electron';
|
import { app, Menu } from 'electron';
|
||||||
|
|
||||||
import { isMacOS } from '../../shared/utils';
|
import { isMacOS } from '../../shared/utils';
|
||||||
import { revealLogFile } from '../logger';
|
import { logger, revealLogFile } from '../logger';
|
||||||
import { initAndShowMainWindow, showMainWindow } from '../main-window';
|
|
||||||
import { checkForUpdates } from '../updater';
|
import { checkForUpdates } from '../updater';
|
||||||
|
import {
|
||||||
|
addTab,
|
||||||
|
closeTab,
|
||||||
|
initAndShowMainWindow,
|
||||||
|
showDevTools,
|
||||||
|
showMainWindow,
|
||||||
|
undoCloseTab,
|
||||||
|
} from '../windows-manager';
|
||||||
import { applicationMenuSubjects } from './subject';
|
import { applicationMenuSubjects } from './subject';
|
||||||
|
|
||||||
// Unique id for menuitems
|
// Unique id for menuitems
|
||||||
@@ -55,8 +62,6 @@ export function createApplicationMenu() {
|
|||||||
applicationMenuSubjects.newPageAction$.next();
|
applicationMenuSubjects.newPageAction$.next();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
|
||||||
isMac ? { role: 'close' } : { role: 'quit' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// { role: 'editMenu' }
|
// { role: 'editMenu' }
|
||||||
@@ -89,34 +94,44 @@ export function createApplicationMenu() {
|
|||||||
submenu: [
|
submenu: [
|
||||||
{ role: 'reload' },
|
{ role: 'reload' },
|
||||||
{ role: 'forceReload' },
|
{ role: 'forceReload' },
|
||||||
{ role: 'toggleDevTools' },
|
{
|
||||||
|
label: 'Open devtools',
|
||||||
|
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
|
||||||
|
click: () => {
|
||||||
|
showDevTools();
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'resetZoom' },
|
{ role: 'resetZoom' },
|
||||||
{ role: 'zoomIn' },
|
{ role: 'zoomIn' },
|
||||||
{ role: 'zoomOut' },
|
{ role: 'zoomOut' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'togglefullscreen' },
|
{ role: 'togglefullscreen' },
|
||||||
],
|
|
||||||
},
|
|
||||||
// { role: 'windowMenu' }
|
|
||||||
{
|
|
||||||
label: 'Window',
|
|
||||||
submenu: [
|
|
||||||
{ role: 'minimize' },
|
|
||||||
{ role: 'zoom' },
|
|
||||||
...(isMac
|
|
||||||
? [
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'front' },
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
role: 'window',
|
label: 'New tab',
|
||||||
click: async () => {
|
accelerator: 'CommandOrControl+T',
|
||||||
await initAndShowMainWindow();
|
click() {
|
||||||
|
logger.info('New tab with shortcut');
|
||||||
|
addTab().catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Close tab',
|
||||||
|
accelerator: 'CommandOrControl+W',
|
||||||
|
click() {
|
||||||
|
logger.info('Close tab with shortcut');
|
||||||
|
closeTab().catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Undo close tab',
|
||||||
|
accelerator: 'CommandOrControl+Shift+T',
|
||||||
|
click() {
|
||||||
|
logger.info('Undo close tab with shortcut');
|
||||||
|
undoCloseTab().catch(console.error);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
|
||||||
: [{ role: 'close' }]),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
|
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
|
||||||
|
export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`;
|
||||||
|
export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getMainWindow,
|
getMainWindow,
|
||||||
handleOpenUrlInHiddenWindow,
|
handleOpenUrlInHiddenWindow,
|
||||||
setCookie,
|
setCookie,
|
||||||
} from './main-window';
|
} from './windows-manager';
|
||||||
|
|
||||||
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
|
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow, WebContentsView } from 'electron';
|
||||||
|
|
||||||
import { applicationMenuEvents } from './application-menu';
|
import { applicationMenuEvents } from './application-menu';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
@@ -21,9 +21,9 @@ export function registerEvents() {
|
|||||||
// register events
|
// register events
|
||||||
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
|
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
|
||||||
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
||||||
const subscription = eventRegister((...args: any[]) => {
|
const unsubscribe = eventRegister((...args: any[]) => {
|
||||||
const chan = `${namespace}:${key}`;
|
const chan = `${namespace}:${key}`;
|
||||||
logger.info(
|
logger.debug(
|
||||||
'[ipc-event]',
|
'[ipc-event]',
|
||||||
chan,
|
chan,
|
||||||
args.filter(
|
args.filter(
|
||||||
@@ -33,10 +33,31 @@ export function registerEvents() {
|
|||||||
typeof a !== 'object'
|
typeof a !== 'object'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
getActiveWindows().forEach(win => win.webContents.send(chan, ...args));
|
// is this efficient?
|
||||||
|
getActiveWindows().forEach(win => {
|
||||||
|
if (win.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// .webContents could be undefined if the window is destroyed
|
||||||
|
win.webContents?.send(chan, ...args);
|
||||||
|
win.contentView.children.forEach(child => {
|
||||||
|
if (
|
||||||
|
child instanceof WebContentsView &&
|
||||||
|
child.webContents &&
|
||||||
|
!child.webContents.isDestroyed()
|
||||||
|
) {
|
||||||
|
child.webContents?.send(chan, ...args);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
subscription();
|
// subscription on quit sometimes crashes the app
|
||||||
|
try {
|
||||||
|
unsubscribe();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('unsubscribe error', err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const registerHandlers = () => {
|
|||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
try {
|
try {
|
||||||
const result = await handler(e, ...args);
|
const result = await handler(e, ...args);
|
||||||
logger.info(
|
logger.debug(
|
||||||
'[ipc-api]',
|
'[ipc-api]',
|
||||||
chan,
|
chan,
|
||||||
args.filter(
|
args.filter(
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
import assert from 'node:assert';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { type CookiesSetDetails } from 'electron';
|
|
||||||
import { BrowserWindow, nativeTheme } from 'electron';
|
|
||||||
import electronWindowState from 'electron-window-state';
|
|
||||||
|
|
||||||
import { isLinux, isMacOS, isWindows } from '../shared/utils';
|
|
||||||
import { buildType } from './config';
|
|
||||||
import { mainWindowOrigin } from './constants';
|
|
||||||
import { ensureHelperProcess } from './helper-process';
|
|
||||||
import { logger } from './logger';
|
|
||||||
import { uiSubjects } from './ui/subject';
|
|
||||||
import { parseCookie } from './utils';
|
|
||||||
|
|
||||||
const IS_DEV: boolean =
|
|
||||||
process.env.NODE_ENV === 'development' && !process.env.CI;
|
|
||||||
|
|
||||||
// todo: not all window need all of the exposed meta
|
|
||||||
const getWindowAdditionalArguments = async () => {
|
|
||||||
const { getExposedMeta } = await import('./exposed');
|
|
||||||
const mainExposedMeta = getExposedMeta();
|
|
||||||
const helperProcessManager = await ensureHelperProcess();
|
|
||||||
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
|
|
||||||
return [
|
|
||||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
|
||||||
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
|
|
||||||
`--window-name=main`,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
function closeAllWindows() {
|
|
||||||
BrowserWindow.getAllWindows().forEach(w => {
|
|
||||||
if (!w.isDestroyed()) {
|
|
||||||
w.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWindow(additionalArguments: string[]) {
|
|
||||||
logger.info('create window');
|
|
||||||
const mainWindowState = electronWindowState({
|
|
||||||
defaultWidth: 1000,
|
|
||||||
defaultHeight: 800,
|
|
||||||
});
|
|
||||||
|
|
||||||
const helperProcessManager = await ensureHelperProcess();
|
|
||||||
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
|
|
||||||
|
|
||||||
assert(helperExposedMeta, 'helperExposedMeta should be defined');
|
|
||||||
|
|
||||||
const browserWindow = new BrowserWindow({
|
|
||||||
titleBarStyle: isMacOS()
|
|
||||||
? 'hiddenInset'
|
|
||||||
: isWindows()
|
|
||||||
? 'hidden'
|
|
||||||
: 'default',
|
|
||||||
x: mainWindowState.x,
|
|
||||||
y: mainWindowState.y,
|
|
||||||
width: mainWindowState.width,
|
|
||||||
autoHideMenuBar: isLinux(),
|
|
||||||
minWidth: 640,
|
|
||||||
minHeight: 480,
|
|
||||||
visualEffectState: 'active',
|
|
||||||
vibrancy: 'under-window',
|
|
||||||
// backgroundMaterial: 'mica',
|
|
||||||
height: mainWindowState.height,
|
|
||||||
show: false, // Use 'ready-to-show' event to show window
|
|
||||||
webPreferences: {
|
|
||||||
webgl: true,
|
|
||||||
contextIsolation: true,
|
|
||||||
sandbox: false,
|
|
||||||
webviewTag: false, // The webview tag is not recommended. Consider alternatives like iframe or Electron's BrowserView. https://www.electronjs.org/docs/latest/api/webview-tag#warning
|
|
||||||
spellcheck: false, // FIXME: enable?
|
|
||||||
preload: join(__dirname, './preload.js'),
|
|
||||||
// serialize exposed meta that to be used in preload
|
|
||||||
additionalArguments: additionalArguments,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLinux()) {
|
|
||||||
browserWindow.setIcon(
|
|
||||||
join(__dirname, `../resources/icons/icon_${buildType}_64x64.png`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
nativeTheme.themeSource = 'light';
|
|
||||||
|
|
||||||
mainWindowState.manage(browserWindow);
|
|
||||||
|
|
||||||
let helperConnectionUnsub: (() => void) | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If you install `show: true` then it can cause issues when trying to close the window.
|
|
||||||
* Use `show: false` and listener events `ready-to-show` to fix these issues.
|
|
||||||
*
|
|
||||||
* @see https://github.com/electron/electron/issues/25012
|
|
||||||
*/
|
|
||||||
browserWindow.on('ready-to-show', () => {
|
|
||||||
helperConnectionUnsub?.();
|
|
||||||
helperConnectionUnsub = helperProcessManager.connectRenderer(
|
|
||||||
browserWindow.webContents
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info('main window is ready to show');
|
|
||||||
|
|
||||||
uiSubjects.onMaximized$.next(browserWindow.isMaximized());
|
|
||||||
uiSubjects.onFullScreen$.next(browserWindow.isFullScreen());
|
|
||||||
|
|
||||||
handleWebContentsResize().catch(logger.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
browserWindow.on('close', e => {
|
|
||||||
// TODO(@Peng): gracefully close the app, for example, ask user to save unsaved changes
|
|
||||||
e.preventDefault();
|
|
||||||
if (!isMacOS()) {
|
|
||||||
closeAllWindows();
|
|
||||||
} else {
|
|
||||||
// hide window on macOS
|
|
||||||
// application quit will be handled by closing the hidden window
|
|
||||||
//
|
|
||||||
// explanation:
|
|
||||||
// - closing the top window (by clicking close button or CMD-w)
|
|
||||||
// - will be captured in "close" event here
|
|
||||||
// - hiding the app to make the app open faster when user click the app icon
|
|
||||||
// - quit the app by "cmd+q" or right click on the dock icon and select "quit"
|
|
||||||
// - all browser windows will capture the "close" event
|
|
||||||
// - the hidden window will close all windows
|
|
||||||
// - "window-all-closed" event will be emitted and eventually quit the app
|
|
||||||
if (browserWindow.isFullScreen()) {
|
|
||||||
browserWindow.once('leave-full-screen', () => {
|
|
||||||
browserWindow.hide();
|
|
||||||
});
|
|
||||||
browserWindow.setFullScreen(false);
|
|
||||||
} else {
|
|
||||||
browserWindow.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
helperConnectionUnsub?.();
|
|
||||||
helperConnectionUnsub = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
browserWindow.on('leave-full-screen', () => {
|
|
||||||
// seems call this too soon may cause the app to crash
|
|
||||||
setTimeout(() => {
|
|
||||||
// FIXME: workaround for theme bug in full screen mode
|
|
||||||
const size = browserWindow.getSize();
|
|
||||||
browserWindow.setSize(size[0] + 1, size[1] + 1);
|
|
||||||
browserWindow.setSize(size[0], size[1]);
|
|
||||||
});
|
|
||||||
uiSubjects.onMaximized$.next(false);
|
|
||||||
uiSubjects.onFullScreen$.next(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
browserWindow.on('maximize', () => {
|
|
||||||
uiSubjects.onMaximized$.next(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
browserWindow.on('unmaximize', () => {
|
|
||||||
uiSubjects.onMaximized$.next(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// full-screen == maximized in UI on windows
|
|
||||||
browserWindow.on('enter-full-screen', () => {
|
|
||||||
uiSubjects.onFullScreen$.next(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
browserWindow.on('leave-full-screen', () => {
|
|
||||||
uiSubjects.onFullScreen$.next(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL for main window.
|
|
||||||
*/
|
|
||||||
const pageUrl = mainWindowOrigin; // see protocol.ts
|
|
||||||
|
|
||||||
logger.info('loading page at', pageUrl);
|
|
||||||
|
|
||||||
await browserWindow.loadURL(pageUrl);
|
|
||||||
|
|
||||||
logger.info('main window is loaded at', pageUrl);
|
|
||||||
|
|
||||||
return browserWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// singleton
|
|
||||||
let browserWindow: Promise<BrowserWindow> | undefined;
|
|
||||||
|
|
||||||
// a hidden window that prevents the app from quitting on MacOS
|
|
||||||
let hiddenMacWindow: BrowserWindow | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Init main BrowserWindow. Will create a new window if it's not created yet.
|
|
||||||
*/
|
|
||||||
export async function initAndShowMainWindow() {
|
|
||||||
if (!browserWindow || (await browserWindow.then(w => w.isDestroyed()))) {
|
|
||||||
browserWindow = (async () => {
|
|
||||||
const additionalArguments = await getWindowAdditionalArguments();
|
|
||||||
return createWindow(additionalArguments);
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
const mainWindow = await browserWindow;
|
|
||||||
|
|
||||||
if (IS_DEV) {
|
|
||||||
// do not gain focus in dev mode
|
|
||||||
mainWindow.showInactive();
|
|
||||||
} else {
|
|
||||||
mainWindow.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hiddenMacWindow && isMacOS()) {
|
|
||||||
hiddenMacWindow = new BrowserWindow({
|
|
||||||
show: false,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
hiddenMacWindow.on('close', () => {
|
|
||||||
closeAllWindows();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return mainWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMainWindow() {
|
|
||||||
if (!browserWindow) return;
|
|
||||||
const window = await browserWindow;
|
|
||||||
if (window.isDestroyed()) return;
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function showMainWindow() {
|
|
||||||
const window = await getMainWindow();
|
|
||||||
if (!window) return;
|
|
||||||
if (window.isMinimized()) {
|
|
||||||
window.restore();
|
|
||||||
}
|
|
||||||
window.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleOpenUrlInHiddenWindow(url: string) {
|
|
||||||
const win = new BrowserWindow({
|
|
||||||
width: 1200,
|
|
||||||
height: 600,
|
|
||||||
webPreferences: {
|
|
||||||
preload: join(__dirname, './preload.js'),
|
|
||||||
},
|
|
||||||
show: false,
|
|
||||||
});
|
|
||||||
win.on('close', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!win.isDestroyed()) {
|
|
||||||
win.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info('loading page at', url);
|
|
||||||
await win.loadURL(url);
|
|
||||||
return win;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setCookie(cookie: CookiesSetDetails): Promise<void>;
|
|
||||||
export async function setCookie(origin: string, cookie: string): Promise<void>;
|
|
||||||
|
|
||||||
export async function setCookie(
|
|
||||||
arg0: CookiesSetDetails | string,
|
|
||||||
arg1?: string
|
|
||||||
) {
|
|
||||||
const window = await browserWindow;
|
|
||||||
if (!window) {
|
|
||||||
// do nothing if window is not ready
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const details =
|
|
||||||
typeof arg1 === 'string' && typeof arg0 === 'string'
|
|
||||||
? parseCookie(arg0, arg1)
|
|
||||||
: arg0;
|
|
||||||
|
|
||||||
logger.info('setting cookie to main window', details);
|
|
||||||
|
|
||||||
if (typeof details !== 'object') {
|
|
||||||
throw new Error('invalid cookie details');
|
|
||||||
}
|
|
||||||
|
|
||||||
await window.webContents.session.cookies.set(details);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeCookie(url: string, name: string): Promise<void> {
|
|
||||||
const window = await browserWindow;
|
|
||||||
if (!window) {
|
|
||||||
// do nothing if window is not ready
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await window.webContents.session.cookies.remove(url, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCookie(url?: string, name?: string) {
|
|
||||||
const window = await browserWindow;
|
|
||||||
if (!window) {
|
|
||||||
// do nothing if window is not ready
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cookies = await window.webContents.session.cookies.get({
|
|
||||||
url,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
return cookies;
|
|
||||||
}
|
|
||||||
|
|
||||||
// there is no proper way to listen to webContents resize event
|
|
||||||
// we will rely on window.resize event in renderer instead
|
|
||||||
export async function handleWebContentsResize() {
|
|
||||||
// right now when window is resized, we will relocate the traffic light positions
|
|
||||||
if (isMacOS()) {
|
|
||||||
const window = await getMainWindow();
|
|
||||||
const factor = window?.webContents.getZoomFactor() || 1;
|
|
||||||
window?.setWindowButtonPosition({ x: 20 * factor, y: 24 * factor - 6 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { net, protocol, session } from 'electron';
|
|||||||
|
|
||||||
import { CLOUD_BASE_URL } from './config';
|
import { CLOUD_BASE_URL } from './config';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { getCookie } from './main-window';
|
import { getCookie } from './windows-manager';
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export class PersistentJSONFileStorage implements Memento {
|
|||||||
try {
|
try {
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
this.filepath,
|
this.filepath,
|
||||||
JSON.stringify(this.data),
|
JSON.stringify(this.data, null, 2),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { MainEventRegister } from '../type';
|
import type { MainEventRegister } from '../type';
|
||||||
|
import {
|
||||||
|
onActiveTabChanged,
|
||||||
|
onTabAction,
|
||||||
|
onTabsBoundingRectChanged,
|
||||||
|
onTabShellViewActiveChange,
|
||||||
|
onTabsStatusChange,
|
||||||
|
onTabViewsMetaChanged,
|
||||||
|
} from '../windows-manager';
|
||||||
import { uiSubjects } from './subject';
|
import { uiSubjects } from './subject';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,4 +25,16 @@ export const uiEvents = {
|
|||||||
sub.unsubscribe();
|
sub.unsubscribe();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
onTabViewsMetaChanged,
|
||||||
|
onTabAction,
|
||||||
|
onToggleRightSidebar: (fn: (tabId: string) => void) => {
|
||||||
|
const sub = uiSubjects.onToggleRightSidebar$.subscribe(fn);
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onTabsStatusChange,
|
||||||
|
onActiveTabChanged,
|
||||||
|
onTabShellViewActiveChange,
|
||||||
|
onTabsBoundingRectChanged,
|
||||||
} satisfies Record<string, MainEventRegister>;
|
} satisfies Record<string, MainEventRegister>;
|
||||||
|
|||||||
@@ -4,15 +4,29 @@ import { getLinkPreview } from 'link-preview-js';
|
|||||||
import { isMacOS } from '../../shared/utils';
|
import { isMacOS } from '../../shared/utils';
|
||||||
import { persistentConfig } from '../config-storage/persist';
|
import { persistentConfig } from '../config-storage/persist';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
|
import type { NamespaceHandlers } from '../type';
|
||||||
import {
|
import {
|
||||||
|
activateView,
|
||||||
|
addTab,
|
||||||
|
closeTab,
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
|
getOnboardingWindow,
|
||||||
|
getTabsBoundingRect,
|
||||||
|
getTabsStatus,
|
||||||
|
getTabViewsMeta,
|
||||||
|
getWorkbenchMeta,
|
||||||
handleWebContentsResize,
|
handleWebContentsResize,
|
||||||
initAndShowMainWindow,
|
initAndShowMainWindow,
|
||||||
} from '../main-window';
|
isActiveTab,
|
||||||
import { getOnboardingWindow } from '../onboarding';
|
launchStage,
|
||||||
import type { NamespaceHandlers } from '../type';
|
showDevTools,
|
||||||
import { launchStage } from '../windows-manager/stage';
|
showTab,
|
||||||
|
showTabContextMenu,
|
||||||
|
updateTabsBoundingRect,
|
||||||
|
updateWorkbenchMeta,
|
||||||
|
} from '../windows-manager';
|
||||||
import { getChallengeResponse } from './challenge';
|
import { getChallengeResponse } from './challenge';
|
||||||
|
import { uiSubjects } from './subject';
|
||||||
|
|
||||||
export let isOnline = true;
|
export let isOnline = true;
|
||||||
|
|
||||||
@@ -141,4 +155,57 @@ export const uiHandlers = {
|
|||||||
openExternal(_, url: string) {
|
openExternal(_, url: string) {
|
||||||
return shell.openExternal(url);
|
return shell.openExternal(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// tab handlers
|
||||||
|
isActiveTab: async e => {
|
||||||
|
return isActiveTab(e.sender);
|
||||||
|
},
|
||||||
|
getWorkbenchMeta: async (_, ...args: Parameters<typeof getWorkbenchMeta>) => {
|
||||||
|
return getWorkbenchMeta(...args);
|
||||||
|
},
|
||||||
|
updateWorkbenchMeta: async (
|
||||||
|
_,
|
||||||
|
...args: Parameters<typeof updateWorkbenchMeta>
|
||||||
|
) => {
|
||||||
|
return updateWorkbenchMeta(...args);
|
||||||
|
},
|
||||||
|
getTabViewsMeta: async () => {
|
||||||
|
return getTabViewsMeta();
|
||||||
|
},
|
||||||
|
getTabsStatus: async () => {
|
||||||
|
return getTabsStatus();
|
||||||
|
},
|
||||||
|
addTab: async (_, ...args: Parameters<typeof addTab>) => {
|
||||||
|
await addTab(...args);
|
||||||
|
},
|
||||||
|
showTab: async (_, ...args: Parameters<typeof showTab>) => {
|
||||||
|
await showTab(...args);
|
||||||
|
},
|
||||||
|
closeTab: async (_, ...args: Parameters<typeof closeTab>) => {
|
||||||
|
await closeTab(...args);
|
||||||
|
},
|
||||||
|
activateView: async (_, ...args: Parameters<typeof activateView>) => {
|
||||||
|
await activateView(...args);
|
||||||
|
},
|
||||||
|
toggleRightSidebar: async (_, tabId?: string) => {
|
||||||
|
tabId ??= getTabViewsMeta().activeWorkbenchId;
|
||||||
|
if (tabId) {
|
||||||
|
uiSubjects.onToggleRightSidebar$.next(tabId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getTabsBoundingRect: async () => {
|
||||||
|
return getTabsBoundingRect();
|
||||||
|
},
|
||||||
|
updateTabsBoundingRect: async (
|
||||||
|
e,
|
||||||
|
rect: { x: number; y: number; width: number; height: number }
|
||||||
|
) => {
|
||||||
|
return updateTabsBoundingRect(e.sender, rect);
|
||||||
|
},
|
||||||
|
showDevTools: async (_, ...args: Parameters<typeof showDevTools>) => {
|
||||||
|
return showDevTools(...args);
|
||||||
|
},
|
||||||
|
showTabContextMenu: async (_, tabKey: string, viewIndex: number) => {
|
||||||
|
return showTabContextMenu(tabKey, viewIndex);
|
||||||
|
},
|
||||||
} satisfies NamespaceHandlers;
|
} satisfies NamespaceHandlers;
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ import { Subject } from 'rxjs';
|
|||||||
export const uiSubjects = {
|
export const uiSubjects = {
|
||||||
onMaximized$: new Subject<boolean>(),
|
onMaximized$: new Subject<boolean>(),
|
||||||
onFullScreen$: new Subject<boolean>(),
|
onFullScreen$: new Subject<boolean>(),
|
||||||
|
onToggleRightSidebar$: new Subject<string>(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './launcher';
|
||||||
|
export * from './main-window';
|
||||||
|
export * from './onboarding';
|
||||||
|
export * from './stage';
|
||||||
|
export * from './tab-views';
|
||||||
|
export * from './types';
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import { initAndShowMainWindow } from '../main-window';
|
import { initAndShowMainWindow } from './main-window';
|
||||||
import {
|
import { getOnboardingWindow, getOrCreateOnboardingWindow } from './onboarding';
|
||||||
getOnboardingWindow,
|
|
||||||
getOrCreateOnboardingWindow,
|
|
||||||
} from '../onboarding';
|
|
||||||
import { launchStage } from './stage';
|
import { launchStage } from './stage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { BrowserWindow, nativeTheme } from 'electron';
|
||||||
|
import electronWindowState from 'electron-window-state';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
import { isLinux, isMacOS, isWindows } from '../../shared/utils';
|
||||||
|
import { buildType } from '../config';
|
||||||
|
import { ensureHelperProcess } from '../helper-process';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import { uiSubjects } from '../ui/subject';
|
||||||
|
|
||||||
|
const IS_DEV: boolean =
|
||||||
|
process.env.NODE_ENV === 'development' && !process.env.CI;
|
||||||
|
|
||||||
|
function closeAllWindows() {
|
||||||
|
BrowserWindow.getAllWindows().forEach(w => {
|
||||||
|
if (!w.isDestroyed()) {
|
||||||
|
w.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MainWindowManager {
|
||||||
|
static readonly instance = new MainWindowManager();
|
||||||
|
mainWindowReady: Promise<BrowserWindow> | undefined;
|
||||||
|
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
|
||||||
|
|
||||||
|
private hiddenMacWindow: BrowserWindow | undefined;
|
||||||
|
|
||||||
|
get mainWindow() {
|
||||||
|
return this.mainWindow$.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region private methods
|
||||||
|
private preventMacAppQuit() {
|
||||||
|
if (!this.hiddenMacWindow && isMacOS()) {
|
||||||
|
this.hiddenMacWindow = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
this.hiddenMacWindow.on('close', () => {
|
||||||
|
this.cleanupWindows();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupWindows() {
|
||||||
|
closeAllWindows();
|
||||||
|
this.mainWindowReady = undefined;
|
||||||
|
this.mainWindow$.next(undefined);
|
||||||
|
this.hiddenMacWindow?.destroy();
|
||||||
|
this.hiddenMacWindow = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createMainWindow() {
|
||||||
|
logger.info('create window');
|
||||||
|
const mainWindowState = electronWindowState({
|
||||||
|
defaultWidth: 1000,
|
||||||
|
defaultHeight: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureHelperProcess();
|
||||||
|
|
||||||
|
const browserWindow = new BrowserWindow({
|
||||||
|
titleBarStyle: isMacOS()
|
||||||
|
? 'hiddenInset'
|
||||||
|
: isWindows()
|
||||||
|
? 'hidden'
|
||||||
|
: 'default',
|
||||||
|
x: mainWindowState.x,
|
||||||
|
y: mainWindowState.y,
|
||||||
|
width: mainWindowState.width,
|
||||||
|
autoHideMenuBar: isLinux(),
|
||||||
|
minWidth: 640,
|
||||||
|
minHeight: 480,
|
||||||
|
visualEffectState: 'active',
|
||||||
|
vibrancy: 'under-window',
|
||||||
|
// backgroundMaterial: 'mica',
|
||||||
|
height: mainWindowState.height,
|
||||||
|
show: false, // Use 'ready-to-show' event to show window
|
||||||
|
webPreferences: {
|
||||||
|
webgl: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLinux()) {
|
||||||
|
browserWindow.setIcon(
|
||||||
|
// __dirname is `packages/frontend/electron/dist` (the bundled output directory)
|
||||||
|
join(__dirname, `../resources/icons/icon_${buildType}_64x64.png`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeTheme.themeSource = 'light';
|
||||||
|
mainWindowState.manage(browserWindow);
|
||||||
|
|
||||||
|
this.bindEvents(browserWindow);
|
||||||
|
return browserWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindEvents(mainWindow: BrowserWindow) {
|
||||||
|
/**
|
||||||
|
* If you install `show: true` then it can cause issues when trying to close the window.
|
||||||
|
* Use `show: false` and listener events `ready-to-show` to fix these issues.
|
||||||
|
*
|
||||||
|
* @see https://github.com/electron/electron/issues/25012
|
||||||
|
*/
|
||||||
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
logger.info('main window is ready to show');
|
||||||
|
|
||||||
|
uiSubjects.onMaximized$.next(mainWindow.isMaximized());
|
||||||
|
uiSubjects.onFullScreen$.next(mainWindow.isFullScreen());
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('close', e => {
|
||||||
|
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isMacOS()) {
|
||||||
|
closeAllWindows();
|
||||||
|
} else {
|
||||||
|
// hide window on macOS
|
||||||
|
// application quit will be handled by closing the hidden window
|
||||||
|
//
|
||||||
|
// explanation:
|
||||||
|
// - closing the top window (by clicking close button or CMD-w)
|
||||||
|
// - will be captured in "close" event here
|
||||||
|
// - hiding the app to make the app open faster when user click the app icon
|
||||||
|
// - quit the app by "cmd+q" or right click on the dock icon and select "quit"
|
||||||
|
// - all browser windows will capture the "close" event
|
||||||
|
// - the hidden window will close all windows
|
||||||
|
// - "window-all-closed" event will be emitted and eventually quit the app
|
||||||
|
if (mainWindow.isFullScreen()) {
|
||||||
|
mainWindow.once('leave-full-screen', () => {
|
||||||
|
mainWindow.hide();
|
||||||
|
});
|
||||||
|
mainWindow.setFullScreen(false);
|
||||||
|
} else {
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('leave-full-screen', () => {
|
||||||
|
// seems call this too soon may cause the app to crash
|
||||||
|
setTimeout(() => {
|
||||||
|
// FIXME: workaround for theme bug in full screen mode
|
||||||
|
const size = mainWindow.getSize();
|
||||||
|
mainWindow.setSize(size[0] + 1, size[1] + 1);
|
||||||
|
mainWindow.setSize(size[0], size[1]);
|
||||||
|
});
|
||||||
|
uiSubjects.onMaximized$.next(false);
|
||||||
|
uiSubjects.onFullScreen$.next(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('maximize', () => {
|
||||||
|
uiSubjects.onMaximized$.next(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('unmaximize', () => {
|
||||||
|
uiSubjects.onMaximized$.next(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// full-screen == maximized in UI on windows
|
||||||
|
mainWindow.on('enter-full-screen', () => {
|
||||||
|
uiSubjects.onFullScreen$.next(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('leave-full-screen', () => {
|
||||||
|
uiSubjects.onFullScreen$.next(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
async ensureMainWindow(): Promise<BrowserWindow> {
|
||||||
|
if (
|
||||||
|
!this.mainWindowReady ||
|
||||||
|
(await this.mainWindowReady.then(w => w.isDestroyed()))
|
||||||
|
) {
|
||||||
|
this.mainWindowReady = this.createMainWindow();
|
||||||
|
this.mainWindow$.next(await this.mainWindowReady);
|
||||||
|
this.preventMacAppQuit();
|
||||||
|
}
|
||||||
|
return this.mainWindowReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init main BrowserWindow. Will create a new window if it's not created yet.
|
||||||
|
*/
|
||||||
|
async initAndShowMainWindow() {
|
||||||
|
const mainWindow = await this.ensureMainWindow();
|
||||||
|
|
||||||
|
if (IS_DEV) {
|
||||||
|
// do not gain focus in dev mode
|
||||||
|
mainWindow.showInactive();
|
||||||
|
} else {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preventMacAppQuit();
|
||||||
|
|
||||||
|
return mainWindow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initAndShowMainWindow() {
|
||||||
|
return MainWindowManager.instance.initAndShowMainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMainWindow() {
|
||||||
|
return MainWindowManager.instance.ensureMainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showMainWindow() {
|
||||||
|
const window = await getMainWindow();
|
||||||
|
if (!window) return;
|
||||||
|
if (window.isMinimized()) {
|
||||||
|
window.restore();
|
||||||
|
}
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a URL in a hidden window.
|
||||||
|
* This is useful for opening a URL in the background without user interaction for *authentication*.
|
||||||
|
*/
|
||||||
|
export async function handleOpenUrlInHiddenWindow(url: string) {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 600,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, './preload.js'),
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
win.on('close', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.info('loading page at', url);
|
||||||
|
await win.loadURL(url);
|
||||||
|
return win;
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ import { join } from 'node:path';
|
|||||||
import type { Display } from 'electron';
|
import type { Display } from 'electron';
|
||||||
import { BrowserWindow, screen } from 'electron';
|
import { BrowserWindow, screen } from 'electron';
|
||||||
|
|
||||||
import { isMacOS } from '../shared/utils';
|
import { isMacOS } from '../../shared/utils';
|
||||||
import { mainWindowOrigin } from './constants';
|
import { onboardingViewUrl } from '../constants';
|
||||||
// import { getExposedMeta } from './exposed';
|
// import { getExposedMeta } from './exposed';
|
||||||
import { logger } from './logger';
|
import { logger } from '../logger';
|
||||||
|
|
||||||
const getScreenSize = (display: Display) => {
|
const getScreenSize = (display: Display) => {
|
||||||
const { width, height } = isMacOS() ? display.bounds : display.workArea;
|
const { width, height } = isMacOS() ? display.bounds : display.workArea;
|
||||||
@@ -15,7 +15,7 @@ const getScreenSize = (display: Display) => {
|
|||||||
|
|
||||||
// todo: not all window need all of the exposed meta
|
// todo: not all window need all of the exposed meta
|
||||||
const getWindowAdditionalArguments = async () => {
|
const getWindowAdditionalArguments = async () => {
|
||||||
const { getExposedMeta } = await import('./exposed');
|
const { getExposedMeta } = await import('../exposed');
|
||||||
const mainExposedMeta = getExposedMeta();
|
const mainExposedMeta = getExposedMeta();
|
||||||
return [
|
return [
|
||||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||||
@@ -90,9 +90,7 @@ async function createOnboardingWindow(additionalArguments: string[]) {
|
|||||||
fullscreenAndCenter(browserWindow);
|
fullscreenAndCenter(browserWindow);
|
||||||
});
|
});
|
||||||
|
|
||||||
await browserWindow.loadURL(
|
await browserWindow.loadURL(onboardingViewUrl);
|
||||||
`${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`
|
|
||||||
);
|
|
||||||
|
|
||||||
return browserWindow;
|
return browserWindow;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const workbenchViewModuleSchema = z.enum([
|
||||||
|
'trash',
|
||||||
|
'all',
|
||||||
|
'collection',
|
||||||
|
'tag',
|
||||||
|
'doc', // refers to a doc whose mode is not yet being resolved
|
||||||
|
'page',
|
||||||
|
'edgeless',
|
||||||
|
'journal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const workbenchViewMetaSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
path: z
|
||||||
|
.object({
|
||||||
|
pathname: z.string().optional(),
|
||||||
|
hash: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
// todo: move title/module to cached stated
|
||||||
|
title: z.string().optional(),
|
||||||
|
moduleName: workbenchViewModuleSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const workbenchMetaSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
activeViewIndex: z.number(),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
basename: z.string(),
|
||||||
|
views: z.array(workbenchViewMetaSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabViewsMetaSchema = z.object({
|
||||||
|
activeWorkbenchId: z.string().optional(),
|
||||||
|
workbenches: z.array(workbenchMetaSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TabViewsMetaKey = 'tabViewsMetaSchema';
|
||||||
|
export type TabViewsMetaSchema = z.infer<typeof tabViewsMetaSchema>;
|
||||||
|
export type WorkbenchMeta = z.infer<typeof workbenchMetaSchema>;
|
||||||
|
export type WorkbenchViewMeta = z.infer<typeof workbenchViewMetaSchema>;
|
||||||
|
export type WorkbenchViewModule = z.infer<typeof workbenchViewModuleSchema>;
|
||||||
999
packages/frontend/electron/src/main/windows-manager/tab-views.ts
Normal file
999
packages/frontend/electron/src/main/windows-manager/tab-views.ts
Normal file
@@ -0,0 +1,999 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
app,
|
||||||
|
type CookiesSetDetails,
|
||||||
|
globalShortcut,
|
||||||
|
Menu,
|
||||||
|
type Rectangle,
|
||||||
|
type View,
|
||||||
|
type WebContents,
|
||||||
|
WebContentsView,
|
||||||
|
} from 'electron';
|
||||||
|
import { partition } from 'lodash-es';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
map,
|
||||||
|
shareReplay,
|
||||||
|
startWith,
|
||||||
|
Subject,
|
||||||
|
type Unsubscribable,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
|
import { isMacOS } from '../../shared/utils';
|
||||||
|
import { isDev } from '../config';
|
||||||
|
import { mainWindowOrigin, shellViewUrl } from '../constants';
|
||||||
|
import { ensureHelperProcess } from '../helper-process';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import { globalStateStorage } from '../shared-storage/storage';
|
||||||
|
import { parseCookie } from '../utils';
|
||||||
|
import { getMainWindow, MainWindowManager } from './main-window';
|
||||||
|
import {
|
||||||
|
TabViewsMetaKey,
|
||||||
|
type TabViewsMetaSchema,
|
||||||
|
tabViewsMetaSchema,
|
||||||
|
type WorkbenchMeta,
|
||||||
|
type WorkbenchViewMeta,
|
||||||
|
} from './tab-views-meta-schema';
|
||||||
|
|
||||||
|
async function getAdditionalArguments() {
|
||||||
|
const { getExposedMeta } = await import('../exposed');
|
||||||
|
const mainExposedMeta = getExposedMeta();
|
||||||
|
const helperProcessManager = await ensureHelperProcess();
|
||||||
|
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
|
||||||
|
return [
|
||||||
|
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||||
|
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
|
||||||
|
`--window-name=main`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabViewsMetaState = {
|
||||||
|
$: globalStateStorage.watch<TabViewsMetaSchema>(TabViewsMetaKey).pipe(
|
||||||
|
map(v => tabViewsMetaSchema.parse(v ?? {})),
|
||||||
|
shareReplay(1)
|
||||||
|
),
|
||||||
|
|
||||||
|
set value(value: TabViewsMetaSchema) {
|
||||||
|
globalStateStorage.set(TabViewsMetaKey, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
get value() {
|
||||||
|
return tabViewsMetaSchema.parse(
|
||||||
|
globalStateStorage.get(TabViewsMetaKey) ?? {}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// shallow merge
|
||||||
|
patch(patch: Partial<TabViewsMetaSchema>) {
|
||||||
|
this.value = {
|
||||||
|
...this.value,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type AddTabAction = {
|
||||||
|
type: 'add-tab';
|
||||||
|
payload: WorkbenchMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloseTabAction = {
|
||||||
|
type: 'close-tab';
|
||||||
|
payload?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PinTabAction = {
|
||||||
|
type: 'pin-tab';
|
||||||
|
payload: { key: string; shouldPin: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivateViewAction = {
|
||||||
|
type: 'activate-view';
|
||||||
|
payload: { tabId: string; viewIndex: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeparateViewAction = {
|
||||||
|
type: 'separate-view';
|
||||||
|
payload: { tabId: string; viewIndex: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenInSplitViewAction = {
|
||||||
|
type: 'open-in-split-view';
|
||||||
|
payload: { tabId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabAction =
|
||||||
|
| AddTabAction
|
||||||
|
| CloseTabAction
|
||||||
|
| PinTabAction
|
||||||
|
| ActivateViewAction
|
||||||
|
| SeparateViewAction
|
||||||
|
| OpenInSplitViewAction;
|
||||||
|
|
||||||
|
type AddTabOption = {
|
||||||
|
basename: string;
|
||||||
|
view?: Omit<WorkbenchViewMeta, 'id'> | Array<Omit<WorkbenchViewMeta, 'id'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WebContentViewsManager {
|
||||||
|
static readonly instance = new WebContentViewsManager(
|
||||||
|
MainWindowManager.instance
|
||||||
|
);
|
||||||
|
|
||||||
|
private constructor(public mainWindowManager: MainWindowManager) {
|
||||||
|
this.setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly tabViewsMeta$ = TabViewsMetaState.$;
|
||||||
|
readonly tabsBoundingRect$ = new BehaviorSubject<Rectangle | null>(null);
|
||||||
|
readonly appTabsUIReady$ = new BehaviorSubject(new Set<string>());
|
||||||
|
|
||||||
|
// all web views
|
||||||
|
readonly webViewsMap$ = new BehaviorSubject(
|
||||||
|
new Map<string, WebContentsView>()
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly tabsStatus$ = combineLatest([
|
||||||
|
this.tabViewsMeta$.pipe(startWith(TabViewsMetaState.value)),
|
||||||
|
this.webViewsMap$,
|
||||||
|
this.appTabsUIReady$,
|
||||||
|
]).pipe(
|
||||||
|
map(([viewsMeta, views, ready]) => {
|
||||||
|
return viewsMeta.workbenches.map(w => {
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
pinned: !!w.pinned,
|
||||||
|
active: viewsMeta.activeWorkbenchId === w.id,
|
||||||
|
loaded: views.has(w.id),
|
||||||
|
ready: ready.has(w.id),
|
||||||
|
activeViewIndex: w.activeViewIndex,
|
||||||
|
views: w.views,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// all app views (excluding shell view)
|
||||||
|
readonly workbenchViewsMap$ = this.webViewsMap$.pipe(
|
||||||
|
map(
|
||||||
|
views => new Map([...views.entries()].filter(([key]) => key !== 'shell'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// a stack of closed workbenches (for undo close tab)
|
||||||
|
readonly closedWorkbenches: WorkbenchMeta[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits whenever a tab action is triggered.
|
||||||
|
*/
|
||||||
|
readonly tabAction$ = new Subject<TabAction>();
|
||||||
|
|
||||||
|
readonly activeWorkbenchId$ = this.tabViewsMeta$.pipe(
|
||||||
|
map(m => m?.activeWorkbenchId ?? m?.workbenches[0].id)
|
||||||
|
);
|
||||||
|
readonly activeWorkbench$ = combineLatest([
|
||||||
|
this.activeWorkbenchId$,
|
||||||
|
this.workbenchViewsMap$,
|
||||||
|
]).pipe(map(([key, views]) => (key ? views.get(key) : undefined)));
|
||||||
|
|
||||||
|
readonly shellView$ = this.webViewsMap$.pipe(
|
||||||
|
map(views => views.get('shell'))
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly webViewKeys$ = this.webViewsMap$.pipe(
|
||||||
|
map(views => Array.from(views.keys()))
|
||||||
|
);
|
||||||
|
|
||||||
|
get tabViewsMeta() {
|
||||||
|
return TabViewsMetaState.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private set tabViewsMeta(meta: TabViewsMetaSchema) {
|
||||||
|
TabViewsMetaState.value = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly patchTabViewsMeta = (patch: Partial<TabViewsMetaSchema>) => {
|
||||||
|
TabViewsMetaState.patch(patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
get tabsBoundingRect() {
|
||||||
|
return this.tabsBoundingRect$.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set tabsBoundingRect(rect: Rectangle | null) {
|
||||||
|
this.tabsBoundingRect$.next(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
get shellView() {
|
||||||
|
return this.webViewsMap$.value.get('shell');
|
||||||
|
}
|
||||||
|
|
||||||
|
set activeWorkbenchId(id: string | undefined) {
|
||||||
|
this.patchTabViewsMeta({
|
||||||
|
activeWorkbenchId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeWorkbenchId() {
|
||||||
|
return (
|
||||||
|
this.tabViewsMeta.activeWorkbenchId ??
|
||||||
|
this.tabViewsMeta.workbenches.at(0)?.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeWorkbenchView() {
|
||||||
|
return this.activeWorkbenchId
|
||||||
|
? this.webViewsMap$.value.get(this.activeWorkbenchId)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeWorkbenchMeta() {
|
||||||
|
return this.tabViewsMeta.workbenches.find(
|
||||||
|
w => w.id === this.activeWorkbenchId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get mainWindow() {
|
||||||
|
return this.mainWindowManager.mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tabViewsMap() {
|
||||||
|
return this.webViewsMap$.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get allViews() {
|
||||||
|
return Array.from(this.tabViewsMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabUIReady = (tabId: string) => {
|
||||||
|
this.appTabsUIReady$.next(new Set([...this.appTabsUIReady$.value, tabId]));
|
||||||
|
this.reorderViews();
|
||||||
|
};
|
||||||
|
|
||||||
|
getViewIdFromWebContentsId = (id: number) => {
|
||||||
|
return Array.from(this.tabViewsMap.entries()).find(
|
||||||
|
([, view]) => view.webContents.id === id
|
||||||
|
)?.[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWorkbenchMeta = (id: string, patch: Partial<WorkbenchMeta>) => {
|
||||||
|
const workbenches = this.tabViewsMeta.workbenches;
|
||||||
|
const index = workbenches.findIndex(w => w.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newWorkbenches = workbenches.toSpliced(index, 1, {
|
||||||
|
...workbenches[index],
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
this.patchTabViewsMeta({
|
||||||
|
workbenches: newWorkbenches,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
isActiveTab = (id: string) => {
|
||||||
|
return this.activeWorkbenchId === id;
|
||||||
|
};
|
||||||
|
|
||||||
|
closeTab = async (id?: string) => {
|
||||||
|
if (!id) {
|
||||||
|
id = this.activeWorkbenchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.tabViewsMeta.workbenches.findIndex(w => w.id === id);
|
||||||
|
if (index === -1 || this.tabViewsMeta.workbenches.length === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetWorkbench = this.tabViewsMeta.workbenches[index];
|
||||||
|
|
||||||
|
if (targetWorkbench.pinned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbenches = this.tabViewsMeta.workbenches.toSpliced(index, 1);
|
||||||
|
// if the active view is closed, switch to the next view (index unchanged)
|
||||||
|
// if the new index is out of bound, switch to the last view
|
||||||
|
let activeWorkbenchKey = this.activeWorkbenchId;
|
||||||
|
|
||||||
|
if (id === activeWorkbenchKey) {
|
||||||
|
activeWorkbenchKey = workbenches[index]?.id ?? workbenches.at(-1)?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeWorkbenchKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showTab(activeWorkbenchKey).catch(logger.error);
|
||||||
|
|
||||||
|
this.patchTabViewsMeta({
|
||||||
|
workbenches,
|
||||||
|
activeWorkbenchId: activeWorkbenchKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tabAction$.next({
|
||||||
|
type: 'close-tab',
|
||||||
|
payload: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.closedWorkbenches.push(targetWorkbench);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const view = this.tabViewsMap.get(id);
|
||||||
|
this.tabViewsMap.delete(id);
|
||||||
|
|
||||||
|
if (this.mainWindow && view) {
|
||||||
|
this.mainWindow.contentView.removeChildView(view);
|
||||||
|
view?.webContents.close();
|
||||||
|
}
|
||||||
|
}, 500); // delay a bit to get rid of the flicker
|
||||||
|
};
|
||||||
|
|
||||||
|
undoCloseTab = async () => {
|
||||||
|
if (this.closedWorkbenches.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbench = this.closedWorkbenches.pop();
|
||||||
|
|
||||||
|
if (workbench) {
|
||||||
|
await this.addTab({
|
||||||
|
basename: workbench.basename,
|
||||||
|
view: workbench.views,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addTab = async (option?: AddTabOption) => {
|
||||||
|
if (!option) {
|
||||||
|
option = {
|
||||||
|
basename: '/',
|
||||||
|
view: {
|
||||||
|
title: 'New Tab',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const workbenches = this.tabViewsMeta.workbenches;
|
||||||
|
const newKey = this.generateViewId('app');
|
||||||
|
const views = (
|
||||||
|
Array.isArray(option.view) ? option.view : [option.view]
|
||||||
|
).map(v => {
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
id: nanoid(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const workbench: WorkbenchMeta = {
|
||||||
|
basename: option.basename,
|
||||||
|
activeViewIndex: 0,
|
||||||
|
views: views,
|
||||||
|
id: newKey,
|
||||||
|
pinned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.patchTabViewsMeta({
|
||||||
|
activeWorkbenchId: newKey,
|
||||||
|
workbenches: [...workbenches, workbench],
|
||||||
|
});
|
||||||
|
await this.showTab(newKey);
|
||||||
|
this.tabAction$.next({
|
||||||
|
type: 'add-tab',
|
||||||
|
payload: workbench,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
key: newKey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTab = async (id: string): Promise<WebContentsView | undefined> => {
|
||||||
|
if (!this.tabViewsMeta.workbenches.some(w => w.id === id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let view = this.tabViewsMap.get(id);
|
||||||
|
if (!view) {
|
||||||
|
view = await this.createAndAddView('app', id);
|
||||||
|
const workbench = this.tabViewsMeta.workbenches.find(w => w.id === id);
|
||||||
|
const viewMeta = workbench?.views[workbench.activeViewIndex];
|
||||||
|
if (workbench && viewMeta) {
|
||||||
|
const url = new URL(
|
||||||
|
workbench.basename + (viewMeta.path?.pathname ?? ''),
|
||||||
|
mainWindowOrigin
|
||||||
|
);
|
||||||
|
url.hash = viewMeta.path?.hash ?? '';
|
||||||
|
url.search = viewMeta.path?.search ?? '';
|
||||||
|
logger.info(`loading tab ${id} at ${url.href}`);
|
||||||
|
view.webContents.loadURL(url.href).catch(logger.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
};
|
||||||
|
|
||||||
|
showTab = async (id: string): Promise<WebContentsView | undefined> => {
|
||||||
|
if (this.activeWorkbenchId !== id) {
|
||||||
|
// todo: this will cause the shell view to be on top and flickers the screen
|
||||||
|
// this.appTabsUIReady$.next(
|
||||||
|
// new Set([...this.appTabsUIReady$.value].filter(key => key !== id))
|
||||||
|
// );
|
||||||
|
this.patchTabViewsMeta({
|
||||||
|
activeWorkbenchId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.reorderViews();
|
||||||
|
let view = this.tabViewsMap.get(id);
|
||||||
|
if (!view) {
|
||||||
|
view = await this.loadTab(id);
|
||||||
|
}
|
||||||
|
this.reorderViews();
|
||||||
|
if (view) {
|
||||||
|
this.resizeView(view);
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
};
|
||||||
|
|
||||||
|
pinTab = (key: string, shouldPin: boolean) => {
|
||||||
|
// move the pinned tab to the last index of the pinned tabs
|
||||||
|
const [pinned, unPinned] = partition(
|
||||||
|
this.tabViewsMeta.workbenches,
|
||||||
|
w => w.pinned
|
||||||
|
);
|
||||||
|
|
||||||
|
const workbench = this.tabViewsMeta.workbenches.find(w => w.id === key);
|
||||||
|
if (!workbench) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tabAction$.next({
|
||||||
|
type: 'pin-tab',
|
||||||
|
payload: { key, shouldPin },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workbench.pinned && !shouldPin) {
|
||||||
|
this.patchTabViewsMeta({
|
||||||
|
workbenches: [
|
||||||
|
...pinned.filter(w => w.id !== key),
|
||||||
|
{ ...workbench, pinned: false },
|
||||||
|
...unPinned,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (!workbench.pinned && shouldPin) {
|
||||||
|
this.patchTabViewsMeta({
|
||||||
|
workbenches: [
|
||||||
|
...pinned,
|
||||||
|
{ ...workbench, pinned: true },
|
||||||
|
...unPinned.filter(w => w.id !== key),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activateView = async (tabId: string, viewIndex: number) => {
|
||||||
|
this.tabAction$.next({
|
||||||
|
type: 'activate-view',
|
||||||
|
payload: { tabId, viewIndex },
|
||||||
|
});
|
||||||
|
this.updateWorkbenchMeta(tabId, {
|
||||||
|
activeViewIndex: viewIndex,
|
||||||
|
});
|
||||||
|
await this.showTab(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
separateView = (tabId: string, viewIndex: number) => {
|
||||||
|
const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId);
|
||||||
|
if (!tabMeta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.tabAction$.next({
|
||||||
|
type: 'separate-view',
|
||||||
|
payload: { tabId, viewIndex },
|
||||||
|
});
|
||||||
|
const newTabMeta: WorkbenchMeta = {
|
||||||
|
...tabMeta,
|
||||||
|
activeViewIndex: 0,
|
||||||
|
views: [tabMeta.views[viewIndex]],
|
||||||
|
};
|
||||||
|
this.updateWorkbenchMeta(tabId, {
|
||||||
|
views: tabMeta.views.toSpliced(viewIndex, 1),
|
||||||
|
});
|
||||||
|
addTab(newTabMeta).catch(logger.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
openInSplitView = (tabId: string) => {
|
||||||
|
const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId);
|
||||||
|
if (!tabMeta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.tabAction$.next({
|
||||||
|
type: 'open-in-split-view',
|
||||||
|
payload: { tabId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reorderViews = () => {
|
||||||
|
if (this.mainWindow) {
|
||||||
|
// if tab ui of the current active view is not ready,
|
||||||
|
// make sure shell view is on top
|
||||||
|
const activeView = this.activeWorkbenchView;
|
||||||
|
const ready = this.activeWorkbenchId
|
||||||
|
? this.appTabsUIReady$.value.has(this.activeWorkbenchId)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// inactive < active view (not ready) < shell < active view (ready)
|
||||||
|
const getScore = (view: View) => {
|
||||||
|
if (view === this.shellView) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (view === activeView) {
|
||||||
|
return ready ? 3 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
[...this.tabViewsMap.values()]
|
||||||
|
.toSorted((a, b) => getScore(a) - getScore(b))
|
||||||
|
.forEach((view, index) => {
|
||||||
|
this.mainWindow?.contentView.addChildView(view, index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setup = () => {
|
||||||
|
const windowReadyToShow$ = this.mainWindowManager.mainWindow$.pipe(
|
||||||
|
filter(w => !!w)
|
||||||
|
);
|
||||||
|
|
||||||
|
const disposables: Unsubscribable[] = [];
|
||||||
|
disposables.push(
|
||||||
|
windowReadyToShow$.subscribe(w => {
|
||||||
|
handleWebContentsResize().catch(logger.error);
|
||||||
|
|
||||||
|
const screenSizeChangeEvents = ['resize', 'maximize', 'unmaximize'];
|
||||||
|
const onResize = () => {
|
||||||
|
if (this.activeWorkbenchView) {
|
||||||
|
this.resizeView(this.activeWorkbenchView);
|
||||||
|
}
|
||||||
|
if (this.shellView) {
|
||||||
|
this.resizeView(this.shellView);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
screenSizeChangeEvents.forEach(event => {
|
||||||
|
w.on(event as any, onResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
// add shell view
|
||||||
|
this.createAndAddView('shell').catch(logger.error);
|
||||||
|
(async () => {
|
||||||
|
if (this.tabViewsMeta.workbenches.length === 0) {
|
||||||
|
// create a default view (e.g., on first launch)
|
||||||
|
await this.addTab();
|
||||||
|
} else {
|
||||||
|
const defaultTabId = this.activeWorkbenchId;
|
||||||
|
if (defaultTabId) await this.showTab(defaultTabId);
|
||||||
|
}
|
||||||
|
})().catch(logger.error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
disposables.push(
|
||||||
|
this.tabsBoundingRect$.subscribe(rect => {
|
||||||
|
if (rect) {
|
||||||
|
this.reorderViews();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.on('ready', () => {
|
||||||
|
// bind CMD/CTRL+1~8 to switch tabs
|
||||||
|
// bind CMD/CTRL+9 to switch to the last tab
|
||||||
|
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach(n => {
|
||||||
|
const shortcut = `CommandOrControl+${n}`;
|
||||||
|
const listener = () => {
|
||||||
|
if (!this.mainWindow?.isFocused()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = this.tabViewsMeta.workbenches.at(n === 9 ? -1 : n - 1);
|
||||||
|
if (item) {
|
||||||
|
this.showTab(item.id).catch(logger.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
globalShortcut.register(shortcut, listener);
|
||||||
|
disposables.push({
|
||||||
|
unsubscribe: () => globalShortcut.unregister(shortcut),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
disposables.forEach(d => d.unsubscribe());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setCookie = async (cookie: CookiesSetDetails) => {
|
||||||
|
const views = this.allViews;
|
||||||
|
if (!views) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('setting cookie to main window view(s)', cookie);
|
||||||
|
for (const view of views) {
|
||||||
|
await view.webContents.session.cookies.set(cookie);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
removeCookie = async (url: string, name: string) => {
|
||||||
|
const views = this.allViews;
|
||||||
|
if (!views) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('removing cookie from main window view(s)', { url, name });
|
||||||
|
for (const view of views) {
|
||||||
|
await view.webContents.session.cookies.remove(url, name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getCookie = (url?: string, name?: string) => {
|
||||||
|
// all webviews share the same session
|
||||||
|
const view = this.allViews?.at(0);
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return view.webContents.session.cookies.get({
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
getViewById = (id: string) => {
|
||||||
|
if (id === 'shell') {
|
||||||
|
return this.shellView;
|
||||||
|
} else {
|
||||||
|
return this.tabViewsMap.get(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resizeView = (view: View) => {
|
||||||
|
// app view will take full w/h of the main window
|
||||||
|
view.setBounds({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: this.mainWindow?.getContentBounds().width ?? 0,
|
||||||
|
height: this.mainWindow?.getContentBounds().height ?? 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly generateViewId = (type: 'app' | 'shell') => {
|
||||||
|
return type === 'shell' ? 'shell' : `app-${nanoid()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly createAndAddView = async (
|
||||||
|
type: 'app' | 'shell',
|
||||||
|
viewId = this.generateViewId(type)
|
||||||
|
) => {
|
||||||
|
if (this.shellView && type === 'shell') {
|
||||||
|
logger.error('shell view is already created');
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const additionalArguments = await getAdditionalArguments();
|
||||||
|
const helperProcessManager = await ensureHelperProcess();
|
||||||
|
// will be added to appInfo
|
||||||
|
additionalArguments.push(`--view-id=${viewId}`);
|
||||||
|
|
||||||
|
const view = new WebContentsView({
|
||||||
|
webPreferences: {
|
||||||
|
webgl: true,
|
||||||
|
transparent: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false,
|
||||||
|
spellcheck: false, // TODO(@pengx17): enable?
|
||||||
|
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
|
||||||
|
// serialize exposed meta that to be used in preload
|
||||||
|
additionalArguments: additionalArguments,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
|
||||||
|
let unsub = () => {};
|
||||||
|
|
||||||
|
// shell process do not need to connect to helper process
|
||||||
|
if (type !== 'shell') {
|
||||||
|
view.webContents.on('did-finish-load', () => {
|
||||||
|
unsub = helperProcessManager.connectRenderer(view.webContents);
|
||||||
|
});
|
||||||
|
view.webContents.on('did-start-loading', () => {
|
||||||
|
// means the view is reloaded
|
||||||
|
this.appTabsUIReady$.next(
|
||||||
|
new Set([...this.appTabsUIReady$.value].filter(key => key !== viewId))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
view.webContents.on('focus', () => {
|
||||||
|
globalThis.setTimeout(() => {
|
||||||
|
// when shell is focused, focus the active app view instead (to make sure global keybindings work)
|
||||||
|
this.activeWorkbenchView?.webContents.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
view.webContents.loadURL(shellViewUrl).catch(logger.error);
|
||||||
|
if (isDev) {
|
||||||
|
view.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view.webContents.on('destroyed', () => {
|
||||||
|
unsub();
|
||||||
|
this.webViewsMap$.next(
|
||||||
|
new Map(
|
||||||
|
[...this.tabViewsMap.entries()].filter(([key]) => key !== viewId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// if all views are destroyed, close the app
|
||||||
|
// should only happen in tests
|
||||||
|
if (this.tabViewsMap.size === 0) {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resizeView(view);
|
||||||
|
// reorder will add to main window when loaded
|
||||||
|
this.reorderViews();
|
||||||
|
|
||||||
|
logger.info(`view ${viewId} created in ${performance.now() - start}ms`);
|
||||||
|
return view;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCookie(cookie: CookiesSetDetails): Promise<void>;
|
||||||
|
export async function setCookie(origin: string, cookie: string): Promise<void>;
|
||||||
|
|
||||||
|
export async function setCookie(
|
||||||
|
arg0: CookiesSetDetails | string,
|
||||||
|
arg1?: string
|
||||||
|
) {
|
||||||
|
const details =
|
||||||
|
typeof arg1 === 'string' && typeof arg0 === 'string'
|
||||||
|
? parseCookie(arg0, arg1)
|
||||||
|
: arg0;
|
||||||
|
|
||||||
|
logger.info('setting cookie to main window', details);
|
||||||
|
|
||||||
|
if (typeof details !== 'object') {
|
||||||
|
throw new Error('invalid cookie details');
|
||||||
|
}
|
||||||
|
return WebContentViewsManager.instance.setCookie(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCookie(url: string, name: string): Promise<void> {
|
||||||
|
return WebContentViewsManager.instance.removeCookie(url, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCookie(url?: string, name?: string) {
|
||||||
|
return WebContentViewsManager.instance.getCookie(url, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// there is no proper way to listen to webContents resize event
|
||||||
|
// we will rely on window.resize event in renderer instead
|
||||||
|
export async function handleWebContentsResize() {
|
||||||
|
// right now when window is resized, we will relocate the traffic light positions
|
||||||
|
if (isMacOS()) {
|
||||||
|
const window = await getMainWindow();
|
||||||
|
const factor = window?.webContents.getZoomFactor() || 1;
|
||||||
|
window?.setWindowButtonPosition({ x: 20 * factor, y: 24 * factor - 6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onTabViewsMetaChanged(
|
||||||
|
fn: (appViewMeta: TabViewsMetaSchema) => void
|
||||||
|
) {
|
||||||
|
const sub = WebContentViewsManager.instance.tabViewsMeta$.subscribe(meta => {
|
||||||
|
fn(meta);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onTabShellViewActiveChange = (fn: (active: boolean) => void) => {
|
||||||
|
const sub = combineLatest([
|
||||||
|
WebContentViewsManager.instance.appTabsUIReady$,
|
||||||
|
WebContentViewsManager.instance.activeWorkbenchId$,
|
||||||
|
]).subscribe(([ready, active]) => {
|
||||||
|
fn(!ready.has(active));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTabsStatus = () => {
|
||||||
|
return firstValueFrom(WebContentViewsManager.instance.tabsStatus$);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onTabsStatusChange = (
|
||||||
|
fn: (
|
||||||
|
tabs: {
|
||||||
|
id: string;
|
||||||
|
active: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
activeViewIndex: number;
|
||||||
|
views: WorkbenchViewMeta[];
|
||||||
|
}[]
|
||||||
|
) => void
|
||||||
|
) => {
|
||||||
|
const sub = WebContentViewsManager.instance.tabsStatus$.subscribe(tabs => {
|
||||||
|
fn(tabs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWorkbenchMeta = (
|
||||||
|
id: string,
|
||||||
|
meta: Partial<Omit<WorkbenchMeta, 'id'>>
|
||||||
|
) => {
|
||||||
|
WebContentViewsManager.instance.updateWorkbenchMeta(id, meta);
|
||||||
|
};
|
||||||
|
export const getWorkbenchMeta = (id: string) => {
|
||||||
|
return TabViewsMetaState.value.workbenches.find(w => w.id === id);
|
||||||
|
};
|
||||||
|
export const getTabViewsMeta = () => TabViewsMetaState.value;
|
||||||
|
export const isActiveTab = (wc: WebContents) => {
|
||||||
|
return (
|
||||||
|
wc.id ===
|
||||||
|
WebContentViewsManager.instance.activeWorkbenchView?.webContents.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const addTab = WebContentViewsManager.instance.addTab;
|
||||||
|
export const showTab = WebContentViewsManager.instance.showTab;
|
||||||
|
export const closeTab = WebContentViewsManager.instance.closeTab;
|
||||||
|
export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab;
|
||||||
|
export const activateView = WebContentViewsManager.instance.activateView;
|
||||||
|
|
||||||
|
export const onTabAction = (fn: (event: TabAction) => void) => {
|
||||||
|
const { unsubscribe } =
|
||||||
|
WebContentViewsManager.instance.tabAction$.subscribe(fn);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onActiveTabChanged = (fn: (tabId: string) => void) => {
|
||||||
|
const sub = WebContentViewsManager.instance.activeWorkbenchId$.subscribe(fn);
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showDevTools = (id?: string) => {
|
||||||
|
const view = id
|
||||||
|
? WebContentViewsManager.instance.getViewById(id)
|
||||||
|
: WebContentViewsManager.instance.activeWorkbenchView;
|
||||||
|
if (view) {
|
||||||
|
view.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onTabsBoundingRectChanged = (
|
||||||
|
fn: (rect: Rectangle | null) => void
|
||||||
|
) => {
|
||||||
|
const sub = WebContentViewsManager.instance.tabsBoundingRect$.subscribe(fn);
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTabsBoundingRect = () => {
|
||||||
|
return WebContentViewsManager.instance.tabsBoundingRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTabsBoundingRect = (wc: WebContents, rect: Rectangle) => {
|
||||||
|
try {
|
||||||
|
if (isActiveTab(wc)) {
|
||||||
|
WebContentViewsManager.instance.tabsBoundingRect = rect;
|
||||||
|
}
|
||||||
|
const viewId = WebContentViewsManager.instance.getViewIdFromWebContentsId(
|
||||||
|
wc.id
|
||||||
|
);
|
||||||
|
if (viewId) {
|
||||||
|
WebContentViewsManager.instance.setTabUIReady(viewId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showTabContextMenu = async (tabId: string, viewIndex: number) => {
|
||||||
|
const workbenches = WebContentViewsManager.instance.tabViewsMeta.workbenches;
|
||||||
|
const unpinned = workbenches.filter(w => !w.pinned);
|
||||||
|
const tabMeta = workbenches.find(w => w.id === tabId);
|
||||||
|
if (!tabMeta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template: Parameters<typeof Menu.buildFromTemplate>[0] = [
|
||||||
|
tabMeta.pinned
|
||||||
|
? {
|
||||||
|
label: 'Unpin tab',
|
||||||
|
click: () => {
|
||||||
|
WebContentViewsManager.instance.pinTab(tabId, false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: 'Pin tab',
|
||||||
|
click: () => {
|
||||||
|
WebContentViewsManager.instance.pinTab(tabId, true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Refresh tab',
|
||||||
|
click: () => {
|
||||||
|
WebContentViewsManager.instance
|
||||||
|
.getViewById(tabId)
|
||||||
|
?.webContents.reload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Duplicate tab',
|
||||||
|
click: () => {
|
||||||
|
addTab(tabMeta).catch(logger.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: 'separator' },
|
||||||
|
|
||||||
|
tabMeta.views.length > 1
|
||||||
|
? {
|
||||||
|
label: 'Separate tabs',
|
||||||
|
click: () => {
|
||||||
|
WebContentViewsManager.instance.separateView(tabId, viewIndex);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: 'Open in split view',
|
||||||
|
click: () => {
|
||||||
|
WebContentViewsManager.instance.openInSplitView(tabId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
...(unpinned.length > 0
|
||||||
|
? ([
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Close tab',
|
||||||
|
click: () => {
|
||||||
|
closeTab(tabId).catch(logger.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Close other tabs',
|
||||||
|
click: () => {
|
||||||
|
const tabsToRetain =
|
||||||
|
WebContentViewsManager.instance.tabViewsMeta.workbenches.filter(
|
||||||
|
w => w.id === tabId || w.pinned
|
||||||
|
);
|
||||||
|
|
||||||
|
WebContentViewsManager.instance.patchTabViewsMeta({
|
||||||
|
workbenches: tabsToRetain,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const)
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const menu = Menu.buildFromTemplate(template);
|
||||||
|
menu.popup();
|
||||||
|
};
|
||||||
@@ -73,10 +73,13 @@ schema = isDev ? 'affine-dev' : schema;
|
|||||||
|
|
||||||
export const appInfo = {
|
export const appInfo = {
|
||||||
electron: true,
|
electron: true,
|
||||||
windowName: process.argv
|
windowName:
|
||||||
.find(arg => arg.startsWith('--window-name='))
|
process.argv.find(arg => arg.startsWith('--window-name='))?.split('=')[1] ??
|
||||||
?.split('=')[1],
|
'unknown',
|
||||||
schema,
|
viewId:
|
||||||
|
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
|
||||||
|
'unknown',
|
||||||
|
schema: `${schema}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getMainAPIs() {
|
function getMainAPIs() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { GlobalLoading } from '@affine/component/global-loading';
|
|||||||
import { AppFallback } from '@affine/core/components/affine/app-container';
|
import { AppFallback } from '@affine/core/components/affine/app-container';
|
||||||
import { configureCommonModules } from '@affine/core/modules';
|
import { configureCommonModules } from '@affine/core/modules';
|
||||||
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
|
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
|
||||||
|
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
|
||||||
import {
|
import {
|
||||||
configureBrowserWorkspaceFlavours,
|
configureBrowserWorkspaceFlavours,
|
||||||
configureIndexedDBWorkspaceEngineStorageProvider,
|
configureIndexedDBWorkspaceEngineStorageProvider,
|
||||||
@@ -70,6 +71,7 @@ let languageLoadingPromise: Promise<void> | null = null;
|
|||||||
|
|
||||||
const framework = new Framework();
|
const framework = new Framework();
|
||||||
configureCommonModules(framework);
|
configureCommonModules(framework);
|
||||||
|
configureBrowserWorkbenchModule(framework);
|
||||||
configureLocalStorageStateStorageImpls(framework);
|
configureLocalStorageStateStorageImpls(framework);
|
||||||
configureBrowserWorkspaceFlavours(framework);
|
configureBrowserWorkspaceFlavours(framework);
|
||||||
configureIndexedDBWorkspaceEngineStorageProvider(framework);
|
configureIndexedDBWorkspaceEngineStorageProvider(framework);
|
||||||
|
|||||||
1
packages/frontend/web/src/polyfill/set-immediate.ts
Normal file
1
packages/frontend/web/src/polyfill/set-immediate.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import 'setimmediate';
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@affine-test/affine-desktop-cloud",
|
"name": "@affine-test/affine-desktop-cloud",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"e2e": "DEBUG=pw:browser yarn playwright test"
|
"e2e": "yarn playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-test/fixtures": "workspace:*",
|
"@affine-test/fixtures": "workspace:*",
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { test } from '@affine-test/kit/electron';
|
import { test } from '@affine-test/kit/electron';
|
||||||
import { withCtrlOrMeta } from '@affine-test/kit/utils/keyboard';
|
|
||||||
import {
|
import {
|
||||||
clickNewPageButton,
|
clickNewPageButton,
|
||||||
createLinkedPage,
|
createLinkedPage,
|
||||||
getBlockSuiteEditorTitle,
|
getBlockSuiteEditorTitle,
|
||||||
waitForEmptyEditor,
|
|
||||||
} from '@affine-test/kit/utils/page-logic';
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
import {
|
import {
|
||||||
clickSideBarCurrentWorkspaceBanner,
|
clickSideBarCurrentWorkspaceBanner,
|
||||||
@@ -14,73 +12,53 @@ import type { Page } from '@playwright/test';
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
const historyShortcut = async (page: Page, command: 'goBack' | 'goForward') => {
|
const historyShortcut = async (page: Page, command: 'goBack' | 'goForward') => {
|
||||||
await withCtrlOrMeta(page, () =>
|
await page.keyboard.press(
|
||||||
page.keyboard.press(command === 'goBack' ? '[' : ']', { delay: 50 })
|
command === 'goBack' ? 'ControlOrMeta+[' : 'ControlOrMeta+]'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
test('new page', async ({ page, workspace }) => {
|
test('new page', async ({ page, workspace }) => {
|
||||||
await page.getByTestId('sidebar-new-page-button').click();
|
await clickNewPageButton(page);
|
||||||
await page.waitForSelector('v-line');
|
|
||||||
const flavour = (await workspace.current()).meta.flavour;
|
const flavour = (await workspace.current()).meta.flavour;
|
||||||
expect(flavour).toBe('local');
|
expect(flavour).toBe('local');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('app sidebar router forward/back', async ({ page }) => {
|
test('app sidebar router forward/back', async ({ page }) => {
|
||||||
{
|
|
||||||
// create pages
|
// create pages
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await page.waitForSelector('v-line');
|
|
||||||
const title = getBlockSuiteEditorTitle(page);
|
const title = getBlockSuiteEditorTitle(page);
|
||||||
await title.focus();
|
await title.focus();
|
||||||
await title.pressSequentially('test1', {
|
await title.pressSequentially('test1', {
|
||||||
delay: 100,
|
delay: 100,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.getByTestId('sidebar-new-page-button').click({
|
await clickNewPageButton(page);
|
||||||
delay: 100,
|
|
||||||
});
|
|
||||||
await page.waitForSelector('v-line');
|
|
||||||
|
|
||||||
await title.focus();
|
await title.focus();
|
||||||
await title.pressSequentially('test2', {
|
await title.pressSequentially('test2', {
|
||||||
delay: 100,
|
delay: 100,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.getByTestId('sidebar-new-page-button').click({
|
await clickNewPageButton(page);
|
||||||
delay: 100,
|
|
||||||
});
|
|
||||||
await page.waitForSelector('v-line');
|
|
||||||
await title.focus();
|
await title.focus();
|
||||||
await title.pressSequentially('test3', {
|
await title.pressSequentially('test3', {
|
||||||
delay: 100,
|
delay: 100,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
{
|
|
||||||
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
|
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
|
||||||
}
|
|
||||||
|
|
||||||
await page.click('[data-testid="app-navigation-button-back"]');
|
await page.click('[data-testid="app-navigation-button-back"]');
|
||||||
await page.click('[data-testid="app-navigation-button-back"]');
|
await page.click('[data-testid="app-navigation-button-back"]');
|
||||||
{
|
|
||||||
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
|
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
|
||||||
}
|
|
||||||
await page.click('[data-testid="app-navigation-button-forward"]');
|
await page.click('[data-testid="app-navigation-button-forward"]');
|
||||||
await page.click('[data-testid="app-navigation-button-forward"]');
|
await page.click('[data-testid="app-navigation-button-forward"]');
|
||||||
{
|
|
||||||
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
|
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
|
||||||
}
|
|
||||||
await historyShortcut(page, 'goBack');
|
await historyShortcut(page, 'goBack');
|
||||||
await historyShortcut(page, 'goBack');
|
await historyShortcut(page, 'goBack');
|
||||||
{
|
|
||||||
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
|
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test1');
|
||||||
}
|
|
||||||
await historyShortcut(page, 'goForward');
|
await historyShortcut(page, 'goForward');
|
||||||
await historyShortcut(page, 'goForward');
|
await historyShortcut(page, 'goForward');
|
||||||
{
|
|
||||||
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
|
await expect(getBlockSuiteEditorTitle(page)).toHaveText('test3');
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clientBorder value should disable by default on window', async ({
|
test('clientBorder value should disable by default on window', async ({
|
||||||
@@ -133,6 +111,8 @@ test('windows only check', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('delete workspace', async ({ page }) => {
|
test('delete workspace', async ({ page }) => {
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
|
||||||
await clickSideBarCurrentWorkspaceBanner(page);
|
await clickSideBarCurrentWorkspaceBanner(page);
|
||||||
await page.getByTestId('new-workspace').click();
|
await page.getByTestId('new-workspace').click();
|
||||||
await page.getByTestId('create-workspace-input').fill('Delete Me');
|
await page.getByTestId('create-workspace-input').fill('Delete Me');
|
||||||
@@ -159,8 +139,7 @@ test('delete workspace', async ({ page }) => {
|
|||||||
await page.getByTestId('delete-workspace-button').click();
|
await page.getByTestId('delete-workspace-button').click();
|
||||||
await page.getByTestId('delete-workspace-input').fill('Delete Me');
|
await page.getByTestId('delete-workspace-input').fill('Delete Me');
|
||||||
await page.getByTestId('delete-workspace-confirm-button').click();
|
await page.getByTestId('delete-workspace-confirm-button').click();
|
||||||
await page.waitForTimeout(1000);
|
await expect(page.getByTestId('workspace-name')).toContainText(
|
||||||
expect(await page.getByTestId('workspace-name').textContent()).toBe(
|
|
||||||
'Demo Workspace'
|
'Demo Workspace'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -192,17 +171,14 @@ async function enableSplitView(page: Page) {
|
|||||||
|
|
||||||
test('open split view', async ({ page }) => {
|
test('open split view', async ({ page }) => {
|
||||||
await enableSplitView(page);
|
await enableSplitView(page);
|
||||||
await page.getByTestId('sidebar-new-page-button').click({
|
await clickNewPageButton(page);
|
||||||
delay: 100,
|
|
||||||
});
|
|
||||||
await waitForEmptyEditor(page);
|
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await createLinkedPage(page, 'hi from another page');
|
await createLinkedPage(page, 'hi from another page');
|
||||||
await page
|
await page
|
||||||
.locator('.affine-reference-title:has-text("hi from another page")')
|
.locator('.affine-reference-title:has-text("hi from another page")')
|
||||||
.click({
|
.click({
|
||||||
modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'],
|
modifiers: ['ControlOrMeta', 'Alt'],
|
||||||
});
|
});
|
||||||
await expect(page.locator('.doc-title-container')).toHaveCount(2);
|
await expect(page.locator('.doc-title-container')).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@affine-test/affine-desktop",
|
"name": "@affine-test/affine-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"e2e": "DEBUG=\"pw:*\" yarn playwright test"
|
"e2e": "yarn playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-test/fixtures": "workspace:*",
|
"@affine-test/fixtures": "workspace:*",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { PlaywrightTestConfig } from '@playwright/test';
|
|||||||
const config: PlaywrightTestConfig = {
|
const config: PlaywrightTestConfig = {
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
workers: 2,
|
||||||
timeout: process.env.CI ? 50_000 : 30_000,
|
timeout: process.env.CI ? 50_000 : 30_000,
|
||||||
outputDir: testResultDir,
|
outputDir: testResultDir,
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
import type { Page } from '@playwright/test';
|
import { expect, type Page } from '@playwright/test';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import type { ElectronApplication } from 'playwright';
|
import type { ElectronApplication } from 'playwright';
|
||||||
import { _electron as electron } from 'playwright';
|
import { _electron as electron } from 'playwright';
|
||||||
|
|
||||||
import {
|
import { test as base, testResultDir } from './playwright';
|
||||||
enableCoverage,
|
|
||||||
istanbulTempDir,
|
|
||||||
test as base,
|
|
||||||
testResultDir,
|
|
||||||
} from './playwright';
|
|
||||||
import { removeWithRetry } from './utils/utils';
|
import { removeWithRetry } from './utils/utils';
|
||||||
|
|
||||||
const projectRoot = join(__dirname, '..', '..');
|
const projectRoot = join(__dirname, '..', '..');
|
||||||
@@ -23,8 +18,30 @@ function generateUUID() {
|
|||||||
|
|
||||||
type RoutePath = 'setting';
|
type RoutePath = 'setting';
|
||||||
|
|
||||||
|
const getPageId = async (page: Page) => {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
return (window.appInfo as any)?.viewId as string;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActivePage = async (page: Page) => {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
return await (window as any).apis?.ui.isActiveTab();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActivePage = async (pages: Page[]) => {
|
||||||
|
for (const page of pages) {
|
||||||
|
if (await isActivePage(page)) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const test = base.extend<{
|
export const test = base.extend<{
|
||||||
electronApp: ElectronApplication;
|
electronApp: ElectronApplication;
|
||||||
|
shell: Page;
|
||||||
appInfo: {
|
appInfo: {
|
||||||
appPath: string;
|
appPath: string;
|
||||||
appData: string;
|
appData: string;
|
||||||
@@ -34,45 +51,48 @@ export const test = base.extend<{
|
|||||||
goto: (path: RoutePath) => Promise<void>;
|
goto: (path: RoutePath) => Promise<void>;
|
||||||
};
|
};
|
||||||
}>({
|
}>({
|
||||||
|
shell: async ({ electronApp }, use) => {
|
||||||
|
await expect.poll(() => electronApp.windows().length > 1).toBeTruthy();
|
||||||
|
|
||||||
|
for (const page of electronApp.windows()) {
|
||||||
|
const viewId = await getPageId(page);
|
||||||
|
if (viewId === 'shell') {
|
||||||
|
await use(page);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
page: async ({ electronApp }, use) => {
|
page: async ({ electronApp }, use) => {
|
||||||
const page = await electronApp.firstWindow();
|
await expect
|
||||||
|
.poll(() => {
|
||||||
|
return electronApp.windows().length > 1;
|
||||||
|
})
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const page = await getActivePage(electronApp.windows());
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('No active page found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for blocksuite to be loaded
|
||||||
|
await page.waitForSelector('v-line');
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.localStorage.setItem('dismissAiOnboarding', 'true');
|
window.localStorage.setItem('dismissAiOnboarding', 'true');
|
||||||
window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
|
window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
|
||||||
window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
|
window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
|
||||||
});
|
});
|
||||||
// wait for blocksuite to be loaded
|
|
||||||
await page.waitForSelector('v-line');
|
await page.reload();
|
||||||
if (enableCoverage) {
|
|
||||||
await fs.promises.mkdir(istanbulTempDir, { recursive: true });
|
|
||||||
await page.exposeFunction(
|
|
||||||
'collectIstanbulCoverage',
|
|
||||||
(coverageJSON?: string) => {
|
|
||||||
if (coverageJSON)
|
|
||||||
fs.writeFileSync(
|
|
||||||
join(
|
|
||||||
istanbulTempDir,
|
|
||||||
`playwright_coverage_${generateUUID()}.json`
|
|
||||||
),
|
|
||||||
coverageJSON
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await use(page as Page);
|
await use(page as Page);
|
||||||
if (enableCoverage) {
|
|
||||||
await page.evaluate(() =>
|
|
||||||
// @ts-expect-error
|
|
||||||
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await page.close();
|
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line no-empty-pattern
|
// eslint-disable-next-line no-empty-pattern
|
||||||
electronApp: async ({}, use) => {
|
electronApp: async ({}, use) => {
|
||||||
|
try {
|
||||||
// a random id to avoid conflicts between tests
|
// a random id to avoid conflicts between tests
|
||||||
const id = generateUUID();
|
const id = generateUUID();
|
||||||
const ext = process.platform === 'win32' ? '.cmd' : '';
|
|
||||||
const dist = resolve(electronRoot, 'dist');
|
const dist = resolve(electronRoot, 'dist');
|
||||||
const clonedDist = resolve(electronRoot, 'e2e-dist-' + id);
|
const clonedDist = resolve(electronRoot, 'e2e-dist-' + id);
|
||||||
await fs.copy(dist, clonedDist);
|
await fs.copy(dist, clonedDist);
|
||||||
@@ -93,29 +113,27 @@ export const test = base.extend<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.DEV_SERVER_URL) {
|
|
||||||
env.DEV_SERVER_URL = process.env.DEV_SERVER_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
env.SKIP_ONBOARDING = '1';
|
env.SKIP_ONBOARDING = '1';
|
||||||
|
|
||||||
const electronApp = await electron.launch({
|
const electronApp = await electron.launch({
|
||||||
args: [clonedDist],
|
args: [clonedDist],
|
||||||
env,
|
env,
|
||||||
executablePath: resolve(
|
|
||||||
projectRoot,
|
|
||||||
'node_modules',
|
|
||||||
'.bin',
|
|
||||||
`electron${ext}`
|
|
||||||
),
|
|
||||||
cwd: clonedDist,
|
cwd: clonedDist,
|
||||||
recordVideo: {
|
recordVideo: {
|
||||||
dir: testResultDir,
|
dir: testResultDir,
|
||||||
},
|
},
|
||||||
colorScheme: 'light',
|
colorScheme: 'light',
|
||||||
});
|
});
|
||||||
|
|
||||||
await use(electronApp);
|
await use(electronApp);
|
||||||
try {
|
console.log('Cleaning up...');
|
||||||
|
const pages = electronApp.windows();
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
const page = pages[i];
|
||||||
|
await page.close();
|
||||||
|
console.log(`Closed page ${i + 1}/${pages.length}`);
|
||||||
|
}
|
||||||
|
await electronApp.close();
|
||||||
await removeWithRetry(clonedDist);
|
await removeWithRetry(clonedDist);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -132,3 +150,8 @@ export const test = base.extend<{
|
|||||||
await use(appInfo);
|
await use(appInfo);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
test.afterEach(({}, testInfo) => {
|
||||||
|
console.log('cleaning up for ' + testInfo);
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ const getChannel = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let entry: string | undefined;
|
let entry: BuildFlags['entry'];
|
||||||
|
|
||||||
const { DISTRIBUTION } = process.env;
|
const { DISTRIBUTION } = process.env;
|
||||||
|
|
||||||
const cwd = getCwdFromDistribution(DISTRIBUTION);
|
const cwd = getCwdFromDistribution(DISTRIBUTION);
|
||||||
|
|
||||||
if (DISTRIBUTION === 'desktop') {
|
if (DISTRIBUTION === 'desktop') {
|
||||||
entry = './index.tsx';
|
entry = { app: './index.tsx', shell: './shell/index.tsx' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ const cwd = getCwdFromDistribution(flags.distribution);
|
|||||||
process.env.DISTRIBUTION = flags.distribution;
|
process.env.DISTRIBUTION = flags.distribution;
|
||||||
|
|
||||||
if (flags.distribution === 'desktop') {
|
if (flags.distribution === 'desktop') {
|
||||||
flags.entry = join(cwd, 'index.tsx');
|
flags.entry = {
|
||||||
|
app: join(cwd, 'index.tsx'),
|
||||||
|
shell: join(cwd, 'shell/index.tsx'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buildFlags.debugBlockSuite) {
|
if (buildFlags.debugBlockSuite) {
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ export type BuildFlags = {
|
|||||||
channel: 'stable' | 'beta' | 'canary' | 'internal';
|
channel: 'stable' | 'beta' | 'canary' | 'internal';
|
||||||
coverage?: boolean;
|
coverage?: boolean;
|
||||||
localBlockSuite?: string;
|
localBlockSuite?: string;
|
||||||
entry?: string;
|
entry?: string | { [key: string]: string };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -368,6 +368,9 @@ export const createConfiguration: (
|
|||||||
? new WebpackS3Plugin()
|
? new WebpackS3Plugin()
|
||||||
: null,
|
: null,
|
||||||
]),
|
]),
|
||||||
|
stats: {
|
||||||
|
errorDetails: true,
|
||||||
|
},
|
||||||
|
|
||||||
optimization: OptimizeOptionOptions(buildFlags),
|
optimization: OptimizeOptionOptions(buildFlags),
|
||||||
|
|
||||||
|
|||||||
@@ -33,23 +33,30 @@ export function createWebpackConfig(cwd: string, flags: BuildFlags) {
|
|||||||
const runtimeConfig = getRuntimeConfig(flags);
|
const runtimeConfig = getRuntimeConfig(flags);
|
||||||
console.log('runtime config', runtimeConfig);
|
console.log('runtime config', runtimeConfig);
|
||||||
const config = createConfiguration(cwd, flags, runtimeConfig);
|
const config = createConfiguration(cwd, flags, runtimeConfig);
|
||||||
return merge(config, {
|
const entry =
|
||||||
entry: {
|
typeof flags.entry === 'string' || !flags.entry
|
||||||
[flags.distribution]: flags.entry ?? resolve(cwd, 'src/index.tsx'),
|
? {
|
||||||
},
|
app: flags.entry ?? resolve(cwd, 'src/index.tsx'),
|
||||||
plugins: [
|
}
|
||||||
new HTMLPlugin({
|
: flags.entry;
|
||||||
|
|
||||||
|
const createHTMLPlugin = (entryName = 'app') => {
|
||||||
|
return new HTMLPlugin({
|
||||||
template: join(rootPath, 'webpack', 'template.html'),
|
template: join(rootPath, 'webpack', 'template.html'),
|
||||||
inject: 'body',
|
inject: 'body',
|
||||||
scriptLoading: 'module',
|
scriptLoading: 'module',
|
||||||
minify: false,
|
minify: false,
|
||||||
chunks: 'all',
|
chunks: [entryName],
|
||||||
filename: 'index.html',
|
filename: `${entryName === 'app' ? 'index' : entryName}.html`, // main entry should take name index.html
|
||||||
templateParameters: {
|
templateParameters: {
|
||||||
GIT_SHORT_SHA: gitShortHash(),
|
GIT_SHORT_SHA: gitShortHash(),
|
||||||
DESCRIPTION,
|
DESCRIPTION,
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
],
|
};
|
||||||
|
|
||||||
|
return merge(config, {
|
||||||
|
entry: entry,
|
||||||
|
plugins: Object.keys(entry).map(createHTMLPlugin),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user