improve columns

This commit is contained in:
fantasticit 2022-11-19 12:22:39 +08:00
parent 3ca99f255a
commit 880acbd703
11 changed files with 299 additions and 353 deletions

View File

@ -0,0 +1,43 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconAddColBefore: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24">
<path fill="none" d="M0 0H24V24H0z" />
<path d="M20 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1h-6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2h-4v14h4V5zM6 7c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2H5v1.999L3 11v2l2-.001V15h2v-2.001L9 13v-2l-2-.001V9z" />
</svg>
}
/>
);
};
export const IconAddColAfter: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24">
<path fill="none" d="M0 0H24V24H0z" />
<path d="M10 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zM9 5H5v14h4V5zm9 2c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L15 11v2l2-.001V15h2v-2.001L21 13v-2l-2-.001V9z" />
</svg>
}
/>
);
};
export const IconDeleteCol: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 24 24">
<path fill="none" d="M0 0H24V24H0z" />
<path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z" />
</svg>
}
/>
);
};

View File

@ -7,6 +7,7 @@ export * from './IconCallout';
export * from './IconCenter'; export * from './IconCenter';
export * from './IconClear'; export * from './IconClear';
export * from './IconCodeBlock'; export * from './IconCodeBlock';
export * from './IconColumns';
export * from './IconCountdown'; export * from './IconCountdown';
export * from './IconDeleteColumn'; export * from './IconDeleteColumn';
export * from './IconDeleteRow'; export * from './IconDeleteRow';

View File

