diff --git a/README.md b/README.md index e9b76334..6fea9c52 100644 --- a/README.md +++ b/README.md @@ -236,3 +236,17 @@ start of the table, rather than the start of the document. Find the table map for the given table node. +### Safari IME support plugin:supportSafariIME +Fix the bug that occurs in Safari browser when using Chinese, Japanese, +or Korean language (IME) in empty cells, causing content duplication and +incorrect column addition. + + * It is an independent plugin just like columnResizing, so you can choose + to use it or not based on your needs. + + * Please be aware that this plugin disables the input behavior after selecting + a cell, as inputting after selecting a cell may cause an unresolved bug. + + * This plugin has refactored the logic for clearing cells after selection. + When pressing the delete key after selecting a cell, the cursor will be + positioned inside the first selected cell after the content is cleared. diff --git a/demo/demo.ts b/demo/demo.ts index 3cee56ab..7f8f2cdb 100644 --- a/demo/demo.ts +++ b/demo/demo.ts @@ -28,7 +28,13 @@ import { goToNextCell, deleteTable, } from '../src'; -import { tableEditing, columnResizing, tableNodes, fixTables } from '../src'; +import { + tableEditing, + columnResizing, + tableNodes, + fixTables, + supportSafariIME, +} from '../src'; const schema = new Schema({ nodes: baseSchema.spec.nodes.append( @@ -84,6 +90,7 @@ let state = EditorState.create({ doc, plugins: [ columnResizing(), + supportSafariIME(), tableEditing(), keymap({ Tab: goToNextCell(1), diff --git a/package.json b/package.json index 8f0ba5b3..9b089ab2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prosemirror-tables", - "version": "1.3.7", + "version": "1.3.8", "description": "ProseMirror's rowspan/colspan tables component", "type": "module", "main": "dist/index.cjs", diff --git a/src/index.ts b/src/index.ts index db21e4cb..69420d3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export { ResizeState, } from './columnresizing'; export type { ColumnResizingOptions, Dragging } from './columnresizing'; +export { supportSafariIME } from './supportSafariIME'; export * from './commands'; export { clipCells as __clipCells, diff --git a/src/supportSafariIME.ts b/src/supportSafariIME.ts new file mode 100644 index 00000000..930405e2 --- /dev/null +++ b/src/supportSafariIME.ts @@ -0,0 +1,170 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { Slice } from 'prosemirror-model'; +import { tableNodeTypes } from './schema'; +import { EditorState, TextSelection, Transaction } from 'prosemirror-state'; + +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { getAllCell } from './util'; + +import { CellSelection } from './cellselection'; + +interface StateAttr { + isCellSelectionNow: boolean; +} + +/** + * @public + */ +const supportSafariIMEPluginKey = new PluginKey('supportSafariIME'); + +const isSafari: boolean = /^((?!chrome|android).)*safari/i.test( + navigator.userAgent, +); + +let editorView: EditorView; +const isStopFromKey = (event: KeyboardEvent) => { + return ( + event.key !== 'Backspace' && + !event.metaKey && + !event.ctrlKey && + event.key.indexOf('Arrow') !== 0 + ); +}; +const keydownEvent = (event: KeyboardEvent) => { + if ( + editorView?.editable && + supportSafariIMEPluginKey.getState?.(editorView.state) + ?.isCellSelectionNow && + isStopFromKey(event) + ) { + event.preventDefault(); + } +}; +function deleteCellSelection( + state: EditorState, + dispatch?: (tr: Transaction) => void, +) { + const sel = state.selection; + if (!(sel instanceof CellSelection)) return false; + if (dispatch) { + const tr = state.tr; + const baseContent = tableNodeTypes(state.schema).cell.createAndFill()! + .content; + let lastCellPos = 0; + sel.forEachCell((cell, pos) => { + lastCellPos = lastCellPos || pos; + if (!cell.content.eq(baseContent)) + tr.replace( + tr.mapping.map(pos + 1), + tr.mapping.map(pos + cell.nodeSize - 1), + new Slice(baseContent, 0, 0), + ); + }); + // Map the old positions of the nodes to the new positions. + const mappedPos = tr.mapping.map(lastCellPos); + + // Get new document + const doc = tr.doc; + const resolvedPos = doc.resolve(mappedPos); + + // Position of the modified node. + const posAfter = resolvedPos.pos; + + // Set the cursor focus to the first selected cell. + // To locate the textNode add 2, posAfter is the position of the tableCell. + tr.setSelection(TextSelection.create(doc, posAfter + 2)); + + dispatch(tr); + } + return true; +} + +/** + * @public + */ +export function supportSafariIME(): Plugin { + const plugin = new Plugin({ + key: supportSafariIMEPluginKey, + view: (view: EditorView) => { + editorView = view; + return { + destroy() { + document.removeEventListener('keydown', keydownEvent, true); + }, + }; + }, + state: { + init() { + return { + isCellSelectionNow: false, + }; + }, + apply(tr, value, oldState, newState) { + const isCellSelectionBefore = + oldState.selection instanceof CellSelection; + const isCellSelectionNow = newState.selection instanceof CellSelection; + // When selecting a cell, register keyboard blocking events, and unbind when canceled. + if (!isCellSelectionBefore && isCellSelectionNow) { + document.addEventListener('keydown', keydownEvent, true); + } else if (!isCellSelectionNow && isCellSelectionBefore) { + document.removeEventListener('keydown', keydownEvent, true); + } + return { + isCellSelectionNow, + }; + }, + }, + props: { + decorations: (state) => { + const decorations: Decoration[] = []; + const { doc, selection } = state; + const isCellSelectionNow = state.selection instanceof CellSelection; + + if (editorView?.editable && isCellSelectionNow) { + const tableNode = state.selection.$anchor.node(1); + if (tableNode?.type?.name !== 'table') { + return DecorationSet.empty; + } + const tableNodePos = state.selection.$anchor.posAtIndex(0, 1) - 1; + decorations.push( + Decoration.node(tableNodePos, tableNodePos + tableNode.nodeSize, { + contenteditable: String( + editorView?.editable && !isCellSelectionNow, + ), + }), + ); + } + + if (isSafari) { + const allCellsArr = getAllCell(selection); + if (allCellsArr) { + // In order to solve the issue of safari removing the

