基於NoCode構建簡歷編輯器

基於NoCode構建簡歷編輯器

基於NoCode構建簡歷編輯器,要參加秋招了,因爲各種模版用起來細節上並不是很滿意,所以嘗試做個簡單的拖拽簡歷編輯器。

描述

GithubResume DEMO

對於無代碼NoCode和低代碼LowCode還是比較容易混淆的,在我的理解上,NoCode強調自己編程給自己用,給用戶的感覺是一個更強大的實用軟件,是一個上層的應用,也就是說NoCode需要面向非常固定的領域才能做到好用;而對於LowCode而言,除了要考慮能用界面化的方式搭建流程,還要考慮在需要擴展的時候,把底層也暴露出來,擁有更強的可定製化功能,也就是說相比NoCode可以不把使用場景限定得那麼固定。
對於簡歷編輯器而言,這就算是非常固定的領域了,而且在使用方面不需要去實現過多代碼的編寫,開箱即用即可,是作爲一個上層應用而實現的。對於我個人而言就是單純的因爲要秋招了,網站上各種模版用起來細節上並不是很滿意,在晚上睡覺前洗澡的時候突然有個想法要做這個,然後一個週末也就是兩天的時間肝出來了一個簡單的基於NoCode的簡歷編輯器。
說回正題,對於實現簡歷編輯器而言,需要有這幾個方面的考慮,當然因爲我是兩天做出來的,也只是比較簡單的實現了部分功能:

  • 需要支持拖動的頁面網格佈局或自由佈局。
  • 對各組件有獨立編輯的能力。
  • 生成PDF與預覽頁面的功能。
  • 生成JSON格式的配置數據。
  • 支持遠程物料簡歷模版的加載。
  • 基礎組件圖片、文本等的實現。

實現

數據存儲

對於數據而言,在這裏是維護了一個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]);

導出PDF

導出PDF功能是藉助了瀏覽器的能力,通過打印即Ctrl + P來實現導出PDF的效果,導出時需要注意:

  • 簡歷是按照A4紙的大小固定的寬高,如果擴大編輯區域可能會造成簡歷多於一頁。
  • 導出PDF需要設置紙張尺寸爲 A4、邊距爲無、選中背景圖形選項 纔可以完整導出一頁簡歷。

基礎組件

圖片組件

圖片組件,用以上傳圖片展示,因爲本身沒有後端,所以圖片只能以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,
  },
};

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

http://javakk.com/2127.html
http://blog.wuweiwang.cn/?p=27961
https://github.com/ctrlplusb/react-sizeme
https://juejin.cn/post/6961309077162950692
https://github.com/WindrunnerMax/DocEditor
https://github.com/react-grid-layout/react-grid-layout
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章