/ResumeEditor

简历编辑器

Primary LanguageTypeScriptMIT LicenseMIT

ResumeEditor

GithubResume DEMOBLOGTODOFAQ

ResumeEditor简历编辑器,因为各种模版用起来细节上并不是很满意,所以尝试做个简单的拖拽简历编辑器。

$ npm i -g pnpm@6.24.3
$ pnpm install
$ npx husky install && chmod 755 .husky/pre-commit

基础组件

图片组件

图片组件,用以上传图片展示,因为本身没有后端,所以图片只能以base64存储在JSON的结构中。

// src/components/image/index.ts
export const image: LocalComponent = {
  name: "image" as const,
  props: {
    src: "./favicon.ico",
  },
  config: {
    layout: {
      x: 0,
      y: 0,
      w: 20,
      h: 20,
      isDraggable: true,
      isResizable: true,
      minW: 2,
      minH: 2,
    },
  },
  module: {
    control: ImageControl,
    main: ImageMain,
    editor: ImageEditor,
  },
};

富文本组件

富文本组件,用以编辑文字,在这里正好我有一个富文本编辑器的组件实现,可以参考 GithubEditor DEMO

// src/components/text/index.ts
export const richText: LocalComponent = {
  name: "rich-text" as const,
  props: {},
  config: {
    layout: {
      x: 0,
      y: 0,
      w: 20,
      h: 10,
      isDraggable: true,
      isResizable: true,
      minW: 4,
      minH: 2,
    },
    observeResize: true,
  },
  module: {
    control: RichTextControl,
    main: RichText,
    editor: RichTextEditor,
  },
};

空白组件

空白组件,可以用以作为占位空白符,也可以通过配合CSS实现背景效果。

// src/components/blank/index.ts
export const blank: LocalComponent = {
  name: "blank" as const,
  props: {},
  config: {
    layout: {
      x: 0,
      y: 0,
      w: 10,
      h: 3,
      isDraggable: true,
      isResizable: true,
      minW: 1,
      minH: 1,
    },
  },
  module: {
    control: BlankControl,
    main: BlankMain,
    editor: BlankEditor,
  },
};

导出PDF

导出PDF功能是借助了浏览器的能力,通过打印即Ctrl + P来实现导出PDF的效果,导出时需要注意:

  • 简历是按照A4纸的大小固定的宽高,如果扩大编辑区域可能会造成简历多于一页。
  • 导出PDF需要设置 纸张尺寸为A4、选中背景图形选项 才可以完整导出一页简历。
  • 打印面板的边距不可以为无,可以在打印面板使用自定义模式来适当调整边距。
  • 如果导出简历表现异常,可以刷新页面再导出简历,或者在预览模式下使用Ctrl + P导出简历。

实现

数据存储

对于数据而言,在这里是维护了一个JSON数据,对于整个简历编辑器而言都有着比较严格的TS定义,所以预先声明组件类型定义是很有必要的,在这里声明了LocalComponentConfig作为组件的类型定义,而对于整个生成的JSON而言,也就完成了作为LocalComponentConfig[]的嵌套。
在项目中显示的简历是完全采用JSON配置的形式来实现的,数据与视图的渲染是完全分离的,那么由此我们就可以通过编写多个JSON配置的形式,来实现不同简历主题模版。如果打开上边提到的Resume DEMO的话,可以看到预先加载了一个简历,这个简历的内容就是完全由JSON配置而得到的,具体而言可以参考src/components/debug/example.ts。如果数据以local storage字符串的形式存储在本地,键值为cld-storage,如果本地local storage没有这个键的话,就会加载示例的初始简历,数据存储形式为{origin: ${data}, expire: number | number},通过JSON.parse可以解析取出数据。有了这个JSON数据的配置。

// 数据定义
// src/types/components-types.ts
export type LocalComponentConfig =  {
  id: string; // uuid
  name: string;
  props: Record<string, unknown>;
  style: React.CSSProperties;
  config: Record<string, unknown>;
  children: LocalComponentConfig[];
  [key: string]: unknown;
};

在这里实际上我们有两套数据结构的定义,因为目的是实现数据与组件的分离,但是组件也是需要有位置进行定义的,此外由于希望整个编辑器是可拆卸的,具体而言就是每个基础组件是独立注册的,如果将其注册部分移除,对于整个项目是不会产生任何影响的,只是视图无法根据JSON的配置成功渲染,最终呈现的效果为空而已。

// 组件定义
// src/types/components-types.ts
interface ComponentsBase {
  name: string;
  props?: Record<string, unknown>; // 传递给组件的默认`props`
  style?: React.CSSProperties; // 样式配置信息
  config?: Record<string, unknown>; // 配置信息
}
export interface LocalComponent extends ComponentsBase {
  module: Panel;
}

// 组件定义
export const xxx: LocalComponent = {
    // ...
}

// 组件注册 
// src/index.tsx
register(image, richText, blank);

数据通信

因为要维护的JSON数据结构还是比较复杂的,在这里我们使用Context + useImmerReducer来实现的状态管理,当然使用reducer或者Mobx也都是可以的,这只是我觉得实现的比较简单的方案。

// src/store/context.tsx
export const AppProvider: React.FC<{ mode?: ContextProps["mode"] }> = props => {
  const { mode = EDITOR_MODE.EDITOR, children } = props;
  const [state, dispatch] = useImmerReducer(reducer, defaultContext.state);
  return <AppContext.Provider value={{ state, mode, dispatch }}>{children}</AppContext.Provider>;
};

页面网格布局