@ -1,18 +1,9 @@
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'; import { mergeAttributes, Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { ColumnsWrapper } from 'tiptap/core/wrappers/columns';
import { getDatasetAttribute, nodeAttrsToDataset } from 'tiptap/prose-utils';
export interface IColumnsAttrs {
columns?: number;
}
export const Column = Node.create({ export const Column = Node.create({
name: 'column', name: 'column',
group: 'block', content: 'block+',
content: '(paragraph|block)*',
isolating: true, isolating: true,
selectable: false,
addOptions() { addOptions() {
return { return {
@ -22,6 +13,15 @@ export const Column = Node.create({
}; };
}, },
addAttributes() {
return {
index: {
default: 0,
parseHTML: (element) => element.getAttribute('index'),
},
};
},
parseHTML() { parseHTML() {
return [ return [
{ {

View File

@ -1,84 +1,28 @@
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'; import { mergeAttributes, Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react'; import { TextSelection } from 'prosemirror-state';
import { Node as ProseMirrorNode, Transaction } from 'prosemirror-model'; import { addOrDeleteCol, createColumns, gotoCol } from 'tiptap/prose-utils';
import { NodeSelection, Plugin, PluginKey, State, TextSelection } from 'prosemirror-state';
import { findParentNodeOfType, findSelectedNodeOfType } from 'prosemirror-utils';
import { ColumnsWrapper } from 'tiptap/core/wrappers/columns';
import { findParentNodeClosestToPos, getDatasetAttribute, getStepRange } from 'tiptap/prose-utils';
export interface IColumnsAttrs { import { EXTENSION_PRIORITY_HIGHEST } from '../constants';
type?: 'left-right' | 'left-sidebar' | 'right-sidebar';
columns?: number;
}
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
columns: { columns: {
setColumns: (attrs?: IColumnsAttrs) => ReturnType; insertColumns: (attrs?: { cols: number }) => ReturnType;
addColBefore: () => ReturnType;
addColAfter: () => ReturnType;
deleteCol: () => ReturnType;
}; };
} }
} }
const ColumnsPluginKey = new PluginKey('columns');
const fixColumnSizes = (changedTr: Transaction, state: State) => {
const columns = state.schema.nodes.columns;
const range = getStepRange(changedTr);
if (!range) {
return undefined;
}
let change;
changedTr.doc.nodesBetween(range.from, range.to, (node, pos) => {
if (node.type !== columns) {
return true;
}
if (node.childCount !== node.attrs.columns) {
const json = node.toJSON();
if (json && json.content && json.content.length) {
change = {
from: pos + 1,
to: pos + node.nodeSize - 1,
node: ProseMirrorNode.fromJSON(state.schema, {
...json,
content: json.content.slice(0, node.attrs.columns),
}),
};
}
}
return false;
});
return change;
};
export const Columns = Node.create({ export const Columns = Node.create({
name: 'columns', name: 'columns',
group: 'block', group: 'block',
content: 'column{2,}*',
defining: true, defining: true,
selectable: true,
draggable: true,
isolating: true, isolating: true,
allowGapCursor: false,
addAttributes() { content: 'column{2,}',
return { priority: EXTENSION_PRIORITY_HIGHEST,
type: {
default: 'left-right',
parseHTML: getDatasetAttribute('type'),
},
columns: {
default: 2,
parseHTML: getDatasetAttribute('columns'),
},
};
},
addOptions() { addOptions() {
return { return {
@ -88,192 +32,79 @@ export const Columns = Node.create({
}; };
}, },
addAttributes() {
return {
cols: {
default: 2,
parseHTML: (element) => element.getAttribute('cols'),
},
};
},
parseHTML() { parseHTML() {
return [ return [
{ {
tag: 'div[class=columns]', tag: 'div[class=grid]',
}, },
]; ];
}, },
renderHTML({ HTMLAttributes, node }) { renderHTML({ HTMLAttributes }) {
return [ return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: `columns ${node.attrs.type}`,
}),
0,
];
}, },
addCommands() { addCommands() {
return { return {
setColumns: insertColumns:
(options) => (attrs) =>
({ state, tr, dispatch }) => { ({ tr, dispatch, editor }) => {
if (!dispatch) return; const node = createColumns(editor.schema, (attrs && attrs.cols) || 3);
const currentNodeWithPos = findParentNodeClosestToPos( if (dispatch) {
state.selection.$from,
(node) => node.type.name === this.name
);
if (currentNodeWithPos) {
let nodes: Array<ProseMirrorNode> = [];
currentNodeWithPos.node.descendants((node, _, parent) => {
if (parent?.type.name === 'column') {
nodes.push(node);
}
});
nodes = nodes.reverse().filter((node) => node.content.size > 0);
const resolvedPos = tr.doc.resolve(currentNodeWithPos.pos);
const sel = new NodeSelection(resolvedPos);
tr = tr.setSelection(sel);
nodes.forEach((node) => (tr = tr.insert(currentNodeWithPos.pos, node)));
tr = tr.deleteSelection();
dispatch(tr);
return true;
}
const { schema } = state;
const { columns: n = 2 } = options;
const selectionContent = tr.selection.content().toJSON();
const firstColumn = {
type: 'column',
content: selectionContent ? selectionContent.content : [{ type: 'paragraph', content: [] }],
};
const otherColumns = Array.from({ length: n - 1 }, () => ({
type: 'column',
content: [{ type: 'paragraph', content: [] }],
}));
const columns = { type: this.name, content: [firstColumn, ...otherColumns] };
const newNode = ProseMirrorNode.fromJSON(schema, columns);
newNode.attrs = options;
const offset = tr.selection.anchor + 1; const offset = tr.selection.anchor + 1;
dispatch( tr.replaceSelectionWith(node)
tr
.replaceSelectionWith(newNode)
.scrollIntoView() .scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset))) .setSelection(TextSelection.near(tr.doc.resolve(offset)));
); }
return true; return true;
}, },
addColBefore:
() =>
({ dispatch, state }) => {
return addOrDeleteCol({ dispatch, state, type: 'addBefore' });
},
addColAfter:
() =>
({ dispatch, state }) => {
return addOrDeleteCol({ dispatch, state, type: 'addAfter' });
},
deleteCol:
() =>
({ dispatch, state }) => {
return addOrDeleteCol({ dispatch, state, type: 'delete' });
},
}; };
}, },
addProseMirrorPlugins() { addKeyboardShortcuts() {
return [
new Plugin({
key: ColumnsPluginKey,
state: {
init: (_, state) => {
const maybeColumns = findParentNodeOfType(state.schema.nodes.columns)(state.selection);
return { return {
pos: maybeColumns ? maybeColumns.pos : null, 'Mod-Alt-G': () => this.editor.commands.insertColumns(),
selectedColumns: maybeColumns ? maybeColumns.node : null, 'Tab': () => {
}; return gotoCol({
}, state: this.editor.state,
apply: (tr, pluginState, _oldState, newState) => { dispatch: this.editor.view.dispatch,
if (tr.docChanged || tr.selectionSet) { type: 'after',
const columns = newState.schema.nodes.columns;
const maybeColumns =
findParentNodeOfType(columns)(newState.selection) ||
findSelectedNodeOfType([columns])(newState.selection);
const newPluginState = {
...pluginState,
pos: maybeColumns ? maybeColumns.pos : null,
selectedColumns: maybeColumns ? maybeColumns.node : null,
};
return newPluginState;
}
return pluginState;
},
},
appendTransaction: (transactions, _oldState, newState) => {
const changes = [];
transactions.forEach((prevTr) => {
changes.forEach((change) => {
return {
from: prevTr.mapping.map(change.from),
to: prevTr.mapping.map(change.to),
node: change.node,
};
}); });
},
if (!prevTr.docChanged) { 'Shift-Tab': () => {
return; return gotoCol({
} state: this.editor.state,
dispatch: this.editor.view.dispatch,
const change = fixColumnSizes(prevTr, newState); type: 'before',
if (change) {
changes.push(change);
}
}); });
if (changes.length) {
const tr = newState.tr;
const selection = newState.selection.toJSON();
changes.forEach((change) => {
tr.replaceRangeWith(change.from, change.to, change.node);
});
if (tr.docChanged) {
const { pos, selectedColumns } = ColumnsPluginKey.getState(newState);
if (pos !== null && selectedColumns != null) {
let endOfColumns = pos - 1;
for (let i = 0; i < selectedColumns?.attrs?.columns; i++) {
endOfColumns += selectedColumns?.content?.content?.[i]?.nodeSize;
}
const selectionPos$ = tr.doc.resolve(endOfColumns);
tr.setSelection(
selection instanceof NodeSelection
? new NodeSelection(selectionPos$)
: new TextSelection(selectionPos$)
);
}
tr.setMeta('addToHistory', false);
return tr;
}
}
return;
}, },
}), };
];
},
addNodeView() {
return ReactNodeViewRenderer(ColumnsWrapper);
},
addInputRules() {
return [
nodeInputRule({
find: /^\$columns\$$/,
type: this.type,
getAttributes: () => {
return { type: 'left-right', columns: 2 };
},
}),
];
}, },
}); });

View File

@ -1,27 +1,21 @@
import { IconCopy, IconDelete } from '@douyinfe/semi-icons'; import { IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Button, Space } from '@douyinfe/semi-ui'; import { Button, Space } from '@douyinfe/semi-ui';
import { Divider } from 'components/divider'; import { Divider } from 'components/divider';
import { IconAddColAfter, IconAddColBefore, IconDeleteCol } from 'components/icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { BubbleMenu } from 'tiptap/core/bubble-menu'; import { BubbleMenu } from 'tiptap/core/bubble-menu';
import { Columns, IColumnsAttrs } from 'tiptap/core/extensions/columns'; import { Columns } from 'tiptap/core/extensions/columns';
import { useAttributes } from 'tiptap/core/hooks/use-attributes'; import { copyNode, deleteNode } from 'tiptap/prose-utils';
import { copyNode, deleteNode, getEditorContainerDOMSize } from 'tiptap/prose-utils';
export const ColumnsBubbleMenu = ({ editor }) => { export const ColumnsBubbleMenu = ({ editor }) => {
const attrs = useAttributes<IColumnsAttrs>(editor, Columns.name, {
type: 'left-right',
columns: 2,
});
const { type, columns } = attrs;
const getRenderContainer = useCallback((node) => { const getRenderContainer = useCallback((node) => {
let container = node; let container = node;
if (!container.tag) { if (!container.tag) {
container = node.parentElement; container = node.parentElement;
} }
while (container && container.classList && !container.classList.contains('node-columns')) { while (container && container.classList && !container.classList.contains('columns')) {
container = container.parentElement; container = container.parentElement;
} }
@ -31,6 +25,9 @@ export const ColumnsBubbleMenu = ({ editor }) => {
const shouldShow = useCallback(() => editor.isActive(Columns.name), [editor]); const shouldShow = useCallback(() => editor.isActive(Columns.name), [editor]);
const copyMe = useCallback(() => copyNode(Columns.name, editor), [editor]); const copyMe = useCallback(() => copyNode(Columns.name, editor), [editor]);
const deleteMe = useCallback(() => deleteNode(Columns.name, editor), [editor]); const deleteMe = useCallback(() => deleteNode(Columns.name, editor), [editor]);
const addColBefore = useCallback(() => editor.chain().focus().addColBefore().run(), [editor]);
const addColAfter = useCallback(() => editor.chain().focus().addColAfter().run(), [editor]);
const deleteCol = useCallback(() => editor.chain().focus().deleteCol().run(), [editor]);
return ( return (
<BubbleMenu <BubbleMenu
@ -43,13 +40,27 @@ export const ColumnsBubbleMenu = ({ editor }) => {
> >
<Space spacing={4}> <Space spacing={4}>
<Tooltip content="复制"> <Tooltip content="复制">
<Button onClick={copyMe} icon={<IconCopy />} type="tertiary" theme="borderless" size="small" /> <Button onClick={copyMe} icon={<IconCopy />} size="small" type="tertiary" theme="borderless" />
</Tooltip> </Tooltip>
<Divider /> <Divider />
<Tooltip content="删除节点" hideOnClick> <Tooltip content="向前插入一列">
<Button onClick={deleteMe} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" /> <Button onClick={addColBefore} icon={<IconAddColBefore />} size="small" type="tertiary" theme="borderless" />
</Tooltip>
<Tooltip content="向后插入一列">
<Button onClick={addColAfter} icon={<IconAddColAfter />} size="small" type="tertiary" theme="borderless" />
</Tooltip>
<Tooltip content="删除当前列">
<Button onClick={deleteCol} icon={<IconDeleteCol />} size="small" type="tertiary" theme="borderless" />
</Tooltip>
<Divider />
<Tooltip content="删除">
<Button size="small" type="tertiary" theme="borderless" icon={<IconDelete />} onClick={deleteMe} />
</Tooltip> </Tooltip>
</Space> </Space>
</BubbleMenu> </BubbleMenu>

View File

@ -110,7 +110,7 @@ export const COMMANDS: ICommand[] = [
label: '布局', label: '布局',
action: () => { action: () => {
editor.chain().focus().run(); editor.chain().focus().run();
editor.chain().setColumns({ type: 'left-right', columns: cols }).focus().run(); editor.chain().insertColumns({ cols }).focus().run();
}, },
})(); })();
}} }}
@ -229,7 +229,7 @@ export const QUICK_INSERT_COMMANDS = [
isBlock: true, isBlock: true,
icon: <IconLayout />, icon: <IconLayout />,
label: '布局', label: '布局',
action: (editor) => editor.chain().focus().setColumns({ type: 'left-right', columns: 2 }).run(), action: (editor) => editor.chain().focus().insertColumns({ cols: 2 }).run(),
}, },
...COMMANDS.slice(4), ...COMMANDS.slice(4),
]; ];

View File

@ -2,13 +2,12 @@
display: flex; display: flex;
width: 100%; width: 100%;
gap: 8px; gap: 8px;
}
.column { .column {
min-width: 0; min-width: 0;
padding: 12px; padding: 12px;
border: 1px solid var(--semi-color-border); border: 1px solid var(--semi-color-border);
border-radius: var(--border-radius); border-radius: 2px;
flex: 1 1 0%; flex: 1 1 0%;
box-sizing: border-box; box-sizing: border-box;
@ -17,4 +16,5 @@
margin-top: 0; margin-top: 0;
} }
} }
}
} }

