基於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
功能是藉助了瀏覽器的能力,通過列印即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