网格布局的实现比较简单,而且不需要再实现参考线去做对齐的功能,直接在拖拽时显示网格就好。另外如果以后会拓展多种宽度的PDF生成的话,也不会导致之前画布布局太过于混乱,因为本身就是栅格的实现,可以根据宽度自动的处理,当然要是适配移动端的话还是需要再做一套Layout数据的。
这个网格的页面布局实际上就是作为整个页面布局的画布来实现,React的生态有很多这方面的库,我使用了react-grid-layout这个库来实现拖拽,具体使用的话可以在本文的参考部分找到其Github链接,这个库的实现也是蛮不错的,基本可以做到开箱即用,但是细节方面还是很多东西需要处理的。对于layout配置项,因为我们本身是存储了一个JSON的数据结构,所以我们需要通过我们自己定义的数据结构来生成layout,在生成的过程中如果cols或者rowHeight有所变化而导致元素超出原定范围的话,还需要处理一下。

// src/views/main-panel/index.tsx
<ReferenceLine
    display={!isRender && dragging}
    rows={rowHeight}
    cols={cols}
>
    <ResponsiveGridLayout
        className="pedestal-responsive-grid-layout"
        style={{ minHeight }}
        layout={layouts}
        autoSize
        draggableHandle=".pedestal-drag-dot"
        margin={[0, 0]}
        onLayoutChange={layoutChange}
        cols={cols}
        rowHeight={rowHeight}
        measureBeforeMount
        onDragStart={dragStart}
        onDragStop={dragStop}
        onResizeStart={resizeStart}
        onResizeStop={resizeStop}
        allowOverlap={allowOverlap}
        compactType={null} // 关闭垂直压实
        preventCollision // 关闭重新排列
        useCSSTransforms={false} // 在`ObserveResize`时会出现动画
        >
    </ResponsiveGridLayout>
</ReferenceLine>

对于<ReferenceLine/>组件,在这里通过CSS绘制了网格布局的网格点,从而实现参考线的作用。

// src/views/main-panel/components/reference-line/index.tsx
<div
    className={classes(
    "pedestal-main-reference-line",
    props.className,
    props.display && "enable"
    )}
    style={{
    backgroundSize: `${cellWidth}px ${props.rows}px`,
    backgroundPositionX: cellWidth / 2,
    backgroundPositionY: -props.rows / 2,
    ...props.style,
    // background-image: radial-gradient(circle, #999 0.8px, transparent 0);
    }}
    ref={referenceLineRef}
>
    {props.children}
</div>

组件独立编辑

有了基础的画布组件,我们就需要实现各个基础组件,那么基础组件就需要实现独立的编辑功能,而独立的编辑功能又需要三部分的实现:首先是数据的变更,因为编辑最终还是需要体现到数据上,也就是我们要维护的那个JSON数据,因为我们有了数据通信的方案,所以这里只需要定义reducer将其写到对应的组件配置的props或者其他字段中即可。

// src/store/reducer.ts
witch (action.type) {
    // ...
    case actions.UPDATE_ONE: {
        const { id: uuid, key, data, merge = true } = action.payload;
        updateOneInNodeTree(state.cld.children, uuid, key, data, merge);
        break;
    }
    // ...
}

// src/utils/node-tree-utils.ts
/**
 * @param tree LocalComponentConfig.children
 * @param uuid string
 * @param key string
 * @param data unknown
 * @returns boolean
 */
export const updateOneInNodeTree = (
  tree: LocalComponentConfig["children"],
  uuid: string,
  key: string,
  data: unknown,
  merge: boolean
): boolean => {
  const node = findOneInNodeTree(tree, uuid);
  if (!node) return false;
  let preKeyData: unknown = node;
  const deepKey = key.split(".");
  const lastKey = deepKey[deepKey.length - 1];
  for (let i = 0, n = deepKey.length - 1; i < n; ++i) {
    if (isObject(preKeyData)) preKeyData = preKeyData[deepKey[i]];
    else return false;
  }
  if (isObject(preKeyData)) {
    const target = preKeyData[lastKey];
    if (isObject(target) && isObject(data)) {
      if (merge) preKeyData[lastKey] = { ...target, ...data };
      else preKeyData[lastKey] = { ...data };
    } else {
      preKeyData[lastKey] = data;
    }
    return true;
  }
  return false;
};

接下来是工具栏的实现,对于工具栏而言,我们需要针对选中的元素的name进行一个判别,加载工具栏之后,对于用户的操作,只需要根据当前选中的id通过数据通信应用到JSON数据中,最后在视图中就会应用其修改了。

// src/views/main-panel/components/tool-bar/index.tsx
const deleteBaseSection = () => {
    // ...
};

const copySection = () => {
    // ...
};

// ...

<Trigger
    popupVisible={selectedId === config.id}
    popup={() => Menu}
    position="top"
    trigger="contextMenu"
>
    {props.children}
</Trigger>

对于编辑面板而言,与工具栏类似,通过加载表单,在表单的数据变动之后通过reducer应用到JSON数据即可,在这里因为实现的编辑器确实比较简单,于是还加载了一个CSS编辑器,通过配合CSS可以实现更多的样式效果,当然通过拓展各个组件编辑面板部分是能够尽量去减少自定义CSS的编写的。

// src/views/editor-panel/index.tsx
const renderEditor = () => {
const [selectNodeName] = state.selectedNode.name.split(".");
    if (!selectNodeName) return null;
    const componentInstance = getComponentInstanceSync(selectNodeName);
    if (!componentInstance || !componentInstance.main) return null;
    const Component = componentInstance.editor;
    return (
        <>
            <Component state={state} dispatch={dispatch}></Component>
            <CustomCSS state={state} dispatch={dispatch}></CustomCSS>
        </>
    );
};

// eslint-disable-next-line react-hooks/exhaustive-deps
const EditorPanel = useMemo(() => renderEditor(), [state.selectedNode.id]);