View File

@ -1,8 +0,0 @@
.wrap {
> div {
display: flex;
width: 100%;
grid-gap: 8px;
gap: 8px;
}
}

View File

@ -1,74 +0,0 @@
import { Space, Spin, Typography } from '@douyinfe/semi-ui';
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames';
import { IconMind } from 'components/icons';
import { Resizeable } from 'components/resizeable';
import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useRef, useState } from 'react';
import VisibilitySensor from 'react-visibility-sensor';
import { Columns } from 'tiptap/core/extensions/columns';
import { getEditorContainerDOMSize } from 'tiptap/prose-utils';
import styles from './index.module.scss';
const { Text } = Typography;
const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' };
export const ColumnsWrapper = ({ editor, node, updateAttributes }) => {
const exportToSvgRef = useRef(null);
const isEditable = editor.isEditable;
const isActive = editor.isActive(Columns.name);
const { width: maxWidth } = getEditorContainerDOMSize(editor);
const { data, width, height } = node.attrs;
const [Svg, setSvg] = useState<SVGElement | null>(null);
const [loading, toggleLoading] = useToggle(true);
const [error, setError] = useState<Error | null>(null);
const [visible, toggleVisible] = useToggle(false);
const onResize = useCallback(
(size) => {
updateAttributes({ width: size.width, height: size.height });
},
[updateAttributes]
);
const onViewportChange = useCallback(
(visible) => {
if (visible) {
toggleVisible(true);
}
},
[toggleVisible]
);
useEffect(() => {
import('@excalidraw/excalidraw')
.then((res) => {
exportToSvgRef.current = res.exportToSvg;
})
.catch(setError)
.finally(() => toggleLoading(false));
}, [toggleLoading, data]);
useEffect(() => {
const setContent = async () => {
if (loading || error || !visible || !data) return;
const svg: SVGElement = await exportToSvgRef.current(data);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('display', 'block');
setSvg(svg);
};
setContent();
}, [data, loading, error, visible]);
return (
<NodeViewWrapper>
<NodeViewContent className={cls(styles.wrap, 'render-wrap')} />
</NodeViewWrapper>
);
};

