从零实现桌面窗口管理(前端部分)

我正在开发 Ray Memex,这是一个宏大的项目,愿景是管理一切个人数据,包括视频、电子书、网页归档、语料、音乐。

Ray Memex 的前端展示部分,我想开发成操作系统桌面一样,也有窗口、开始菜单、最大最小化。

通过一番研究,成功实现。在本文中,我将以桌面窗口管理为主题,为大家介绍如何从零实现一个桌面窗口管理器。

标题所说的“前端部分”,指的是所有的窗口都是前端组件,不包含与实际桌面系统(如 X11)的合成。不过,实现后者也是有例可循的,参见 wnayes/bond-wm: An X Window Manager built on web technologies.

本文使用的技术栈如下:React、Redux(Toolkit)、TypeScript,React 95,整个项目是一个 Electron 应用,使用 Electron Forge 创建。

运行截图如下,借助于 React 95 组件库,实现了复古风:

Screenshot_20240608_142751222.png

Note

本文所有代码实现位于 Ray MemexRay Memex 本身也是一个开源项目,现在还处于早期阶段,尚未达到作为产品面向用户的成熟度,欢迎大家 star、阅读代码,一起交流、维护!

本文中窗口管理的代码,学自 geo-tp/React-Desktop: Operating system desktop style in a react app 项目,该项目带有一个 Web Demo,可以在线体验。


窗口的数据结构:WindowType

首先,让我们定义窗口的数据结构 WindowType:

export type WindowType = {
    id: number;
    appType: AppTypeEnum;
    hidden: boolean;
    width: string;
    height: string;
    top: number;
    left: number;
    zIndex: number;
    viewState: WindowViewStateEnum;
    params: any;
}

其中:

AppTypeEnum 声明了 Ray Memex 内的应用类型,目前还很少:

export enum AppTypeEnum {
    WebBrowser,
    ImageViewer
}

前面说道,Ray Memex 愿景是管理一切个人数据,包括视频、电子书、网页归档、语料、音乐。因此未来,AppTypeEnum 将会包括浏览器、视频播放器、电子书阅读器……

WindowViewStateEnum:

export enum WindowViewStateEnum {
    Fullscreen,
    Custom
}

Redux Store

定义好数据结构部分,接下来开始写 Redux 部分。

这部分的完整代码,既可以参见原项目 geo-tp/React-Desktop 项目,也可以参见 Ray Memex。下面给出关键实现步骤。

Store 部分固定写法:

import { configureStore } from '@reduxjs/toolkit';
import { windowReducer } from './slices/windows/reducer';