tags and then re-adding them when inputting in empty cells in tables with IME on Safari. + allCellsArr.forEach(({ pos }) => { + decorations.push( + Decoration.widget(pos + 1, () => { + const grip = document.createElement('span'); + grip.setAttribute('style', 'display: block;line-height:0px;'); + grip.innerHTML = '​'; + return grip; + }), + ); + }); + } + } + + return DecorationSet.create(doc, decorations); + }, + handleKeyDown: (view, event) => { + // After selecting a cell and pressing the delete key, the cursor will move to the first selected cell after clearing the cell. + if (event.code === 'Backspace') { + const isCellSelection = view.state.selection instanceof CellSelection; + if (isCellSelection) { + return deleteCellSelection(view.state, view.dispatch); + } + } + }, + }, + }); + return plugin; +} diff --git a/src/util.ts b/src/util.ts index 70fd658f..b6c3fd54 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,11 @@ // Various helper function for working with tables -import { EditorState, NodeSelection, PluginKey } from 'prosemirror-state'; +import { + EditorState, + NodeSelection, + PluginKey, + Selection, +} from 'prosemirror-state'; import { Attrs, Node, ResolvedPos } from 'prosemirror-model'; import { CellSelection } from './cellselection'; @@ -193,3 +198,95 @@ export function columnIsHeader( return false; return true; } + +type Predicate = (node: Node) => boolean; + +function findParentNodeClosestToPos( + $pos: ResolvedPos, + predicate: Predicate, +): + | { + pos: number; + start: number; + depth: number; + node: Node; + } + | undefined { + for (let i = $pos.depth; i > 0; i -= 1) { + const node = $pos.node(i); + + if (predicate(node)) { + return { + pos: i > 0 ? $pos.before(i) : 0, + start: $pos.start(i), + depth: i, + node, + }; + } + } +} + +function findParentNode(predicate: Predicate) { + return (selection: Selection) => + findParentNodeClosestToPos(selection.$from, predicate); +} + +interface CellInfo { + pos: number; + start: number; + node: Node | null; +} + +export const getCellsInColumn = + (columnIndex: number) => (selection: Selection) => { + const table = findTable(selection); + if (table) { + const map = TableMap.get(table.node); + const indexes: number[] = Array.isArray(columnIndex) + ? columnIndex + : Array.from([columnIndex]); + return indexes.reduce((acc: CellInfo[], index: number) => { + if (index >= 0 && index <= map.width - 1) { + const cells = map.cellsInRect({ + left: index, + right: index + 1, + top: 0, + bottom: map.height, + }); + return acc.concat( + cells.map((nodePos: number) => { + const node = table.node.nodeAt(nodePos); + const pos: number = nodePos + table.start; + return { pos, start: pos + 1, node }; + }), + ); + } + return acc; + }, []); + } + }; + +export const findTable = (selection: Selection) => + findParentNode( + (node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table', + )(selection); + +export const getAllCell = (selection: Selection) => { + const table = findTable(selection); + if (table) { + const coloumNum = TableMap.get(table.node).width; + const cellsArr = []; + for (let i = 0; i < coloumNum; i++) { + const cellsResult = getCellsInColumn(i)(selection); + if (cellsResult) { + const cells = cellsResult.filter( + (cellInfo: CellInfo | undefined): cellInfo is CellInfo => + cellInfo !== undefined, + ); + cellsArr.push(...cells); + } + } + + return cellsArr; + } +};