View File

@ -0,0 +1,141 @@
import { Node as PMNode } from 'prosemirror-model';
import { EditorState, TextSelection } from 'prosemirror-state';
import { findParentNode } from 'prosemirror-utils';
import { Column } from 'tiptap/core/extensions/column';
import { Columns } from 'tiptap/core/extensions/columns';
export function createColumn(colType, index, colContent = null) {
if (colContent) {
return colType.createChecked({ index }, colContent);
}
return colType.createAndFill({ index });
}
export function getColumnsNodeTypes(schema) {
if (schema.cached.columnsNodeTypes) {
return schema.cached.columnsNodeTypes;
}
const roles = {
columns: schema.nodes['columns'],
column: schema.nodes['column'],
};
schema.cached.columnsNodeTypes = roles;
return roles;
}
export function createColumns(schema, colsCount, colContent = null) {
const types = getColumnsNodeTypes(schema);
const cols = [];
for (let index = 0; index < colsCount; index += 1) {
const col = createColumn(types.column, index, colContent);
if (col) {
// @ts-ignore
cols.push(col);
}
}
return types.columns.createChecked({ cols: colsCount }, cols);
}
export function addOrDeleteCol({
state,
dispatch,
type,
}: {
state: EditorState;
dispatch: any;
type: 'addBefore' | 'addAfter' | 'delete';
}) {
const maybeColumns = findParentNode((node: PMNode) => node.type.name === Columns.name)(state.selection);
const maybeColumn = findParentNode((node: PMNode) => node.type.name === Column.name)(state.selection);
if (dispatch && maybeColumns && maybeColumn) {
const cols = maybeColumns.node;
const colIndex = maybeColumn.node.attrs.index;
const colsJSON = cols.toJSON();
let nextIndex = colIndex;
if (type === 'delete') {
nextIndex = colIndex - 1;
colsJSON.content.splice(colIndex, 1);
} else {
nextIndex = type === 'addBefore' ? colIndex : colIndex + 1;
colsJSON.content.splice(nextIndex, 0, {
type: 'column',
attrs: {
index: colIndex,
},
content: [
{
type: 'paragraph',
},
],
});
}
colsJSON.attrs.cols = colsJSON.content.length;
colsJSON.content.forEach((colJSON, index) => {
colJSON.attrs.index = index;
});
const nextCols = PMNode.fromJSON(state.schema, colsJSON);
let nextSelectPos = maybeColumns.pos;
nextCols.content.forEach((col, pos, index) => {
if (index < nextIndex) {
nextSelectPos += col.nodeSize;
}
});
const tr = state.tr.setTime(Date.now());
tr.replaceWith(maybeColumns.pos, maybeColumns.pos + maybeColumns.node.nodeSize, nextCols).setSelection(
TextSelection.near(tr.doc.resolve(nextSelectPos))
);
dispatch(tr);
}
return true;
}
export function gotoCol({ state, dispatch, type }: { state: EditorState; dispatch: any; type: 'before' | 'after' }) {
const maybeColumns = findParentNode((node: PMNode) => node.type.name === Columns.name)(state.selection);
const maybeColumn = findParentNode((node: PMNode) => node.type.name === Column.name)(state.selection);
if (dispatch && maybeColumns && maybeColumn) {
const cols = maybeColumns.node;
const colIndex = maybeColumn.node.attrs.index;
let nextIndex = 0;
if (type === 'before') {
nextIndex = (colIndex - 1 + cols.attrs.cols) % cols.attrs.cols;
} else {
nextIndex = (colIndex + 1) % cols.attrs.cols;
}
let nextSelectPos = maybeColumns.pos;
cols.content.forEach((col, pos, index) => {
if (index < nextIndex) {
nextSelectPos += col.nodeSize;
}
});
const tr = state.tr.setTime(Date.now());
tr.setSelection(TextSelection.near(tr.doc.resolve(nextSelectPos)));
dispatch(tr);
return true;
}
return false;
}

View File

@ -2,6 +2,7 @@ export * from './active';
export * from './clamp'; export * from './clamp';
export * from './code'; export * from './code';
export * from './color'; export * from './color';
export * from './columns';
export * from './copy-node'; export * from './copy-node';
export * from './create-node'; export * from './create-node';
export * from './debug'; export * from './debug';