tiptap: add support for columns

feat/columns
fantasticit 2022-08-17 16:36:39 +08:00
parent e08e72e1ae
commit 57fb18e40a
18 changed files with 601 additions and 3 deletions

View File

@ -0,0 +1,20 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconLayout: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="18" height="18" viewBox="0 0 24 24" role="presentation">
<g fill="none" fillRule="evenodd">
<path
d="M5 5h5a1 1 0 011 1v12a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1zm9 0h5a1 1 0 011 1v12a1 1 0 01-1 1h-5a1 1 0 01-1-1V6a1 1 0 011-1z"
fill="currentColor"
fillRule="nonzero"
></path>
</g>
</svg>
}
/>
);
};

View File

@ -27,6 +27,7 @@ export * from './IconHorizontalRule';
export * from './IconImage'; export * from './IconImage';
export * from './IconInfo'; export * from './IconInfo';
export * from './IconJSON'; export * from './IconJSON';
export * from './IconLayout';
export * from './IconLeft'; export * from './IconLeft';
export * from './IconLink'; export * from './IconLink';
export * from './IconList'; export * from './IconList';

View File

@ -0,0 +1,36 @@
import { mergeAttributes, Node, nodeInputRule } 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({
name: 'column',
group: 'block',
content: '(paragraph|block)*',
isolating: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {
class: 'column',
},
};
},
parseHTML() {
return [
{
tag: 'div[class=column]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});

View File

@ -0,0 +1,279 @@
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { Node as ProseMirrorNode, Transaction } from 'prosemirror-model';
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 {
type?: 'left-right' | 'left-sidebar' | 'right-sidebar';
columns?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
columns: {
setColumns: (attrs?: IColumnsAttrs) => 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({
name: 'columns',
group: 'block',
content: 'column{2,}*',
defining: true,
selectable: true,
draggable: true,
isolating: true,
addAttributes() {
return {
type: {
default: 'left-right',
parseHTML: getDatasetAttribute('type'),
},
columns: {
default: 2,
parseHTML: getDatasetAttribute('columns'),
},
};
},
addOptions() {
return {
HTMLAttributes: {
class: 'columns',
},
};
},
parseHTML() {
return [
{
tag: 'div[class=columns]',
},
];
},
renderHTML({ HTMLAttributes, node }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: `columns ${node.attrs.type}`,
}),
0,
];
},
addCommands() {
return {
setColumns:
(options) =>
({ state, tr, dispatch }) => {
if (!dispatch) return;
const currentNodeWithPos = findParentNodeClosestToPos(
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;
dispatch(
tr
.replaceSelectionWith(newNode)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)))
);
return true;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: ColumnsPluginKey,
state: {
init: (_, state) => {
const maybeColumns = findParentNodeOfType(state.schema.nodes.columns)(state.selection);
return {
pos: maybeColumns ? maybeColumns.pos : null,
selectedColumns: maybeColumns ? maybeColumns.node : null,
};
},
apply: (tr, pluginState, _oldState, newState) => {
if (tr.docChanged || tr.selectionSet) {
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) {
return;
}
const change = fixColumnSizes(prevTr, newState);
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

@ -0,0 +1,57 @@
import { IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Button, Space } from '@douyinfe/semi-ui';
import { Divider } from 'components/divider';
import { Tooltip } from 'components/tooltip';
import { useCallback } from 'react';
import { BubbleMenu } from 'tiptap/core/bubble-menu';
import { Columns, IColumnsAttrs } from 'tiptap/core/extensions/columns';
import { useAttributes } from 'tiptap/core/hooks/use-attributes';
import { copyNode, deleteNode, getEditorContainerDOMSize } from 'tiptap/prose-utils';
export const ColumnsBubbleMenu = ({ editor }) => {
const attrs = useAttributes<IColumnsAttrs>(editor, Columns.name, {
type: 'left-right',
columns: 2,
});
const { type, columns } = attrs;
const getRenderContainer = useCallback((node) => {
let container = node;
if (!container.tag) {
container = node.parentElement;
}
while (container && container.classList && !container.classList.contains('node-columns')) {
container = container.parentElement;
}
return container;
}, []);
const shouldShow = useCallback(() => editor.isActive(Columns.name), [editor]);
const copyMe = useCallback(() => copyNode(Columns.name, editor), [editor]);
const deleteMe = useCallback(() => deleteNode(Columns.name, editor), [editor]);
return (
<BubbleMenu
className={'bubble-menu'}
editor={editor}
pluginKey="columns-bubble-menu"
shouldShow={shouldShow}
getRenderContainer={getRenderContainer}
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
>
<Space spacing={4}>
<Tooltip content="复制">
<Button onClick={copyMe} icon={<IconCopy />} type="tertiary" theme="borderless" size="small" />
</Tooltip>
<Divider />
<Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteMe} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" />
</Tooltip>
</Space>
</BubbleMenu>
);
};

View File

@ -0,0 +1,12 @@
import React from 'react';
import { Editor } from 'tiptap/core';
import { ColumnsBubbleMenu } from './bubble';
export const Columns: React.FC<{ editor: Editor }> = ({ editor }) => {
return (
<>
<ColumnsBubbleMenu editor={editor} />
</>
);
};

View File

@ -9,6 +9,7 @@ import {
IconDocument, IconDocument,
IconFlow, IconFlow,
IconImage, IconImage,
IconLayout,
IconLink, IconLink,
IconMath, IconMath,
IconMind, IconMind,
@ -50,6 +51,7 @@ export const COMMANDS: ICommand[] = [
{ {
title: '通用', title: '通用',
}, },
{ {
icon: <IconTableOfContents />, icon: <IconTableOfContents />,
label: '目录', label: '目录',
@ -85,6 +87,38 @@ export const COMMANDS: ICommand[] = [
</Popover> </Popover>
), ),
}, },
{
isBlock: true,
icon: <IconLayout />,
label: '布局',
custom: (editor, runCommand) => (
<Popover
key="table"
showArrow
position="rightTop"
zIndex={10000}
content={
<div style={{ padding: 0 }}>
<GridSelect
rows={1}
cols={5}
onSelect={({ cols }) => {
return runCommand({
label: '布局',
action: () => editor.chain().focus().setColumns({ type: 'left-right', columns: cols }).run(),
})();
}}
/>
</div>
}
>
<Dropdown.Item>
<IconLayout />
</Dropdown.Item>
</Popover>
),
},
{ {
isBlock: true, isBlock: true,
icon: <IconCodeBlock />, icon: <IconCodeBlock />,
@ -185,7 +219,13 @@ export const QUICK_INSERT_COMMANDS = [
label: '表格', label: '表格',
action: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), action: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
}, },
...COMMANDS.slice(3), {
isBlock: true,
icon: <IconLayout />,
label: '布局',
action: (editor) => editor.chain().focus().setColumns({ type: 'left-right', columns: 2 }).run(),
},
...COMMANDS.slice(4),
]; ];
export const transformToCommands = (commands, data: string[]) => { export const transformToCommands = (commands, data: string[]) => {

View File

@ -0,0 +1,20 @@
.columns {
display: flex;
width: 100%;
gap: 8px;
}
.column {
min-width: 0;
padding: 12px;
border: 1px solid var(--semi-color-border);
border-radius: var(--border-radius);
flex: 1 1 0%;
box-sizing: border-box;
p {
&:first-of-type {
margin-top: 0;
}
}
}

View File

@ -18,3 +18,4 @@
@import './title.scss'; @import './title.scss';
@import './kityminder.scss'; @import './kityminder.scss';
@import './drag.scss'; @import './drag.scss';
@import './columns.scss';

View File

@ -39,7 +39,8 @@
.node-codeBlock, .node-codeBlock,
.node-documentChildren, .node-documentChildren,
.node-documentReference, .node-documentReference,
.node-excalidraw { .node-excalidraw,
.node-columns {
margin-top: 0.75em; margin-top: 0.75em;
} }

View File

@ -111,4 +111,27 @@
} }
} }
} }
.node-columns {
&.selected-node {
.column {
position: relative;
border: 1px solid var(--node-selected-border-color) !important;
&:hover,
&:active {
border-color: var(--node-selected-border-color);
box-shadow: none;
}
&::after {
position: absolute;
pointer-events: none;
background-color: rgb(179 212 255 / 30%);
content: '';
inset: 0;
}
}
}
}
} }

