吐司问卷:问卷编辑器 II
Date: February 26, 2025
Log
**软件操持的可拓展性:**对修改封闭,对拓睁开放
工具栏
删除组件
需求:
要点:
- 实现删除选中组件
- 思绪:重新盘算 selectedId,优先选择下一个,没有下一个则选择上一个
- 以上通过componentReducer工具函数utils
componentReducer/index.ts
- removeSelectedComponent: (draft: ComponentsStateType) => {
- const { selectedId: removeId, componentList } = draft
- // 重新计算 selectedId, 优先选择下一个,没有下一个则选择上一个
- const nextSelectedId = getNextSelectedId(removeId, componentList)
- draft.selectedId = nextSelectedId
- // 删除组件
- const index = componentList.findIndex(c => c.fe_id === removeId)
- componentList.splice(index, 1)
- },
复制代码 componentReducer/utils.ts
- import { ComponentInfoType } from './index'
- /**
- * 获取下一个选中的组件 id
- * @param fe_id 当前选中的组件 id
- * @param componentList 组件列表
- * @returns 下一个选中的组件 id
- */
- export function getNextSelectedId(
- fe_id: string,
- componentList: ComponentInfoType[]
- ) {
- const index = componentList.findIndex(c => c.fe_id === fe_id)
- if (index < 0) return ''
- if (index === componentList.length - 1) {
- return componentList[index - 1].fe_id
- } else {
- return componentList[index + 1].fe_id
- }
- }
复制代码 隐蔽/表现组件
需求:
要点:
- 界说属性 isHidden(Mock + Redux store)
- Redux中changeComponentHidden修改 isHidden,实现 表现/隐蔽 功能
- 修复埋伏标题:隐蔽组件属性袒露
思绪:
componentReducer 中先界说属性 isHidden ,Redux 中实现 changeComponentHidden
用于修改 isHidden,从而实现 表现/隐蔽功能。不外,记得页面的 componentList 必要过滤掉隐蔽的信息,而且在处置处罚组件对应属性面板的时间,也得先过滤掉隐蔽的信息,再做选中下个组件逻辑。
埋伏标题:
当组件A上面有隐蔽组件B时,隐蔽组件A,右侧的组件属性面板会表现B的属性。
参考服务端的 Mock 数据如下:
- {
- fe_id: Random.id(),
- type: 'questionInput',
- title: '这是一个输入框组件',
- isHidden: false,
- props: {
- title: '你的电话',
- placeholder: '请输入内容'
- }
- },
- {
- fe_id: Random.id(),
- type: 'questionInput',
- title: '这是一个输入框组件',
- isHidden: true,
- props: {
- title: '隐藏咯!!!',
- placeholder: '请输入内容'
- }
- },
- {
- fe_id: Random.id(),
- type: 'questionInput',
- title: '这是一个输入框组件',
- isHidden: false,
- props: {
- title: '上面有一个隐藏元素',
- placeholder: '请输入内容'
- }
- }
复制代码 EditCanvas.tsx 组件列表更新后去除隐蔽组件
- }
- return (
- <div className={styles.canvas}>
- {componentList
- .filter(c => !c.isHidden)
- .map(c => {
- const { fe_id } = c
- // 拼接 class name
- const wrapperDefaultClassName = styles['component-wrapper']
复制代码 componentReducer/index.ts Redux实现隐蔽组件
- export type ComponentInfoType = {
- fe_id: string
- type: string
- title: string
- isHidden?: boolean
- props: ComponentPropsType
- }
- changeComponentHidden: (
- draft: ComponentsStateType,
- action: PayloadAction<{ fe_id: string; isHidden: boolean }>
- ) => {
- const { componentList } = draft
- const { fe_id, isHidden } = action.payload
- const component = draft.componentList.find(c => c.fe_id === fe_id)
- // 重新计算 selectedId, 优先选择下一个,没有下一个则选择上一个
- let newSelectedId = ''
- if (isHidden) {
- newSelectedId = getNextSelectedId(fe_id, componentList)
- } else {
- newSelectedId = fe_id
- }
- draft.selectedId = newSelectedId
- if (component) {
- component.isHidden = isHidden
- }
- },
复制代码 componentReducer/utils.ts 重新盘算 selected 时必要过滤隐蔽元素
- import { ComponentInfoType } from './index'
- /**
- * 获取下一个选中的组件 id
- * @param fe_id 当前选中的组件 id
- * @param componentList 组件列表
- * @returns 下一个选中的组件 id
- */
- export function getNextSelectedId(
- fe_id: string,
- componentList: ComponentInfoType[]
- ) {
- // 重新计算 selected 时需要过滤隐藏元素
- const visibleComponentList = componentList.filter(c => !c.isHidden)
- const index = visibleComponentList.findIndex(c => c.fe_id === fe_id)
- if (index < 0) return ''
- if (index === visibleComponentList.length - 1) {
- return visibleComponentList[index - 1].fe_id
- } else {
- return visibleComponentList[index + 1].fe_id
- }
- }
复制代码 锁定/解锁组件
需求:
思绪:
分析需求:
当点击锁定按钮时,大概必要转达锁定这个参数,因此,先从数据层面入手:
数据层面:先为组件参数界说 isLocked 属性,并在 Redux 中操持 锁定逻辑
逻辑层面:定位到顶部的工具栏,获取 Redux 中的锁定函数,并绑定到对应组件。
样式层面:当点击实现锁定效果
别的,当点击对应组件,属性面板组件也必要锁定,这一块也得必要分析:
先从数据层面入手
数据层面:表单锁定,根据 AntD,大概必要 disable 的属性,因此我们必要为属性面板的组件添加 disabled 的参数设定。
逻辑层面:点击画布中组件时,转达 isHidden 到属性组件中,也就是属性面板,假如画布中组件是锁定的,那么我们就转达 disable 给组件对应的属性面板。
样式层面:给表单添加 disabled 属性即可。
要点:
- 数据:
- 界说属性 isLocked(Mock + Redux store)
- 变革:
- 面板组件锁定:界说 Redux 中 toggleComponentLock 处置处罚锁定
- 组件属性面板锁定:属性面板,组件锁定则禁用 form
- 样式:
Code:
componentReducer/index.ts
- toggleComponentLock: (
- draft: ComponentsStateType,
- action: PayloadAction<{ fe_id: string }>
- ) => {
- const { fe_id } = action.payload
- const component = draft.componentList.find(c => c.fe_id === fe_id)
- if (component) {
- component.isLocked = !component.isLocked
- }
- },
复制代码 样式:
EditCanvas.tsx
- <div className={styles.canvas}>
- {componentList
- .filter(c => !c.isHidden)
- .map(c => {
- const { fe_id, isLocked } = c
- // 样式处理
- const wrapperDefaultClassName = styles['component-wrapper']
- const selectedClassName = styles.selected
- const locked = styles.locked
- const wrapperClassName = classNames({
- [wrapperDefaultClassName]: true,
- [selectedClassName]: fe_id === selectedId,
- [locked]: isLocked,
- })
- return (
- <div
- key={fe_id}
- className={wrapperClassName}
- onClick={e => handleClick(e, fe_id || '')}
- >
- <div className={styles.component}>{getComponent(c)}</div>
- </div>
- )
- })}
- </div>
复制代码 EditCanvas.module.scss
- .locked {
- opacity: 0.5;
- cursor: not-allowed;
- }
复制代码 属性面板,组件锁定则禁用 form
componentProp.tsx
- <PropComponent
- {...props}
- disabled={isLocked || isHidden}
- onChange={changeProps}
- />
复制代码 复制/粘贴组件
需求:
要点:
- 在 Redux store 中存储复制的内容 copiedComponent
- 粘贴按钮,判断是否 disabled
- 公共代码抽离 insertNewComponent :新增组件逻辑
思绪:
需求:点击组件,然后点击复制按钮,再选择位置,后点击粘贴,将拷贝的组件插入对应位置。
数据层面:
- 组件状态必要新增 copiedComponent 状态,用于处置处罚粘贴。
逻辑层面:
- 选中组件,再点击复制按钮,将 selected 转到达 redux 中
- Redux中设定 拷贝和粘贴 函数,根据 selectedId 深度拷贝对应组件,然后天生具有新的id的深拷贝组件,末了插入到对应位置即可。
utils.ts
- /**
- * 插入新组件
- * @param draft 组件状态
- * @param newCompontent 新组件
- * @returns
- */
- export const insertNewComponent = (
- draft: ComponentsStateType,
- newCompontent: ComponentInfoType
- ) => {
- const { selectedId, componentList } = draft
- const index = componentList.findIndex(c => c.fe_id === selectedId)
- if (index < 0) {
- draft.componentList.push(newCompontent)
- } else {
- draft.componentList.splice(index + 1, 0, newCompontent)
- }
- draft.selectedId = newCompontent.fe_id
- }
复制代码 componentReducer/index.ts
- export type ComponentsStateType = {
- selectedId: string
- componentList: Array<ComponentInfoType>
- copiedComponent: ComponentInfoType | null
- }
- const INIT_STATE: ComponentsStateType = {
- selectedId: '',
- componentList: [],
- copiedComponent: null,
- }
- ------
- copySelectedComponent: (draft: ComponentsStateType) => {
- const { selectedId, componentList } = draft
- const selectedComponent = componentList.find(c => c.fe_id === selectedId)
- if (selectedComponent) {
- draft.copiedComponent = clonedeep(selectedComponent)
- }
- },
- pasteCopiedComponent: (draft: ComponentsStateType) => {
- const { copiedComponent } = draft
- if (!copiedComponent) return
- const newCopiedComponent = clonedeep(copiedComponent)
- newCopiedComponent.fe_id = nanoid()
- insertNewComponent(draft, newCopiedComponent)
- },
复制代码 画布增长速捷键
需求:
要点:
- 删除、复制、粘贴、上下选中功能
- 处置处罚埋伏标题:属性面板举行 backspace 时,会删除画布组件
**埋伏标题:**属性面板举行 backspace 时,会删除画布组件
办理方案:
点击input组件表现的时间 <input … />,点击其他组件,好比画布组件会表现
根据以上这点,来处置处罚删除快捷键标题。
- function isActiveElementValid() {
- const activeElement = document.activeElement
- // 光标没有 focus 到 ipnut 上
- if (activeElement === document.body) {
- return true
- }
- return false
- }
复制代码 useBindCanvasKeyPress.tsx
- import { useDispatch } from 'react-redux'import { removeSelectedComponent, copySelectedComponent, pasteCopiedComponent, selectPrevComponent, selectNextComponent,} from '../store/componentReducer'import { useKeyPress } from 'ahooks'/** * 判断光标是否在 input 上 * @returns * true: 光标在 input 上 * false: 光标不在 input 上 * */function isActiveElementValid() {
- const activeElement = document.activeElement
- // 光标没有 focus 到 ipnut 上
- if (activeElement === document.body) {
- return true
- }
- return false
- }
- const useBindCanvasKeyPress = () => { const dispatch = useDispatch() // 删除选中的组件 useKeyPress(['Delete', 'backspace'], () => { if (!isActiveElementValid()) return dispatch(removeSelectedComponent()) }) // 复制选中的组件 useKeyPress(['ctrl.c', 'meta.c'], () => { if (!isActiveElementValid()) return dispatch(copySelectedComponent()) }) // 粘贴复制的组件 useKeyPress(['ctrl.v', 'meta.v'], () => { if (!isActiveElementValid()) return dispatch(pasteCopiedComponent()) }) // 选中上一个组件 useKeyPress(['uparrow'], () => { if (!isActiveElementValid()) return dispatch(selectPrevComponent()) }) // 选中下一个组件 useKeyPress(['downarrow'], () => { if (!isActiveElementValid()) return dispatch(selectNextComponent()) })}export default useBindCanvasKeyPress
复制代码 componentReducer.tsx
- selectPrevComponent: (draft: ComponentsStateType) => {
- const { selectedId, componentList } = draft
- const index = componentList.findIndex(c => c.fe_id === selectedId)
- // 如果是第一个组件,不做任何操作
- if (index <= 0) return
- const prevComponent = componentList[index - 1]
- if (prevComponent) {
- draft.selectedId = prevComponent.fe_id
- }
- },
- selectNextComponent: (draft: ComponentsStateType) => {
- const { selectedId, componentList } = draft
- const index = componentList.findIndex(c => c.fe_id === selectedId)
- if (index <= 0) return
- if (index === componentList.length - 1) return
- const nextComponent = componentList[index + 1]
- if (nextComponent) {
- draft.selectedId = nextComponent.fe_id
- }
- },
复制代码 组件库拓展操持
扩展性:
- 从最简单的组件开始
- 界说好规则,跑通流程
- 增长其他组件,不改变编辑器的规则
**软件开辟规则:**对拓睁开放,对修改封闭
段落组件
需求:
要点:
- 段落组件范例、接口、组件、属性组件实现
- 埋伏标题:段落换行处置处罚
文件树:
- │ │ ├── QuestionComponents
- │ │ │ ├── QuestionParagraph
- │ │ │ │ ├── Component.tsx
- │ │ │ │ ├── PropComponent.tsx
- │ │ │ │ ├── index.ts
- │ │ │ │ └── interface.ts
- │ │ │ └── index.ts
复制代码 埋伏标题:段落换行处置处罚
只管不要使用 dangerouslySetInnerHTML 来渲染 html,会有 xss 攻击风险。
如下可以选用 map 对组件列表举行渲染
- const textList = text.split('\n')
- <Paragraph
- style={{ textAlign: isCenter ? 'center' : 'start', marginBottom: 0 }}
- >
- {/* <span dangerouslySetInnerHTML={{ __html: t }}></span> */}
- {textList.map((item, index) => (
- <span key={index}>
- {index === 0 ? '' : <br />}
- {item}
- </span>
- ))}
- </Paragraph>
复制代码 Component.tsx
- import React, { FC } from 'react'
- import {
- QuestionParagraphPropsType,
- QuestionParagraphDefaultProps,
- } from './interface'
- import { Typography } from 'antd'
- const { Paragraph } = Typography
- const Component: FC<QuestionParagraphPropsType> = (
- props: QuestionParagraphPropsType
- ) => {
- const { text = '', isCenter = false } = {
- ...QuestionParagraphDefaultProps,
- ...props,
- }
- // 尽量不要使用 dangerouslySetInnerHTML 来渲染 html,会有 xss 攻击风险
- // const t = text.replace('\n', '<br/>')
- const textList = text.split('\n')
- return (
- <Paragraph
- style={{ textAlign: isCenter ? 'center' : 'start', marginBottom: 0 }}
- >
- {/* <span dangerouslySetInnerHTML={{ __html: t }}></span> */}
- {textList.map((item, index) => (
- <span key={index}>
- {index === 0 ? '' : <br />}
- {item}
- </span>
- ))}
- </Paragraph>
- )
- }
- export default Component
复制代码 index.ts
- /**
- * @description 段落组件
- */
- import Component from './Component'
- import { QuestionParagraphDefaultProps } from './interface'
- import PropComponent from './PropComponent'
- export * from './interface'
- // paragraph 组件配置
- export default {
- title: '段落',
- type: 'questionPragraph',
- Component: Component,
- PropComponent: PropComponent,
- defaultProps: QuestionParagraphDefaultProps,
- }
复制代码 interface.ts
- export type QuestionParagraphPropsType = {
- text?: string
- isCenter?: boolean
- onChange?: (newProps: QuestionParagraphPropsType) => void
- disabled?: boolean
- }
- export const QuestionParagraphDefaultProps: QuestionParagraphPropsType = {
- text: '一行段落',
- isCenter: false,
- }
复制代码 PropComponent.tsx
- import React, { FC } from 'react'
- import { useEffect } from 'react'
- import { Form, Input, Checkbox } from 'antd'
- import { QuestionParagraphPropsType } from './interface'
- const { TextArea } = Input
- const PropComponent: FC<QuestionParagraphPropsType> = (
- props: QuestionParagraphPropsType
- ) => {
- const { text, isCenter, onChange, disabled } = props
- const [form] = Form.useForm()
- useEffect(() => {
- form.setFieldsValue({ text, isCenter })
- }, [text, isCenter])
- function handleValuesChange() {
- if (onChange) {
- onChange(form.getFieldsValue())
- }
- }
- return (
- <Form
- layout="vertical"
- initialValues={{ text, isCenter }}
- form={form}
- onChange={handleValuesChange}
- disabled={disabled}
- >
- <Form.Item
- label="段落内容"
- name="text"
- rules={[{ required: true, message: '请输入段落内容' }]}
- >
- <TextArea cols={5} />
- </Form.Item>
- <Form.Item label="是否居中" name="isCenter" valuePropName="checked">
- <Checkbox />
- </Form.Item>
- </Form>
- )
- }
- export default PropComponent
复制代码 单选框组件
多选框同理处置处罚
需求:
要点:
- 组件属性面板:表单标题、默认选中、竖向编辑
- 动态增减嵌套字段
Component.tsx
- import React from 'react'
- import { Radio, Typography } from 'antd'
- import { QuestionRadioPropsType, QuestionRadioDefaultProps } from './interface'
- const { Paragraph } = Typography
- const QuestionRadio: React.FC<QuestionRadioPropsType> = (
- props: QuestionRadioPropsType
- ) => {
- const { title, isVertical, options, value } = {
- ...QuestionRadioDefaultProps,
- ...props,
- }
- const radioStyle: React.CSSProperties = isVertical
- ? { display: 'flex', flexDirection: 'column' }
- : {}
- return (
- <div>
- <Paragraph strong>{title}</Paragraph>
- <Radio.Group
- value={value}
- style={radioStyle}
- options={options?.map(option => ({
- value: option.value,
- label: option.text,
- }))}
- />
- </div>
- )
- }
- export default QuestionRadio
复制代码 PropCompnent.tsx
- import React, { FC } from 'react'
- import { useEffect } from 'react'
- import { Checkbox, Form, Input, Button, Space, Select } from 'antd'
- import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
- import { QuestionRadioPropsType, OptionType } from './interface'
- import { nanoid } from '@reduxjs/toolkit'
- const PropComponent: FC<QuestionRadioPropsType> = (
- props: QuestionRadioPropsType
- ) => {
- const { title, isVertical, options, value, disabled, onChange } = props
- const [form] = Form.useForm()
- useEffect(() => {
- form.setFieldsValue({ title, isVertical, options, value })
- }, [title, isVertical, options, value])
- function handleValuesChange() {
- const values = form.getFieldsValue()
- const { options } = values
- // 生成唯一的value
- if (options && options.length > 0) {
- options.forEach((opt: OptionType) => {
- if (!opt.value) {
- opt.value = nanoid(5)
- }
- })
- }
- if (onChange) {
- onChange(form.getFieldsValue())
- }
- }
- return (
- <Form
- layout="vertical"
- initialValues={{ title, isVertical, options, value }}
- onValuesChange={handleValuesChange}
- form={form}
- disabled={disabled}
- >
- <Form.Item
- label="标题"
- name="title"
- rules={[{ required: true, message: '请输入标题' }]}
- >
- <Input />
- </Form.Item>
- <Form.Item label="选项" shouldUpdate>
- <Form.List name="options">
- {(fields, { add, remove }) => (
- <>
- {fields.map(({ key, name }) => (
- <Space key={key} align="baseline">
- <Form.Item
- name={[name, 'text']}
- rules={[
- { required: true, message: '请输入选项文字' },
- {
- validator: (_, value) => {
- const optionTexts = form
- .getFieldValue('options')
- .map((opt: OptionType) => opt.text)
- if (
- optionTexts.filter((text: string) => text === value)
- .length > 1
- ) {
- return Promise.reject(new Error('选项重复!'))
- }
- return Promise.resolve()
- },
- },
- ]}
- >
- <Input placeholder="选项文字" />
- </Form.Item>
- <MinusCircleOutlined onClick={() => remove(name)} />
- </Space>
- ))}
- <Form.Item>
- <Button
- type="dashed"
- onClick={() => add({ text: '', value: '' })}
- block
- icon={<PlusOutlined />}
- >
- 添加选项
- </Button>
- </Form.Item>
- </>
- )}
- </Form.List>
- </Form.Item>
- <Form.Item label="默认选中" name="value">
- <Select
- options={options?.map(({ text, value }) => ({
- label: text,
- value: value,
- }))}
- allowClear
- placeholder="请选择默认选项"
- />
- </Form.Item>
- <Form.Item label="竖向排列" name="isVertical" valuePropName="checked">
- <Checkbox />
- </Form.Item>
- </Form>
- )
- }
- export default PropComponent
复制代码 表单细节:getFieldValue
在这两个文件中,getFieldValue 的使用方式差异是由于它们获取表单字段值的方式差异。
文件 PropComponent.tsx
- const optionTexts = form
- .getFieldsValue()
- .list.map((opt: OptionType) => opt.text)
复制代码 在这个文件中,getFieldsValue 被用来获取整个表单的全部字段值,然后通过链式调用获取 list 字段的值。list 是一个数组,此中包罗了全部选项的对象。
文件 PropComponent.tsx-1
- const optionTexts = form
- .getFieldValue('options')
- .map((opt: OptionType) => opt.text)
复制代码 在这个文件中,getFieldValue 被用来直接获取 options 字段的值。options 是一个数组,此中包罗了全部选项的对象。
总结
- getFieldsValue 返回整个表单的全部字段值作为一个对象。
- getFieldValue 必要一个参数,返回指定字段的值。
这两种方法的选择取决于你必要获取的字段值的范围。假如你只必要一个特定字段的值,使用 getFieldValue 更加直接和高效。假如你必要多个字段的值,使用 getFieldsValue 会更方便。
fix: 重复选项提示处置处罚
需求:
解释代码运行时间,当用户添加选项,输入选项值时后,哪怕值与之前选项不重复,它会保持报“选项重复!
- <Form.Item
- name={[name, 'text']}
- rules={[
- { required: true, message: '请输入选项文字' },
- {
- validator: (_, value) => {
- const optionTexts = form
- .getFieldValue('list')
- .map((opt: OptionType) => opt.text)
- // if (optionTexts.indexOf(value) !== -1) {
- // return Promise.reject('选项文字不能重复')
- // }
- if (
- optionTexts.filter((text: string) => text === value)
- .length > 1
- ) {
- return Promise.reject(new Error('选项重复!'))
- }
- return Promise.resolve()
- },
- },
- ]}
- >
- <Input />
- </Form.Item>
复制代码 标题缘故原由:
- optionTexts 包罗当前正在编辑选项的旧值
- 当用户开始输入新值时,表单立刻更新导致:
旧值仍然存在于数组中,新值会被重复校验,纵然输入唯一值,旧值的存在也会触发校验失败
办理方案:
- if (
- optionTexts.filter((text: string) => text === value)
- .length > 1
- ) {
- return Promise.reject(new Error('选项重复!'))
- }
复制代码 校验逻辑剖析:
- filter 会遍历全部选项笔墨(包罗当前正在编辑的选项)
- 当类似笔墨出现 高出1次 时才触发错误
- 这意味着:
- 答应当前编辑项自身存在一次
- 只有当其他选项存在类似笔墨时才会报错
- 空值场景:多个空选项会触发错误(由于 "" === "")
- // 原错误逻辑(任意重复即报错,包含自身)
- if (optionTexts.indexOf(value) !== -1) { ... }
- // 当前逻辑(允许自身存在一次)
- if (重复次数 > 1) { ... }
复制代码 免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
|