diff --git a/packages/client/src/tiptap/core/extensions/table-cell.tsx b/packages/client/src/tiptap/core/extensions/table-cell.tsx index 984285f9..963bf6cf 100644 --- a/packages/client/src/tiptap/core/extensions/table-cell.tsx +++ b/packages/client/src/tiptap/core/extensions/table-cell.tsx @@ -1,13 +1,18 @@ +import { IconPlus } from '@douyinfe/semi-icons'; 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 React from 'react'; +import ReactDOM from 'react-dom'; import { getCellsInColumn, isRowSelected, isTableSelected, selectRow, selectTable } from 'tiptap/prose-utils'; export interface TableCellOptions { HTMLAttributes: Record; } -export const TableCell = Node.create({ +export const TableCell = Node.create void> }>({ name: 'tableCell', content: 'block+', tableRole: 'cell', @@ -19,14 +24,6 @@ export const TableCell = Node.create({ }; }, - parseHTML() { - return [{ tag: 'td' }]; - }, - - renderHTML({ HTMLAttributes }) { - return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; - }, - addAttributes() { return { colspan: { @@ -46,7 +43,7 @@ export const TableCell = Node.create({ }, }, colwidth: { - default: [150], + default: [100], parseHTML: (element) => { const colwidth = element.getAttribute('colwidth'); const value = colwidth ? [parseInt(colwidth, 10)] : null; @@ -59,22 +56,44 @@ export const TableCell = Node.create({ }; }, + 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() { const { isEditable } = this.editor; return [ new Plugin({ + key: new PluginKey('table-cell-control'), props: { decorations: (state) => { if (!isEditable) { return DecorationSet.empty; } - const { doc, selection } = state; const decorations: Decoration[] = []; const cells = getCellsInColumn(0)(selection); - if (cells) { + this.storage.clearCallbacks.forEach((cb) => cb()); + this.storage.clearCallbacks.length = 0; + cells.forEach(({ pos }, index) => { if (index === 0) { decorations.push( @@ -89,8 +108,10 @@ export const TableCell = Node.create({ grip.addEventListener('mousedown', (event) => { event.preventDefault(); event.stopImmediatePropagation(); - this.editor.view.dispatch(selectTable(this.editor.state.tr)); - // this.options.onSelectTable(state); + this.editor.view.dispatch( + // @ts-ignore + selectTable(this.editor.state.tr) + ); }); return grip; }) @@ -99,7 +120,6 @@ export const TableCell = Node.create({ decorations.push( Decoration.widget(pos + 1, () => { const rowSelected = isRowSelected(index)(selection); - let className = 'grip-row'; if (rowSelected) { className += ' selected'; @@ -111,18 +131,41 @@ export const TableCell = Node.create({ className += ' last'; } const grip = document.createElement('a'); - grip.className = className; - grip.addEventListener('mousedown', (event) => { - event.preventDefault(); - event.stopImmediatePropagation(); - this.editor.view.dispatch(selectRow(index)(this.editor.state.tr)); + + ReactDOM.render( + + + , + 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 DecorationSet.create(doc, decorations); }, }, diff --git a/packages/client/src/tiptap/core/extensions/table-header.ts b/packages/client/src/tiptap/core/extensions/table-header.tsx similarity index 53% rename from packages/client/src/tiptap/core/extensions/table-header.ts rename to packages/client/src/tiptap/core/extensions/table-header.tsx index 18301029..4c44e14d 100644 --- a/packages/client/src/tiptap/core/extensions/table-header.ts +++ b/packages/client/src/tiptap/core/extensions/table-header.tsx @@ -1,9 +1,29 @@ -import { TableHeader as BuiltInTableHeader } from '@tiptap/extension-table-header'; -import { Plugin } from 'prosemirror-state'; +import { IconPlus } from '@douyinfe/semi-icons'; +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 React from 'react'; +import ReactDOM from 'react-dom'; import { getCellsInRow, isColumnSelected, selectColumn } from 'tiptap/prose-utils'; -export const TableHeader = BuiltInTableHeader.extend({ +export interface TableHeaderOptions { + HTMLAttributes: Record; +} + +export const TableHeader = Node.create void> }>({ + name: 'tableHeader', + content: 'block+', + tableRole: 'header_cell', + isolating: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + addAttributes() { return { colspan: { @@ -13,10 +33,10 @@ export const TableHeader = BuiltInTableHeader.extend({ default: 1, }, colwidth: { - default: [150], + default: [100], parseHTML: (element) => { 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; }, @@ -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() { const { isEditable } = this.editor; return [ new Plugin({ + key: new PluginKey('table-header-control'), props: { decorations: (state) => { if (!isEditable) { return DecorationSet.empty; } - const { doc, selection } = state; const decorations: Decoration[] = []; const cells = getCellsInRow(0)(selection); - if (cells) { + this.storage.clearCallbacks.forEach((cb) => cb()); + this.storage.clearCallbacks.length = 0; + cells.forEach(({ pos }, index) => { decorations.push( Decoration.widget(pos + 1, () => { @@ -58,17 +100,33 @@ export const TableHeader = BuiltInTableHeader.extend({ } const grip = document.createElement('a'); grip.className = className; + + ReactDOM.render( + + + , + grip + ); + + this.storage.clearCallbacks.push(() => { + ReactDOM.unmountComponentAtNode(grip); + }); + grip.addEventListener('mousedown', (event) => { event.preventDefault(); event.stopImmediatePropagation(); + 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 DecorationSet.create(doc, decorations); }, }, diff --git a/packages/client/src/tiptap/core/extensions/table.ts b/packages/client/src/tiptap/core/extensions/table.ts index a785a203..1f7dcc10 100644 --- a/packages/client/src/tiptap/core/extensions/table.ts +++ b/packages/client/src/tiptap/core/extensions/table.ts @@ -1,5 +1,7 @@ import BuiltInTable from '@tiptap/extension-table'; import { Node as ProseMirrorNode } from 'prosemirror-model'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { tableNodeTypes } from 'prosemirror-tables'; import { NodeView } from 'prosemirror-view'; 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({ // @ts-ignore addOptions() { @@ -118,4 +140,8 @@ export const Table = BuiltInTable.extend({ allowTableNodeSelection: false, }; }, + + addProseMirrorPlugins() { + return [...this.parent(), !this.editor.isEditable && readonlyTableView()].filter(Boolean); + }, }).configure({ resizable: true }); diff --git a/packages/client/src/tiptap/core/menus/table/bubble.tsx b/packages/client/src/tiptap/core/menus/table/bubble.tsx index 09ec7504..1f387dd0 100644 --- a/packages/client/src/tiptap/core/menus/table/bubble.tsx +++ b/packages/client/src/tiptap/core/menus/table/bubble.tsx @@ -25,13 +25,14 @@ export const TableBubbleMenu = ({ editor }) => { const shouldShow = useCallback(() => { return editor.isActive(Table.name); }, [editor]); + const getRenderContainer = useCallback((node) => { let container = node; // 文本节点 if (container && !container.tag) { container = node.parentElement; } - while (container && container.tagName !== 'TABLE') { + while (container && !container.classList.contains('tableWrapper')) { container = container.parentElement; } return container; @@ -40,6 +41,7 @@ export const TableBubbleMenu = ({ editor }) => { const deleteMe = useCallback(() => { deleteNode(Table.name, editor); }, [editor]); + const addColumnBefore = useCallback(() => editor.chain().focus().addColumnBefore().run(), [editor]); const addColumnAfter = useCallback(() => editor.chain().focus().addColumnAfter().run(), [editor]); const deleteColumn = useCallback(() => editor.chain().focus().deleteColumn().run(), [editor]); @@ -59,7 +61,6 @@ export const TableBubbleMenu = ({ editor }) => { pluginKey="table-bubble-menu" tippyOptions={{ maxWidth: 'calc(100vw - 100px)', - placement: 'bottom', }} shouldShow={shouldShow} getRenderContainer={getRenderContainer} diff --git a/packages/client/src/tiptap/core/menus/table/index.tsx b/packages/client/src/tiptap/core/menus/table/index.tsx index 14cc501a..a3a55ca6 100644 --- a/packages/client/src/tiptap/core/menus/table/index.tsx +++ b/packages/client/src/tiptap/core/menus/table/index.tsx @@ -9,8 +9,8 @@ export const Table: React.FC<{ editor: Editor }> = ({ editor }) => { return ( <> - - + {/* */} + {/* */} ); }; diff --git a/packages/client/src/tiptap/core/styles/table.scss b/packages/client/src/tiptap/core/styles/table.scss index 52ae460c..33dbe38c 100644 --- a/packages/client/src/tiptap/core/styles/table.scss +++ b/packages/client/src/tiptap/core/styles/table.scss @@ -1,160 +1,282 @@ -.ProseMirror { - .node-table { - position: relative; - margin-top: 0.75em; - scrollbar-width: thin; - scrollbar-color: transparent transparent; - } +/* stylelint-disable */ +$tableBorderColor: var(--semi-color-border); +$tableHeaderBgColor: var(--semi-color-fill-0); +$tableSelectedBorderColor: rgb(0 101 255); +$tableSelectedCellBgColor: transparent; +$tableSelectedControlBgColor: #2584ff; +$tableResizeHandleBgColor: #adf; - .scrollable { - padding-left: 1em; - margin-left: -1em; - overflow: auto hidden; - border-left: 1px solid transparent; - border-right: 1px solid transparent; - transition: border 250ms ease-in-out 0s; - } +.tableWrapper { + position: relative; + margin: 0.5em 0px; - .scrollable-shadow { - position: absolute; - top: 0; - 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: ''; - } - } + &.has-focus { + .scrollWrapper { + margin-top: -20px; } } +} - table { - width: 100%; - margin-top: 1em; - border-radius: 4px; - border-collapse: collapse; +.scrollWrapper { + overflow-y: hidden; + overflow-x: auto; + padding-left: 28px; + 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; - border-color: var(--semi-color-fill-2); + } - &.is-readonly { - margin-top: 0; - } + tr { + position: relative; + border-bottom: 1px solid $tableBorderColor; + } - td, - th { - 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); + th { + background: $tableHeaderBgColor; + } - :not(a) { - &:first-of-type { - margin-top: 0; - } - } - } + td, + th { + position: relative; + vertical-align: top; + border: 1px solid $tableBorderColor; + position: relative; + padding: 4px 8px; + text-align: left; + min-width: 100px; + } - th { - font-weight: bold; - background-color: var(--semi-color-fill-0); - } + .selectedCell { + position: relative; + border: 1px solid $tableSelectedBorderColor; + background-color: $tableSelectedCellBgColor; - .selectedCell { - border-style: double; - 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; + &::after { + box-sizing: content-box; height: 100%; - margin-right: 3px; - cursor: pointer; - background: #ced4da; - - &:hover, - &.selected { - background: var(--semi-color-info); - } - } - - .grip-table { + width: 100%; + border: 1px solid $tableSelectedBorderColor; + content: ''; position: absolute; - top: -1em; - left: -1em; - z-index: 10; - display: block; - width: 0.8em; - height: 0.8em; - cursor: pointer; - background: #ced4da; - border-radius: 50%; + left: -1px; + top: -1px; + bottom: 0px; + z-index: 12; + display: inline-block; + pointer-events: none; + } + } - &:hover, - &.selected { - background: var(--semi-color-info); + .grip-column { + position: absolute; + 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; 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; width: 4px; - pointer-events: none; - background-color: #adf; + height: 4px; + 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 {