mirror of https://github.com/fantasticit/think.git
tiptap: add support for columns
This commit is contained in:
parent
e08e72e1ae
commit
57fb18e40a
|
@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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';
|
||||||
|
|
|
@ -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];
|
||||||
|
},
|
||||||
|
});
|
|
@ -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 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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[]) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
.wrap {
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
grid-gap: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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';
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue