arch-editor
Version:
Rich text editor with a high degree of customization.
338 lines (304 loc) • 9.41 kB
JavaScript
import React, { useCallback, useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames/bind';
import { EditorState, convertFromRaw, convertToRaw } from 'draft-js';
import { v4 as uuid } from 'uuid';
import { updateBlockEntityData, removeBlockEntity } from '@/utils/draftUtils';
import { defaultDecorator } from '@/utils/default';
import { useToolbar, EditToolbar } from '@/utils/atomic';
import Tooltip from '@/components/Tooltip';
import Icon from '@/components/Icon';
import styles from './AtomicTable.less';
import styles2 from '../index.less';
const cx = classNames.bind(styles);
// TODO: todoList
// ✔ 1.编辑状态控制 editing IMP: 控制何时编辑,何时提交,预期和其他实体的操作逻辑一致。有bug
// ✔ 2.提交时数据转换 editorState convert to contentStateRaw to storage
// 3.初始化选择几行几列
// 4.BlockToolbar 作用对象切换table内部editor,table内部无缝支持富文本
// 5.添加/删除 行和列
// 6.合并单元格
function convertRowsFromRaw(rows) {
return rows.map((r) => ({
key: r.key,
columns: r.columns.map((c) => {
const contentState = convertFromRaw(c.rawContentState);
const editorStateWithDecorator = EditorState.createWithContent(
contentState,
defaultDecorator,
);
return {
key: c.key,
editorState: editorStateWithDecorator,
};
}),
}));
}
function convertRowsToRaw(rows) {
return rows.map((r) => ({
key: r.key,
columns: r.columns.map((c) => {
const contentState = c.editorState.getCurrentContent();
const rawContentState = convertToRaw(contentState);
return {
key: c.key,
rawContentState,
};
}),
}));
}
const defaultContentState = EditorState.createEmpty().getCurrentContent();
const defaultEditorState = EditorState.createWithContent(defaultContentState, defaultDecorator);
// 尽量保证 set editorState 独立执行 https://draftjs.org/docs/advanced-topics-issues-and-pitfalls#delayed-state-updates
export default function AtomicTable(props) {
// props
const {
block,
data = {},
readOnly,
editorState,
setEditorState,
editing,
setEditing,
Editor,
} = props;
// ref
const atomicTable = useRef(null);
// state
const [rows, setRows] = useState([]);
// effect
useEffect(() => {
// initial state: 初次创建处于编辑状态
let timer;
if (data.initial) {
setEditing(true, () => {
timer = setTimeout(() => {
if (atomicTable.current) atomicTable.current.scrollIntoView();
}, 0);
});
}
return () => {
if (timer) clearTimeout(timer);
};
}, [data, setEditing]);
useEffect(() => {
const compositeRows = convertRowsFromRaw(data.rows);
setRows(compositeRows);
}, [data, editing]);
// handler
const execSubmitAction = useCallback(() => {
const nextRows = convertRowsToRaw(rows); // 转换成可存储的数据
const newEditorState = updateBlockEntityData(block, editorState, {
rows: nextRows,
initial: false,
});
setEditorState(newEditorState);
}, [block, editorState, rows, setEditorState]);
const execRemoveAction = useCallback(() => {
const newEditorState = removeBlockEntity(block, editorState);
setEditorState(newEditorState);
}, [block, editorState, setEditorState]);
const handleChange = useCallback(
(value, pos) => {
// change local data
const [ri, ci] = pos; // row index, column index
const nextRows = rows.concat();
nextRows[ri].columns[ci].editorState = value;
setRows(nextRows);
},
[rows],
);
const handleEdit = useCallback(() => {
setEditing(true);
}, [setEditing]);
const handleCancel = useCallback(() => {
setEditing(false);
}, [setEditing]);
const handleSubmit = useCallback(() => {
setEditing(false, () => {
execSubmitAction();
});
}, [execSubmitAction, setEditing]);
const handleDelete = useCallback(() => {
if (editing) {
setEditing(false, () => {
execRemoveAction();
});
return;
}
execRemoveAction();
}, [editing, execRemoveAction, setEditing]);
const insertColumn = (index) => {
setRows((v) => v.map((item) => {
const { columns } = item;
const newcol = columns.concat();
newcol.splice(index, 0, {
key: uuid(),
editorState: defaultEditorState,
});
return { ...item, columns: newcol };
}));
};
const handleInsertColLeft = (i) => {
insertColumn(i);
};
const handleInsertColRight = (i) => {
insertColumn(i + 1);
};
const handleDeleteCol = (i) => {
setRows((v) => v.map((item) => ({
...item,
columns: item.columns.filter((p, index) => index !== i),
})));
};
const toolbarColumn = (i) => (
<span className={`${styles2.popoverButtonGroup} ${styles2.large}`}>
<button
type="button"
className={styles2.popoverButton}
onClick={() => handleInsertColLeft(i)}
>
<Icon name="insert-column-left" />
</button>
<span className={styles2.popoverDivider} />
<button
type="button"
className={styles2.popoverButton}
onClick={() => handleInsertColRight(i)}
>
<Icon name="insert-column-right" />
</button>
{rows[0].columns.length > 1 && (
<>
<span className={styles2.popoverDivider} />
<button
type="button"
className={styles2.popoverButton}
onClick={() => handleDeleteCol(i)}
>
<Icon name="delete-column" />
</button>
</>
)}
</span>
);
const insertRow = (index) => {
const columnTemp = rows[0].columns;
setRows((v) => {
const newRow = {
key: uuid(),
columns: columnTemp.map(() => ({
key: uuid(),
editorState: defaultEditorState,
})),
};
const next = v.concat();
next.splice(index, 0, newRow);
return next;
});
};
const handleInsertRowAbove = (i) => {
insertRow(i);
};
const handleInsertRowBelow = (i) => {
insertRow(i + 1);
};
const handleDeleteRow = (i) => {
setRows((v) => v.filter((p, index) => index !== i));
};
const toolbarRow = (i) => (
<span className={`${styles2.popoverButtonGroup} ${styles2.large}`}>
<button
type="button"
className={styles2.popoverButton}
onClick={() => handleInsertRowAbove(i)}
>
<Icon name="insert-row-above" />
</button>
<span className={styles2.popoverDivider} />
<button
type="button"
className={styles2.popoverButton}
onClick={() => handleInsertRowBelow(i)}
>
<Icon name="insert-row-below" />
</button>
<span className={styles2.popoverDivider} />
<button type="button" className={styles2.popoverButton} onClick={() => handleDeleteRow(i)}>
<Icon name="delete-row" />
</button>
</span>
);
const toolbarEdit = useToolbar({
editing: false,
onEdit: handleEdit,
onDelete: handleDelete,
});
const core = (
<table className={cx({ readonly: !editing })}>
<tbody>
{editing && (
<tr className={styles.columnBar}>
{rows[0] &&
rows[0].columns.map(({ key }, i) => (
<Tooltip content={toolbarColumn(i)} key={key}>
<td />
</Tooltip>
))}
<td className={styles.last} />
</tr>
)}
{rows.map(({ key, columns }, i) => (
<tr key={key}>
{columns.map((item, j) => (
<td key={item.key}>
<Editor
editorKey={item.key}
editorState={item.editorState}
onChange={(v) => handleChange(v, [i, j])}
readOnly={!editing}
/>
</td>
))}
{editing && (
<Tooltip content={toolbarRow(i)} placement="right">
<td className={styles.rowBar} />
</Tooltip>
)}
</tr>
))}
</tbody>
</table>
);
if (readOnly) {
return <div className={styles.atomicTable}>{core}</div>;
}
if (editing) {
return (
<div className={styles.atomicTable} ref={atomicTable}>
{core}
<EditToolbar
className={styles.editToolbar}
onCancel={handleCancel}
onOk={handleSubmit}
onDelete={handleDelete}
/>
</div>
);
}
return (
<Tooltip content={toolbarEdit}>
<div className={styles.atomicTable}>{core}</div>
</Tooltip>
);
}
AtomicTable.propTypes = {
block: PropTypes.object,
data: PropTypes.any,
readOnly: PropTypes.bool,
editorState: PropTypes.object,
setEditorState: PropTypes.func,
editing: PropTypes.bool,
setEditing: PropTypes.func,
Editor: PropTypes.elementType,
};