View File

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

View File

@ -0,0 +1,74 @@
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

@ -12,6 +12,7 @@ import { Callout } from 'tiptap/core/menus/callout';
import { CleadrNodeAndMarks } from 'tiptap/core/menus/clear-node-and-marks'; import { CleadrNodeAndMarks } from 'tiptap/core/menus/clear-node-and-marks';
import { Code } from 'tiptap/core/menus/code'; import { Code } from 'tiptap/core/menus/code';
import { CodeBlock } from 'tiptap/core/menus/code-block'; import { CodeBlock } from 'tiptap/core/menus/code-block';
import { Columns } from 'tiptap/core/menus/columns';
import { Countdonw } from 'tiptap/core/menus/countdown'; import { Countdonw } from 'tiptap/core/menus/countdown';
import { DocumentChildren } from 'tiptap/core/menus/document-children'; import { DocumentChildren } from 'tiptap/core/menus/document-children';
import { DocumentReference } from 'tiptap/core/menus/document-reference'; import { DocumentReference } from 'tiptap/core/menus/document-reference';
@ -109,6 +110,7 @@ const _MenuBar: React.FC<{ editor: Editor }> = ({ editor }) => {
<Katex editor={editor} /> <Katex editor={editor} />
<Mind editor={editor} /> <Mind editor={editor} />
<Excalidraw editor={editor} /> <Excalidraw editor={editor} />
<Columns editor={editor} />
</Space> </Space>
</div> </div>
); );

