{"id":16974713,"url":"https://github.com/windrunnermax/resumeeditor","last_synced_at":"2025-03-17T08:38:06.045Z","repository":{"id":41552692,"uuid":"509902555","full_name":"WindRunnerMax/ResumeEditor","owner":"WindRunnerMax","description":"简历编辑器","archived":false,"fork":false,"pushed_at":"2024-03-29T14:10:20.000Z","size":17389,"stargazers_count":94,"open_issues_count":2,"forks_count":17,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-03-16T10:23:36.238Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://windrunnermax.github.io/ResumeEditor/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/WindRunnerMax.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2022-07-03T02:15:08.000Z","updated_at":"2025-03-09T09:43:40.000Z","dependencies_parsed_at":"2024-03-24T03:37:06.567Z","dependency_job_id":null,"html_url":"https://github.com/WindRunnerMax/ResumeEditor","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WindRunnerMax%2FResumeEditor","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WindRunnerMax%2FResumeEditor/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WindRunnerMax%2FResumeEditor/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WindRunnerMax%2FResumeEditor/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/WindRunnerMax","download_url":"https://codeload.github.com/WindRunnerMax/ResumeEditor/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":244001752,"owners_count":20381838,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-14T01:07:43.282Z","updated_at":"2025-03-17T08:38:05.432Z","avatar_url":"https://github.com/WindRunnerMax.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ResumeEditor\n [Github](https://github.com/WindrunnerMax/ResumeEditor) ｜ [Resume DEMO](https://windrunnermax.github.io/ResumeEditor/) ｜ [BLOG](https://github.com/WindrunnerMax/EveryDay/blob/master/Plugin/%E5%9F%BA%E4%BA%8ENoCode%E6%9E%84%E5%BB%BA%E7%AE%80%E5%8E%86%E7%BC%96%E8%BE%91%E5%99%A8.md) ｜ [TODO](./TODO.MD) ｜ [FAQ](https://github.com/WindrunnerMax/ResumeEditor/issues/2)\n \n`ResumeEditor`简历编辑器，因为各种模版用起来细节上并不是很满意，所以尝试做个简单的拖拽简历编辑器。\n\n```bash\n$ npm i -g pnpm@6.24.3\n$ pnpm install\n$ npx husky install \u0026\u0026 chmod 755 .husky/pre-commit\n```\n\n## 基础组件\n\n### 图片组件\n图片组件，用以上传图片展示，因为本身没有后端，所以图片只能以`base64`存储在`JSON`的结构中。\n\n```typescript\n// src/components/image/index.ts\nexport const image: LocalComponent = {\n  name: \"image\" as const,\n  props: {\n    src: \"./favicon.ico\",\n  },\n  config: {\n    layout: {\n      x: 0,\n      y: 0,\n      w: 20,\n      h: 20,\n      isDraggable: true,\n      isResizable: true,\n      minW: 2,\n      minH: 2,\n    },\n  },\n  module: {\n    control: ImageControl,\n    main: ImageMain,\n    editor: ImageEditor,\n  },\n};\n```\n\n### 富文本组件\n富文本组件，用以编辑文字，在这里正好我有一个富文本编辑器的组件实现，可以参考 [Github](https://github.com/WindrunnerMax/DocEditor) ｜ [Editor DEMO](https://windrunnermax.github.io/DocEditor/)。\n\n```typescript\n// src/components/text/index.ts\nexport const richText: LocalComponent = {\n  name: \"rich-text\" as const,\n  props: {},\n  config: {\n    layout: {\n      x: 0,\n      y: 0,\n      w: 20,\n      h: 10,\n      isDraggable: true,\n      isResizable: true,\n      minW: 4,\n      minH: 2,\n    },\n    observeResize: true,\n  },\n  module: {\n    control: RichTextControl,\n    main: RichText,\n    editor: RichTextEditor,\n  },\n};\n```\n\n### 空白组件\n空白组件，可以用以作为占位空白符，也可以通过配合`CSS`实现背景效果。\n\n```typescript\n// src/components/blank/index.ts\nexport const blank: LocalComponent = {\n  name: \"blank\" as const,\n  props: {},\n  config: {\n    layout: {\n      x: 0,\n      y: 0,\n      w: 10,\n      h: 3,\n      isDraggable: true,\n      isResizable: true,\n      minW: 1,\n      minH: 1,\n    },\n  },\n  module: {\n    control: BlankControl,\n    main: BlankMain,\n    editor: BlankEditor,\n  },\n};\n```\n\n## 导出PDF\n导出`PDF`功能是借助了浏览器的能力，通过打印即`Ctrl + P`来实现导出PDF的效果，导出时需要注意:\n* 简历是按照`A4`纸的大小固定的宽高，如果扩大编辑区域可能会造成简历多于一页。\n* 导出`PDF`需要设置 纸张尺寸为`A4`、选中背景图形选项 才可以完整导出一页简历。\n* 打印面板的边距不可以为无，可以在打印面板使用自定义模式来适当调整边距。\n* 如果导出简历表现异常，可以刷新页面再导出简历，或者在预览模式下使用`Ctrl + P`导出简历。\n\n## 实现\n\n### 数据存储\n对于数据而言，在这里是维护了一个`JSON`数据，对于整个简历编辑器而言都有着比较严格的`TS`定义，所以预先声明组件类型定义是很有必要的，在这里声明了`LocalComponentConfig`作为组件的类型定义，而对于整个生成的`JSON`而言，也就完成了作为`LocalComponentConfig[]`的嵌套。  \n在项目中显示的简历是完全采用`JSON`配置的形式来实现的，数据与视图的渲染是完全分离的，那么由此我们就可以通过编写多个`JSON`配置的形式，来实现不同简历主题模版。如果打开上边提到的`Resume DEMO`的话，可以看到预先加载了一个简历，这个简历的内容就是完全由`JSON`配置而得到的，具体而言可以参考`src/components/debug/example.ts`。如果数据以`local storage`字符串的形式存储在本地，键值为`cld-storage`，如果本地`local storage`没有这个键的话，就会加载示例的初始简历，数据存储形式为`{origin: ${data}, expire: number | number}`，通过`JSON.parse`可以解析取出数据。有了这个`JSON`数据的配置。\n\n```typescript\n// 数据定义\n// src/types/components-types.ts\nexport type LocalComponentConfig =  {\n  id: string; // uuid\n  name: string;\n  props: Record\u003cstring, unknown\u003e;\n  style: React.CSSProperties;\n  config: Record\u003cstring, unknown\u003e;\n  children: LocalComponentConfig[];\n  [key: string]: unknown;\n};\n```\n\n在这里实际上我们有两套数据结构的定义，因为目的是实现数据与组件的分离，但是组件也是需要有位置进行定义的，此外由于希望整个编辑器是可拆卸的，具体而言就是每个基础组件是独立注册的，如果将其注册部分移除，对于整个项目是不会产生任何影响的，只是视图无法根据`JSON`的配置成功渲染，最终呈现的效果为空而已。\n\n```typescript\n// 组件定义\n// src/types/components-types.ts\ninterface ComponentsBase {\n  name: string;\n  props?: Record\u003cstring, unknown\u003e; // 传递给组件的默认`props`\n  style?: React.CSSProperties; // 样式配置信息\n  config?: Record\u003cstring, unknown\u003e; // 配置信息\n}\nexport interface LocalComponent extends ComponentsBase {\n  module: Panel;\n}\n\n// 组件定义\nexport const xxx: LocalComponent = {\n    // ...\n}\n\n// 组件注册 \n// src/index.tsx\nregister(image, richText, blank);\n```\n\n### 数据通信\n因为要维护的`JSON`数据结构还是比较复杂的，在这里我们使用`Context + useImmerReducer`来实现的状态管理，当然使用`reducer`或者`Mobx`也都是可以的，这只是我觉得实现的比较简单的方案。\n\n```typescript\n// src/store/context.tsx\nexport const AppProvider: React.FC\u003c{ mode?: ContextProps[\"mode\"] }\u003e = props =\u003e {\n  const { mode = EDITOR_MODE.EDITOR, children } = props;\n  const [state, dispatch] = useImmerReducer(reducer, defaultContext.state);\n  return \u003cAppContext.Provider value={{ state, mode, dispatch }}\u003e{children}\u003c/AppContext.Provider\u003e;\n};\n```\n\n\n### 页面网格布局\n网格布局的实现比较简单，而且不需要再实现参考线去做对齐的功能，直接在拖拽时显示网格就好。另外如果以后会拓展多种宽度的`PDF`生成的话，也不会导致之前画布布局太过于混乱，因为本身就是栅格的实现，可以根据宽度自动的处理，当然要是适配移动端的话还是需要再做一套`Layout`数据的。  \n这个网格的页面布局实际上就是作为整个页面布局的画布来实现，`React`的生态有很多这方面的库，我使用了`react-grid-layout`这个库来实现拖拽，具体使用的话可以在本文的参考部分找到其`Github`链接，这个库的实现也是蛮不错的，基本可以做到开箱即用，但是细节方面还是很多东西需要处理的。对于`layout`配置项，因为我们本身是存储了一个`JSON`的数据结构，所以我们需要通过我们自己定义的数据结构来生成`layout`，在生成的过程中如果`cols`或者`rowHeight`有所变化而导致元素超出原定范围的话，还需要处理一下。\n\n```typescript\n// src/views/main-panel/index.tsx\n\u003cReferenceLine\n    display={!isRender \u0026\u0026 dragging}\n    rows={rowHeight}\n    cols={cols}\n\u003e\n    \u003cResponsiveGridLayout\n        className=\"pedestal-responsive-grid-layout\"\n        style={{ minHeight }}\n        layout={layouts}\n        autoSize\n        draggableHandle=\".pedestal-drag-dot\"\n        margin={[0, 0]}\n        onLayoutChange={layoutChange}\n        cols={cols}\n        rowHeight={rowHeight}\n        measureBeforeMount\n        onDragStart={dragStart}\n        onDragStop={dragStop}\n        onResizeStart={resizeStart}\n        onResizeStop={resizeStop}\n        allowOverlap={allowOverlap}\n        compactType={null} // 关闭垂直压实\n        preventCollision // 关闭重新排列\n        useCSSTransforms={false} // 在`ObserveResize`时会出现动画\n        \u003e\n    \u003c/ResponsiveGridLayout\u003e\n\u003c/ReferenceLine\u003e\n```\n\n对于`\u003cReferenceLine/\u003e`组件，在这里通过`CSS`绘制了网格布局的网格点，从而实现参考线的作用。\n\n```typescript\n// src/views/main-panel/components/reference-line/index.tsx\n\u003cdiv\n    className={classes(\n    \"pedestal-main-reference-line\",\n    props.className,\n    props.display \u0026\u0026 \"enable\"\n    )}\n    style={{\n    backgroundSize: `${cellWidth}px ${props.rows}px`,\n    backgroundPositionX: cellWidth / 2,\n    backgroundPositionY: -props.rows / 2,\n    ...props.style,\n    // background-image: radial-gradient(circle, #999 0.8px, transparent 0);\n    }}\n    ref={referenceLineRef}\n\u003e\n    {props.children}\n\u003c/div\u003e\n```\n\n\n### 组件独立编辑\n有了基础的画布组件，我们就需要实现各个基础组件，那么基础组件就需要实现独立的编辑功能，而独立的编辑功能又需要三部分的实现：首先是数据的变更，因为编辑最终还是需要体现到数据上，也就是我们要维护的那个`JSON`数据，因为我们有了数据通信的方案，所以这里只需要定义`reducer`将其写到对应的组件配置的`props`或者其他字段中即可。\n\n\n```typescript\n// src/store/reducer.ts\nwitch (action.type) {\n    // ...\n    case actions.UPDATE_ONE: {\n        const { id: uuid, key, data, merge = true } = action.payload;\n        updateOneInNodeTree(state.cld.children, uuid, key, data, merge);\n        break;\n    }\n    // ...\n}\n\n// src/utils/node-tree-utils.ts\n/**\n * @param tree LocalComponentConfig.children\n * @param uuid string\n * @param key string\n * @param data unknown\n * @returns boolean\n */\nexport const updateOneInNodeTree = (\n  tree: LocalComponentConfig[\"children\"],\n  uuid: string,\n  key: string,\n  data: unknown,\n  merge: boolean\n): boolean =\u003e {\n  const node = findOneInNodeTree(tree, uuid);\n  if (!node) return false;\n  let preKeyData: unknown = node;\n  const deepKey = key.split(\".\");\n  const lastKey = deepKey[deepKey.length - 1];\n  for (let i = 0, n = deepKey.length - 1; i \u003c n; ++i) {\n    if (isObject(preKeyData)) preKeyData = preKeyData[deepKey[i]];\n    else return false;\n  }\n  if (isObject(preKeyData)) {\n    const target = preKeyData[lastKey];\n    if (isObject(target) \u0026\u0026 isObject(data)) {\n      if (merge) preKeyData[lastKey] = { ...target, ...data };\n      else preKeyData[lastKey] = { ...data };\n    } else {\n      preKeyData[lastKey] = data;\n    }\n    return true;\n  }\n  return false;\n};\n```\n\n接下来是工具栏的实现，对于工具栏而言，我们需要针对选中的元素的`name`进行一个判别，加载工具栏之后，对于用户的操作，只需要根据当前选中的`id`通过数据通信应用到`JSON`数据中，最后在视图中就会应用其修改了。\n\n```typescript\n// src/views/main-panel/components/tool-bar/index.tsx\nconst deleteBaseSection = () =\u003e {\n    // ...\n};\n\nconst copySection = () =\u003e {\n    // ...\n};\n\n// ...\n\n\u003cTrigger\n    popupVisible={selectedId === config.id}\n    popup={() =\u003e Menu}\n    position=\"top\"\n    trigger=\"contextMenu\"\n\u003e\n    {props.children}\n\u003c/Trigger\u003e\n```\n\n对于编辑面板而言，与工具栏类似，通过加载表单，在表单的数据变动之后通过`reducer`应用到`JSON`数据即可，在这里因为实现的编辑器确实比较简单，于是还加载了一个`CSS`编辑器，通过配合`CSS`可以实现更多的样式效果，当然通过拓展各个组件编辑面板部分是能够尽量去减少自定义`CSS`的编写的。\n\n```typescript\n// src/views/editor-panel/index.tsx\nconst renderEditor = () =\u003e {\nconst [selectNodeName] = state.selectedNode.name.split(\".\");\n    if (!selectNodeName) return null;\n    const componentInstance = getComponentInstanceSync(selectNodeName);\n    if (!componentInstance || !componentInstance.main) return null;\n    const Component = componentInstance.editor;\n    return (\n        \u003c\u003e\n            \u003cComponent state={state} dispatch={dispatch}\u003e\u003c/Component\u003e\n            \u003cCustomCSS state={state} dispatch={dispatch}\u003e\u003c/CustomCSS\u003e\n        \u003c/\u003e\n    );\n};\n\n// eslint-disable-next-line react-hooks/exhaustive-deps\nconst EditorPanel = useMemo(() =\u003e renderEditor(), [state.selectedNode.id]);\n```\n\n\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwindrunnermax%2Fresumeeditor","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwindrunnermax%2Fresumeeditor","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwindrunnermax%2Fresumeeditor/lists"}