const store = configureStore({
    reducer: {
        windows: windowReducer,
    },
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

export default store;

关键在于 windowReducer,也就是窗口的状态管理部分。


Windows Slice

在 Redux 中,状态由多个子模块组成,windows 包即管理窗口状态的模块。在 slices/windows 下,包含以下内容:


Windows 状态

先看 state.ts,定义了 Windows 模块的初始状态:

export const windowsInitalState = {
    elements: [{
        id: 1,
        appType: AppTypeEnum.WebBrowser,
        hidden: false,
        width: "60%",
        height: "300px",
        top: 100,
        left: 100,
        zIndex: 1,
        viewState: WindowViewStateEnum.Custom,
        params: {}
    },] as WindowType[],
}

可见,就是 WindowType 的列表。同时,还包含一个初始状态,即一个初始窗口。

==未来,桌面上的窗口,在背后都是由这个状态所映射的。==我很喜欢 React、Redux 的这种思想。


窗口操作 Actions

action 下定义了所支持的操作,这里以最常用的创建窗口和销毁窗口为例:

export const deleteWindow = (windowId: number) => ({
    type: DELETE_WINDOW,
    payload: { windowId }
});

export const addWindow = (params: WindowCreateParams) => ({
    type: ADD_WINDOW,
    payload: { params }
});

其中,创建窗口的入参要复杂一点:

export type WindowCreateParams = {
    name: string;
    app: AppTypeEnum;
    params?: any;
};

包含窗口 id、名称、要打开的应用类型,以及启动参数。

需要注意,窗口仅仅是一层包装,窗口内部所要展示的内容,是由 AppTypeEnum 所控制的。在 Redux 中不会根据 AppTypeEnum 创建出具体的应用组件,而是等到在 UI 层,React 组件实际创建时,才会根据 AppTypeEnum 真正创建出对应的内容

另外,params 的类型为 any,并且可为空,这是因为不同的内容组件所需要的参数是不同的。在后续实际创建内容组件的时候,需要对这个 any 类型的 params 加一层类型验证,以及 cast。


窗口 Reducer 实现

本节中介绍,在 Reducer 中,如何对上一节中的两个操作进行处理。

在状态层面,窗口的添加与删除,仅仅是操作 WindowType[] 列表中的数据结构。我非常喜欢这一点。

创建窗口:

case ADD_WINDOW:
    const id = getWindowMaxId(state.elements) + 1;
    const zIndex = getMaxZIndex(state.elements) + 1;
    const appType = action.payload.params.type;

    const newWindow: WindowType = {
        id,
        appType,
        hidden: false,
        width: "100%",
        height: "100vh",
        top: 0,
        left: 0,
        zIndex,
        viewState: WindowViewStateEnum.Fullscreen,
        params: action.payload.params
    };

    return {
        ...state,
        elements: [...state.elements, newWindow]
    };

其中 getWindowMaxId、getMaxZIndex 是从已有从已有的窗口列表中,获取最大的,然后新窗口比最大的再大一个。

删除窗口:

case DELETE_WINDOW:
    elements = [];

    for (let element of state.elements) {
        if (element.id !== action.payload.windowId) {
            elements.push(element);
        }
    }

    return {
        ...state,
        elements: [...elements]
    };

删除操作也是一样,从窗口列表(state.elements)中,把要被删除的那个窗口过滤掉,组成新列表即可。


前端展示:Windows 组件

状态层完成后,接下来可以写前端展示部分了。

首先,我们需要一个组件,将 WindowType[] 状态映射为 React 组件,在 Ray Memex 中该组件叫做 Windows:

export const Windows = () => {
    const windows = useSelector(selectWindows);

    return <WindowStyle>
        {
            windows?.elements?.length && windows.elements.map((window) =>
                <WindowFrame key={window.id} window={window}><span>React95</span></WindowFrame>
            )
        }
    </WindowStyle>
}

整体上也非常直观,首先使用 useSelector 拿出我们所需的状态。然后一个 map 操作,映射到 WindowFrame 组件上面。

未完工的 React95 字样

上面代码中 WindowFrame 的自组件是一段“WindowFrame”文字,这里实际上应当是根据 appType 换出对应的内容组件。这部分代码还在编写当中。

不同从中,能够看出,WindowFrame 与内容组件之间的分工解耦。WindowFrame 并不关心内容组件,它只是一层包装。不管是 WindowFrame 还是内容组件,他们背后都是由 Redux 的 WindowType[] 的一个 WindowType 所映射。


前端展示:WindowFrame 组件

WindowFrame 组件负责对具体的窗口进行绘制,这里我使用了 React95 组件库。React95 组件库非常有意思,它提供了大量 Windows 95 风格的组件,你别说,还真有那个味道!我很喜欢。

WindowFrame 的代码实现如下,其中主要是对 React95 组件的组装,以及一些自定义 Style 的微调。

其中,width、height、top、left 这些,都是从 Redux 状态中映射而来的。这是一种响应式设计模式,非常好。

export const WindowFrame = (props: {
    window: WindowType;
    children: ReactElement
}) => {
    return <Window resizable className="window" style={{
        flexDirection: 'column',
        width: props.window.width,
        height: props.window.height,
        top: props.window.top,
        left: props.window.left,
        zIndex: props.window.zIndex,
        display: props.window.hidden ? "none" : "flex"
    }}>
        <WindowHeader>
            <div style={{
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                width: '100%',
                height: '100%',
                padding: '0',
                boxSizing: 'border-box',

            }}>
                <span>Ray Memex</span>
                <Button style={{
                    display: 'flex',
                    alignItems: 'center',
                    width: '20px',
                    height: '20px',
                    marginLeft: '-1px',
                    marginTop: '-1px',

                }}>
                    <IconClose />
                </Button>
            </div>
        </WindowHeader>
        <Toolbar></Toolbar>
        <WindowContent style={{
            flex: 1,
        }}>
            {/* {props.children} */}
            <div>React95</div>
        </WindowContent>
        <Frame variant='well' className='footer'>
            Put some useful information here
        </Frame>
    </Window>
}

其中,还有一个 React95 字样,这也是 Mock 代码,实际上应当换成 {props.children},把 Windows 组件中制定的内容,透传进来。


功能迭代:窗口拖动

注意,上面这段代码中,窗口的位置是固定的,不支持拖动。在本节中,以窗口拖动需求为例,介绍我们如何对这套系统进行迭代。

首先需要依赖 react-draggable - npm 库。

Note

这一部分代码也是学习自 geo-tp/React-Desktop: Operating system desktop style in a react app 项目。

修改 WindowFrame 为如下实现:

handleResize函数:当窗口大小改变时,这个函数会被调用。它首先检查当前窗口的状态是否为全屏,如果是,那么它会将窗口状态更新为自定义状态。这个函数被传递给useResizeObserver,这是一个自定义的 React Hook,它使用ResizeObserver API来监听元素的大小改变。

handleDrag函数:当用户拖动窗口时,这个函数会被调用。它首先检查当前窗口的状态是否为全屏,如果是,那么它会更新窗口的宽度和高度为50,并将窗口状态更新为自定义状态。然后,它会获取窗口的当前位置,并更新窗口的左和顶部位置。最后,它会更新窗口的translate样式,使其移动到鼠标的位置

Draggable组件:它使得其子组件可以被用户拖动。它接受一个onDrag属性,当用户拖动元素时,这个函数会被调用。在这个例子中,onDrag属性被设置为handleDrag函数。

export const WindowFrame = (props: {
    window: WindowType;
    children: ReactElement
}) => {

    const dispatch = useDispatch();

    const handleResize = useCallback((target: HTMLDivElement) => {
        // dispatch(setIsDragDisable(true));
        if (props.window.viewState === WindowViewStateEnum.Fullscreen) {
            dispatch(updateViewState(props.window.id, WindowViewStateEnum.Custom));
        }
    }, []);

    const frameRef = useResizeObserver(handleResize);

    const handleDrag = (e: any) => {
        if (props.window.viewState === WindowViewStateEnum.Fullscreen) {
            dispatch(updateWidth(props.window.id, 50));
            dispatch(updateHeight(props.window.id, 50));
            dispatch(updateViewState(props.window.id, WindowViewStateEnum.Custom));
        }

        if (frameRef?.current) {
            const pos = frameRef.current.getBoundingClientRect();
            dispatch(updateLeft(props.window.id, pos.x));
            dispatch(updateTop(props.window.id, pos.y));
            frameRef.current.style.translate = `translate(${e.target.clientX}, ${e.target.clientY})`;
        }
    };

    return <Draggable
        defaultPosition={{ x: props.window.left, y: props.window.top }}
        onDrag={(e) => handleDrag(e)}
        // disabled={os.isDragDisable}
        bounds="parent"
    >
        <Window className="window" ref={frameRef} style={{
            flexDirection: 'column',
            width: props.window.width,
            height: props.window.height,
            zIndex: props.window.zIndex,
            display: props.window.hidden ? "none" : "flex",
            resize: "both",
            position: "absolute",
            overflow: 'hidden',
        }}>
            // ......
        </Window>
    </Draggable>
}

这段代码的核心在于 handleDrag 回调方法,有几个要点需要注意:

第一个注意点:frameRef 加载 Window 组件上,而 Window 组件被 Draggable 拖拽组件带着一起走。这样,在触发拖动时,调用 handleDrag,handleDrag 从 frameRef 取出的位置,是拖动后的位置,将其保存到 Redux 中

第二个注意点:我们引入了一个 React Hook,名为 useResizeObserver,这个 ref 同时也监听 Window 组件的缩放,并在大小变化时调用 handleResize。


useResizeObserver

其中使用到一个自定义的React Hook,名为useResizeObserver,它使用了ResizeObserver API来监听一个HTML元素的大小变化。

具体代码如下:

import { useLayoutEffect, useRef } from "react";

function useResizeObserver<T extends HTMLElement>(
    callback: (target: T, entry: ResizeObserverEntry) => void
) {
    const ref = useRef<T>(null);

    useLayoutEffect(() => {
        const element = ref?.current;

        if (!element) {
            return;
        }

        const observer = new ResizeObserver((entries) => {
            callback(element, entries[0]);
        });

        observer.observe(element);
        return () => {
            observer.disconnect();
        };
    }, [callback, ref]);

    return ref;
}

export default useResizeObserver;

其中:

  1. useResizeObserver接受一个回调函数callback作为参数,这个回调函数会在元素大小变化时被调用。这个回调函数接受两个参数:目标元素target和ResizeObserverEntry对象entry

  2. useResizeObserver内部,首先使用useRef创建了一个引用ref,这个引用将被赋值给需要监听大小变化的元素。

  3. 使用useLayoutEffect来创建一个副作用。在这个副作用中,首先获取ref的当前值element,如果element存在,那么创建一个新的ResizeObserver实例,并开始观察element

  4. ResizeObserver的回调函数接受一个entries参数,这是一个包含所有被观察元素的ResizeObserverEntry对象的数组。在这个例子中,我们只关心第一个元素,所以直接使用entries[0]

  5. element的大小变化时,ResizeObserver的回调函数会被调用,然后调用传入useResizeObservercallback函数,并传入elemententries[0]

  6. 最后,useLayoutEffect的回调函数返回一个清理函数,这个函数会在组件卸载或callbackref变化时被调用。在这个清理函数中,调用observer.disconnect()来停止观察所有元素。

  7. useResizeObserver返回ref,这样用户可以将这个ref赋值给需要监听大小变化的元素。


本文作者:Maeiee

本文链接:从零实现桌面窗口管理(前端部分)

版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!


喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!