Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Support for Chinese, Japanese, and Korean IME in Safari browser. #233

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 8 additions & 1 deletion demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -84,6 +90,7 @@ let state = EditorState.create({
doc,
plugins: [
columnResizing(),
supportSafariIME(),
tableEditing(),
keymap({
Tab: goToNextCell(1),
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
170 changes: 170 additions & 0 deletions src/supportSafariIME.ts
Original file line number Diff line number Diff line change
@@ -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<StateAttr>('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 <p> 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 = '&ZeroWidthSpace;';
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;
}
99 changes: 98 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
};