View File

@ -11,6 +11,8 @@ import { Code, CodeMarkPlugin } from 'tiptap/core/extensions/code';
import { CodeBlock } from 'tiptap/core/extensions/code-block'; import { CodeBlock } from 'tiptap/core/extensions/code-block';
import { Color } from 'tiptap/core/extensions/color'; import { Color } from 'tiptap/core/extensions/color';
import { ColorHighlighter } from 'tiptap/core/extensions/color-highlighter'; import { ColorHighlighter } from 'tiptap/core/extensions/color-highlighter';
import { Column } from 'tiptap/core/extensions/column';
import { Columns } from 'tiptap/core/extensions/columns';
import { Countdown } from 'tiptap/core/extensions/countdown'; import { Countdown } from 'tiptap/core/extensions/countdown';
// 基础扩展 // 基础扩展
import { Document } from 'tiptap/core/extensions/document'; import { Document } from 'tiptap/core/extensions/document';
@ -102,6 +104,8 @@ export const CollaborationKit = [
CodeBlock, CodeBlock,
Color, Color,
ColorHighlighter, ColorHighlighter,
Column,
Columns,
Dropcursor, Dropcursor,
Excalidraw, Excalidraw,
EventEmitter, EventEmitter,

View File

@ -0,0 +1,19 @@
import { ReadonlyTransaction, Transaction } from 'prosemirror-state';
export const getStepRange = (transaction: Transaction | ReadonlyTransaction): { from: number; to: number } | null => {
let from = -1;
let to = -1;
transaction.steps.forEach((step) => {
step.getMap().forEach((_oldStart, _oldEnd, newStart, newEnd) => {
from = newStart < from || from === -1 ? newStart : from;
to = newEnd < to || to === -1 ? newEnd : to;
});
});
if (from !== -1) {
return { from, to };
}
return null;
};

View File

@ -6,6 +6,7 @@ export * from './copy-node';
export * from './create-node'; export * from './create-node';
export * from './debug'; export * from './debug';
export * from './delete-node'; export * from './delete-node';
export * from './document';
export * from './dom'; export * from './dom';
export * from './dom-dataset'; export * from './dom-dataset';
export * from './download'; export * from './download';

View File

@ -1,5 +1,5 @@
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import { Node } from 'prosemirror-model'; import { Node, ResolvedPos } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state'; import { EditorState } from 'prosemirror-state';
export function isTitleNode(node: Node): boolean { export function isTitleNode(node: Node): boolean {