基於NoCode構建簡歷編輯器

2022-07-24 12:00:30

基於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