improve table cell

This commit is contained in:
fantasticit 2022-12-19 19:14:06 +08:00
parent 922ecdf98f
commit c6abfab7c8
6 changed files with 418 additions and 168 deletions

View File

@ -1,13 +1,18 @@
import { IconPlus } from '@douyinfe/semi-icons';
import { mergeAttributes, Node } from '@tiptap/core'; import { mergeAttributes, Node } from '@tiptap/core';
import { Plugin } from 'prosemirror-state'; import { Tooltip } from 'components/tooltip';
import { Plugin, PluginKey } from 'prosemirror-state';
import { addRowAfter } from 'prosemirror-tables';
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import React from 'react';
import ReactDOM from 'react-dom';
import { getCellsInColumn, isRowSelected, isTableSelected, selectRow, selectTable } from 'tiptap/prose-utils'; import { getCellsInColumn, isRowSelected, isTableSelected, selectRow, selectTable } from 'tiptap/prose-utils';
export interface TableCellOptions { export interface TableCellOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
} }
export const TableCell = Node.create<TableCellOptions>({ export const TableCell = Node.create<TableCellOptions, { clearCallbacks: Array<() => void> }>({
name: 'tableCell', name: 'tableCell',
content: 'block+', content: 'block+',
tableRole: 'cell', tableRole: 'cell',
@ -19,14 +24,6 @@ export const TableCell = Node.create<TableCellOptions>({
}; };
}, },
parseHTML() {
return [{ tag: 'td' }];
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addAttributes() { addAttributes() {
return { return {
colspan: { colspan: {
@ -46,7 +43,7 @@ export const TableCell = Node.create<TableCellOptions>({
}, },
}, },
colwidth: { colwidth: {
default: [150], default: [100],
parseHTML: (element) => { parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth'); const colwidth = element.getAttribute('colwidth');
const value = colwidth ? [parseInt(colwidth, 10)] : null; const value = colwidth ? [parseInt(colwidth, 10)] : null;
@ -59,22 +56,44 @@ export const TableCell = Node.create<TableCellOptions>({
}; };
}, },
parseHTML() {
return [{ tag: 'td' }];
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addStorage() {
return {
clearCallbacks: [],
};
},
onDestroy() {
this.storage.clearCallbacks.forEach((cb) => cb());
this.storage.clearCallbacks.length = 0;
},
// @ts-ignore
addProseMirrorPlugins() { addProseMirrorPlugins() {
const { isEditable } = this.editor; const { isEditable } = this.editor;
return [ return [
new Plugin({ new Plugin({
key: new PluginKey('table-cell-control'),
props: { props: {
decorations: (state) => { decorations: (state) => {
if (!isEditable) { if (!isEditable) {
return DecorationSet.empty; return DecorationSet.empty;
} }
const { doc, selection } = state; const { doc, selection } = state;
const decorations: Decoration[] = []; const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection); const cells = getCellsInColumn(0)(selection);
if (cells) { if (cells) {
this.storage.clearCallbacks.forEach((cb) => cb());
this.storage.clearCallbacks.length = 0;
cells.forEach(({ pos }, index) => { cells.forEach(({ pos }, index) => {
if (index === 0) { if (index === 0) {
decorations.push( decorations.push(
@ -89,8 +108,10 @@ export const TableCell = Node.create<TableCellOptions>({
grip.addEventListener('mousedown', (event) => { grip.addEventListener('mousedown', (event) => {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
this.editor.view.dispatch(selectTable(this.editor.state.tr)); this.editor.view.dispatch(
// this.options.onSelectTable(state); // @ts-ignore
selectTable(this.editor.state.tr)
);
}); });
return grip; return grip;
}) })
@ -99,7 +120,6 @@ export const TableCell = Node.create<TableCellOptions>({
decorations.push( decorations.push(
Decoration.widget(pos + 1, () => { Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(selection); const rowSelected = isRowSelected(index)(selection);
let className = 'grip-row'; let className = 'grip-row';
if (rowSelected) { if (rowSelected) {
className += ' selected'; className += ' selected';
@ -111,18 +131,41 @@ export const TableCell = Node.create<TableCellOptions>({
className += ' last'; className += ' last';
} }
const grip = document.createElement('a'); const grip = document.createElement('a');
grip.className = className;
grip.addEventListener('mousedown', (event) => { ReactDOM.render(
event.preventDefault(); <Tooltip content="向后增加一行">
event.stopImmediatePropagation(); <IconPlus />
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr)); </Tooltip>,
grip
);
this.storage.clearCallbacks.push(() => {
ReactDOM.unmountComponentAtNode(grip);
}); });
grip.className = className;
grip.addEventListener(
'mousedown',
(event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(
// @ts-ignore
selectRow(index)(this.editor.state.tr)
);
if (event.target !== grip) {
addRowAfter(this.editor.state, this.editor.view.dispatch);
}
},
true
);
return grip; return grip;
}) })
); );
}); });
} }
return DecorationSet.create(doc, decorations); return DecorationSet.create(doc, decorations);
}, },
}, },

View File

@ -1,9 +1,29 @@
import { TableHeader as BuiltInTableHeader } from '@tiptap/extension-table-header'; import { IconPlus } from '@douyinfe/semi-icons';
import { Plugin } from 'prosemirror-state'; import { mergeAttributes, Node } from '@tiptap/core';
import { Tooltip } from 'components/tooltip';
import { Plugin, PluginKey } from 'prosemirror-state';
import { addColumnAfter } from 'prosemirror-tables';
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import React from 'react';
import ReactDOM from 'react-dom';
import { getCellsInRow, isColumnSelected, selectColumn } from 'tiptap/prose-utils'; import { getCellsInRow, isColumnSelected, selectColumn } from 'tiptap/prose-utils';
export const TableHeader = BuiltInTableHeader.extend({ export interface TableHeaderOptions {
HTMLAttributes: Record<string, any>;
}
export const TableHeader = Node.create<TableHeaderOptions, { clearCallbacks: Array<() => void> }>({
name: 'tableHeader',
content: 'block+',
tableRole: 'header_cell',
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() { addAttributes() {
return { return {
colspan: { colspan: {
@ -13,10 +33,10 @@ export const TableHeader = BuiltInTableHeader.extend({
default: 1, default: 1,
}, },
colwidth: { colwidth: {
default: [150], default: [100],
parseHTML: (element) => { parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth'); const colwidth = element.getAttribute('colwidth');
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null; const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value; return value;
}, },
@ -27,22 +47,44 @@ export const TableHeader = BuiltInTableHeader.extend({
}; };
}, },
parseHTML() {
return [{ tag: 'th' }];
},
renderHTML({ HTMLAttributes }) {
return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addStorage() {
return {
clearCallbacks: [],
};
},
onDestroy() {
this.storage.clearCallbacks.forEach((cb) => cb());
this.storage.clearCallbacks.length = 0;
},
// @ts-ignore
addProseMirrorPlugins() { addProseMirrorPlugins() {
const { isEditable } = this.editor; const { isEditable } = this.editor;
return [ return [
new Plugin({ new Plugin({
key: new PluginKey('table-header-control'),
props: { props: {
decorations: (state) => { decorations: (state) => {
if (!isEditable) { if (!isEditable) {
return DecorationSet.empty; return DecorationSet.empty;
} }
const { doc, selection } = state; const { doc, selection } = state;
const decorations: Decoration[] = []; const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection); const cells = getCellsInRow(0)(selection);
if (cells) { if (cells) {
this.storage.clearCallbacks.forEach((cb) => cb());
this.storage.clearCallbacks.length = 0;
cells.forEach(({ pos }, index) => { cells.forEach(({ pos }, index) => {
decorations.push( decorations.push(
Decoration.widget(pos + 1, () => { Decoration.widget(pos + 1, () => {
@ -58,17 +100,33 @@ export const TableHeader = BuiltInTableHeader.extend({
} }
const grip = document.createElement('a'); const grip = document.createElement('a');
grip.className = className; grip.className = className;
ReactDOM.render(
<Tooltip content="向后增加一列">
<IconPlus />
</Tooltip>,
grip
);
this.storage.clearCallbacks.push(() => {
ReactDOM.unmountComponentAtNode(grip);
});
grip.addEventListener('mousedown', (event) => { grip.addEventListener('mousedown', (event) => {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr)); this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
if (event.target !== grip) {
addColumnAfter(this.editor.state, this.editor.view.dispatch);
}
}); });
return grip; return grip;
}) })
); );
}); });
} }
return DecorationSet.create(doc, decorations); return DecorationSet.create(doc, decorations);
}, },
}, },

View File

@ -1,5 +1,7 @@
import BuiltInTable from '@tiptap/extension-table'; import BuiltInTable from '@tiptap/extension-table';
import { Node as ProseMirrorNode } from 'prosemirror-model'; import { Node as ProseMirrorNode } from 'prosemirror-model';
import { Plugin, PluginKey } from 'prosemirror-state';
import { tableNodeTypes } from 'prosemirror-tables';
import { NodeView } from 'prosemirror-view'; import { NodeView } from 'prosemirror-view';
function updateColumns( function updateColumns(
@ -105,6 +107,26 @@ class TableView implements NodeView {
} }
} }
export function readonlyTableView({ cellMinWidth = 25, View = TableView } = {}) {
const plugin = new Plugin({
key: new PluginKey('readonlyTableView'),
state: {
init(_, state) {
this.spec.props.nodeViews[tableNodeTypes(state.schema).table.name] = (node, view) =>
new View(node, cellMinWidth);
return {};
},
apply(tr, prev) {
return prev;
},
},
props: {
nodeViews: {},
},
});
return plugin;
}
export const Table = BuiltInTable.extend({ export const Table = BuiltInTable.extend({
// @ts-ignore // @ts-ignore
addOptions() { addOptions() {
@ -118,4 +140,8 @@ export const Table = BuiltInTable.extend({
allowTableNodeSelection: false, allowTableNodeSelection: false,
}; };
}, },
addProseMirrorPlugins() {
return [...this.parent(), !this.editor.isEditable && readonlyTableView()].filter(Boolean);
},
}).configure({ resizable: true }); }).configure({ resizable: true });

View File

@ -25,13 +25,14 @@ export const TableBubbleMenu = ({ editor }) => {
const shouldShow = useCallback(() => { const shouldShow = useCallback(() => {
return editor.isActive(Table.name); return editor.isActive(Table.name);
}, [editor]); }, [editor]);
const getRenderContainer = useCallback((node) => { const getRenderContainer = useCallback((node) => {
let container = node; let container = node;
// 文本节点 // 文本节点
if (container && !container.tag) { if (container && !container.tag) {
container = node.parentElement; container = node.parentElement;
} }
while (container && container.tagName !== 'TABLE') { while (container && !container.classList.contains('tableWrapper')) {
container = container.parentElement; container = container.parentElement;
} }
return container; return container;
@ -40,6 +41,7 @@ export const TableBubbleMenu = ({ editor }) => {
const deleteMe = useCallback(() => { const deleteMe = useCallback(() => {
deleteNode(Table.name, editor); deleteNode(Table.name, editor);
}, [editor]); }, [editor]);
const addColumnBefore = useCallback(() => editor.chain().focus().addColumnBefore().run(), [editor]); const addColumnBefore = useCallback(() => editor.chain().focus().addColumnBefore().run(), [editor]);
const addColumnAfter = useCallback(() => editor.chain().focus().addColumnAfter().run(), [editor]); const addColumnAfter = useCallback(() => editor.chain().focus().addColumnAfter().run(), [editor]);
const deleteColumn = useCallback(() => editor.chain().focus().deleteColumn().run(), [editor]); const deleteColumn = useCallback(() => editor.chain().focus().deleteColumn().run(), [editor]);
@ -59,7 +61,6 @@ export const TableBubbleMenu = ({ editor }) => {
pluginKey="table-bubble-menu" pluginKey="table-bubble-menu"
tippyOptions={{ tippyOptions={{
maxWidth: 'calc(100vw - 100px)', maxWidth: 'calc(100vw - 100px)',
placement: 'bottom',
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
getRenderContainer={getRenderContainer} getRenderContainer={getRenderContainer}

View File

@ -9,8 +9,8 @@ export const Table: React.FC<{ editor: Editor }> = ({ editor }) => {
return ( return (
<> <>
<TableBubbleMenu editor={editor} /> <TableBubbleMenu editor={editor} />
<TableRowBubbleMenu editor={editor} /> {/* <TableRowBubbleMenu editor={editor} /> */}
<TableColBubbleMenu editor={editor} /> {/* <TableColBubbleMenu editor={editor} /> */}
</> </>
); );
}; };

View File

@ -1,160 +1,282 @@
.ProseMirror { /* stylelint-disable */
.node-table { $tableBorderColor: var(--semi-color-border);
position: relative; $tableHeaderBgColor: var(--semi-color-fill-0);
margin-top: 0.75em; $tableSelectedBorderColor: rgb(0 101 255);
scrollbar-width: thin; $tableSelectedCellBgColor: transparent;
scrollbar-color: transparent transparent; $tableSelectedControlBgColor: #2584ff;
} $tableResizeHandleBgColor: #adf;
.scrollable { .tableWrapper {
padding-left: 1em; position: relative;
margin-left: -1em; margin: 0.5em 0px;
overflow: auto hidden;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
transition: border 250ms ease-in-out 0s;
}
.scrollable-shadow { &.has-focus {
position: absolute; .scrollWrapper {
top: 0; margin-top: -20px;
bottom: 0;
left: -1em;
width: 16px;
transition: box-shadow 250ms ease-in-out 0s;
border-width: 0 0 0 1em;
border-style: solid;
border-color: transparent;
border-image: initial;
pointer-events: none;
&.left {
box-shadow: 16px 0 16px -16px inset rgb(0 0 0 / 25%);
}
&.right {
right: 0;
left: auto;
box-shadow: rgb(0 0 0 / 25%) -16px 0 16px -16px inset;
&.is-editable {
&::after {
position: absolute;
top: 0;
right: 0;
width: 1em;
height: 1em;
background-color: var(--semi-color-nav-bg);
content: '';
}
}
} }
} }
}
table { .scrollWrapper {
width: 100%; overflow-y: hidden;
margin-top: 1em; overflow-x: auto;
border-radius: 4px; padding-left: 28px;
border-collapse: collapse; padding-top: 28px;
padding-bottom: 8px;
margin-left: -28px;
margin-top: -20px;
margin-bottom: -8px;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
-webkit-transition: border 250ms ease-in-out 0s;
transition: border 250ms ease-in-out 0s;
}
table {
width: 100%;
border-collapse: collapse;
border-radius: 4px;
overflow: auto;
box-sizing: border-box;
* {
box-sizing: border-box; box-sizing: border-box;
border-color: var(--semi-color-fill-2); }
&.is-readonly { tr {
margin-top: 0; position: relative;
} border-bottom: 1px solid $tableBorderColor;
}
td, th {
th { background: $tableHeaderBgColor;
position: relative; }
min-width: 100px;
padding: 4px 8px;
text-align: left;
vertical-align: top;
border: 1px solid rgb(232 235 237);
border-color: var(--semi-color-fill-2);
:not(a) { td,
&:first-of-type { th {
margin-top: 0; position: relative;
} vertical-align: top;
} border: 1px solid $tableBorderColor;
} position: relative;
padding: 4px 8px;
text-align: left;
min-width: 100px;
}
th { .selectedCell {
font-weight: bold; position: relative;
background-color: var(--semi-color-fill-0); border: 1px solid $tableSelectedBorderColor;
} background-color: $tableSelectedCellBgColor;
.selectedCell { &::after {
border-style: double; box-sizing: content-box;
border-color: rgb(0 101 255);
background: var(--semi-color-info-light-hover);
}
.grip-column {
position: absolute;
top: -1em;
left: 0;
z-index: 10;
display: block;
width: 100%;
height: 0.7em;
margin-bottom: 3px;
cursor: pointer;
background: #ced4da;
&:hover,
&.selected {
background: var(--semi-color-info);
}
}
.grip-row {
position: absolute;
top: 0;
left: -1em;
z-index: 10;
display: block;
width: 0.7em;
height: 100%; height: 100%;
margin-right: 3px; width: 100%;
cursor: pointer; border: 1px solid $tableSelectedBorderColor;
background: #ced4da; content: '';
&:hover,
&.selected {
background: var(--semi-color-info);
}
}
.grip-table {
position: absolute; position: absolute;
top: -1em; left: -1px;
left: -1em; top: -1px;
z-index: 10; bottom: 0px;
display: block; z-index: 12;
width: 0.8em; display: inline-block;
height: 0.8em; pointer-events: none;
cursor: pointer; }
background: #ced4da; }
border-radius: 50%;
&:hover, .grip-column {
&.selected { position: absolute;
background: var(--semi-color-info); top: -12px;
left: -1px;
width: 100%;
> span {
position: absolute;
top: -18px;
left: 100%;
transform: translateX(-8px);
display: inline-block;
width: 16px;
height: 16px;
font-size: 0;
cursor: pointer;
.semi-icon-default {
font-size: inherit;
} }
} }
.column-resize-handle { &::before {
content: '';
position: absolute;
left: 100%;
bottom: 4px;
transform: translateX(-1px);
width: 4px;
height: 4px;
background-color: $tableBorderColor;
border-radius: 50%;
display: block;
}
&::after {
box-sizing: content-box;
content: '';
cursor: pointer;
position: absolute; position: absolute;
top: 0; top: 0;
right: -2px; left: 0;
width: 100%;
height: 10px;
background: $tableHeaderBgColor;
border: 1px solid $tableBorderColor;
display: block;
}
&:hover {
color: $tableSelectedBorderColor;
> span {
font-size: 14px;
}
&::before {
display: none;
}
&::after {
background: $tableSelectedControlBgColor;
border-color: $tableSelectedControlBgColor;
}
}
&.last::after {
border-top-right-radius: 3px;
}
&.selected::after {
background: $tableSelectedControlBgColor;
border-color: $tableSelectedControlBgColor;
}
}
.grip-row {
position: absolute;
left: -12px;
top: -1px;
height: 100%;
> span {
transform: translateY(8px);
position: absolute;
left: -16px;
bottom: 4px;
display: inline-block;
width: 16px;
height: 16px;
font-size: 0;
cursor: pointer;
.semi-icon-default {
font-size: inherit;
}
}
&::before {
content: '';
position: absolute;
left: -10px;
bottom: -2px; bottom: -2px;
width: 4px; width: 4px;
pointer-events: none; height: 4px;
background-color: #adf; background-color: $tableBorderColor;
border-radius: 50%;
display: block;
}
&::after {
box-sizing: content-box;
content: '';
cursor: pointer;
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 10px;
background: $tableHeaderBgColor;
border: 1px solid $tableBorderColor;
display: block;
}
&:hover {
color: $tableSelectedBorderColor;
> span {
font-size: 14px;
}
&::before {
display: none;
}
&::after {
background: $tableSelectedControlBgColor;
border-color: $tableSelectedBorderColor;
}
}
&.last::after {
border-bottom-left-radius: 3px;
}
&.selected::after {
background: $tableSelectedControlBgColor;
border-color: $tableSelectedBorderColor;
} }
} }
.grip-table {
&::after {
box-sizing: content-box;
content: '';
cursor: pointer;
position: absolute;
top: -12px;
left: -12px;
display: block;
background: $tableHeaderBgColor;
width: 10px;
height: 10px;
border: 1px solid $tableBorderColor;
border-top-left-radius: 3px;
}
&:hover::after {
background: $tableSelectedControlBgColor;
border-color: $tableSelectedBorderColor;
}
&.selected::after {
background: $tableSelectedControlBgColor;
border-color: $tableSelectedBorderColor;
}
}
}
.column-resize-handle {
position: absolute;
top: 0;
right: -2px;
bottom: -2px;
width: 4px;
pointer-events: none;
background-color: $tableResizeHandleBgColor;
} }
.resize-cursor { .resize-cursor {