基於NoCode構建簡歷編輯器
基於NoCode
構建簡歷編輯器,要參加秋招了,因爲各種模版用起來細節上並不是很滿意,所以嘗試做個簡單的拖拽簡歷編輯器。
描述
對於無代碼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,
},
};
富文本組件
富文本組件,用以編輯文字,在這裏正好我有一個富文本編輯器的組件實現,可以參考 Github | Editor 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