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

feat: Table support #33

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
},
"homepage": "https://github.com/chatwoot/prosemirror-schema#readme",
"dependencies": {
"markdown-it": "^12.0.0",
"markdown-it-multimd-table": "^4.2.3",
"markdown-it-sup": "^1.0.0",
"markdown-it-table": "^4.1.1",
"prosemirror-commands": "^1.1.4",
"prosemirror-dropcursor": "^1.3.2",
"prosemirror-gapcursor": "^1.1.5",
Expand All @@ -31,7 +34,7 @@
"prosemirror-model": "^1.1.0",
"prosemirror-schema-list": "^1.1.4",
"prosemirror-state": "^1.3.3",
"prosemirror-tables": "^1.3.0",
"prosemirror-tables": "^1.5.0",
"prosemirror-utils": "^0.9.6",
"prosemirror-view": "^1.17.2"
},
Expand Down
18 changes: 18 additions & 0 deletions src/icons.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Plugin } from "prosemirror-state";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { menuBar } from "prosemirror-menu";

import { keymap } from "prosemirror-keymap";
import { tableEditing, goToNextCell } from "prosemirror-tables";
// import { tableInputRule } from "./rules/tables";
import Placeholder from "./Placeholder";
import {
listInputRules,
Expand Down Expand Up @@ -52,9 +54,19 @@ export const buildEditor = ({
onImageUpload,
}),
}),
// Add table-related plugins only if enabledMenuOptions includes 'table'
...(enabledMenuOptions.includes('table') ? [
// tableInputRule(schema),
tableEditing(),
keymap({
Tab: goToNextCell(1),
'Shift-Tab': goToNextCell(-1),
}),
] : []),
new Plugin({
props: {
attributes: { class: "ProseMirror-woot-style" },
},
}),

];
89 changes: 83 additions & 6 deletions src/menu/menuOptions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { wrapInList } from "prosemirror-schema-list";
import { TextSelection } from 'prosemirror-state';
import { Fragment } from 'prosemirror-model';
import { toggleMark } from "prosemirror-commands";
import { MenuItem } from "prosemirror-menu";
import { Dropdown, DropdownSubmenu, MenuItem } from "prosemirror-menu";
import { undo, redo } from "prosemirror-history";
import {
addColumnAfter,
addColumnBefore,
deleteColumn,
addRowAfter,
addRowBefore,
deleteRow,
mergeCells,
splitCell,
deleteTable,
} from 'prosemirror-tables';

import { openPrompt } from "../prompt";
import { TextField } from "../TextField";
import {
Expand Down Expand Up @@ -54,6 +68,56 @@ const headerItem = (nodeType, options) => {
});
};

const createTable = (state, dispatch) => {
const offset = state.tr.selection.anchor + 1;
const transaction = state.tr;
const createCell = () => state.schema.nodes.table_cell.createAndFill(null, state.schema.nodes.paragraph.create());
const node = state.schema.nodes.table.create(
null,
Fragment.fromArray([
state.schema.nodes.table_row.create(
null,
Fragment.fromArray([createCell(), createCell(), createCell()])
),
state.schema.nodes.table_row.create(
null,
Fragment.fromArray([createCell(), createCell(), createCell()])
)
])
);

if (dispatch) {
dispatch(
transaction
.replaceSelectionWith(node)
.setSelection(
TextSelection.near(
transaction.doc.resolve(offset)
)
)
);
}

return true;
}

const tableMenu = [
{
label: 'Insert table',
run: createTable,
icon: icons.insertTable // Assuming you have this icon
},
{ label: 'Delete table', run: deleteTable, select: deleteTable, icon: icons.deleteTable },
{ label: 'Insert column before', run: addColumnBefore, select: addColumnBefore, icon: icons.insertColumnBefore },
{ label: 'Insert column after', run: addColumnAfter, select: addColumnAfter, icon: icons.insertColumnAfter },
{ label: 'Delete column', run: deleteColumn, select: deleteColumn, icon: icons.deleteColumn },
{ label: 'Insert row before', run: addRowBefore, select: addRowBefore, icon: icons.insertRowBefore },
{ label: 'Insert row after', run: addRowAfter, select: addRowAfter, icon: icons.insertRowAfter },
{ label: 'Delete row', run: deleteRow, select: deleteRow, icon: icons.deleteRow },
{ label: 'Merge cells', run: mergeCells, select: mergeCells, icon: icons.mergeCells },
{ label: 'Split cell', run: splitCell, select: splitCell, icon: icons.splitCell },
];

const linkItem = (markType) =>
new MenuItem({
title: "Add or remove link",
Expand Down Expand Up @@ -99,6 +163,7 @@ const buildMenuOptions = (
"redo",
"bulletList",
"orderedList",
"table",
],
onImageUpload = () => {},
}
Expand Down Expand Up @@ -153,13 +218,25 @@ const buildMenuOptions = (
icon: icons.h3,
}),
imageUpload: imageUploadItem(schema.nodes.image, onImageUpload),
table: new Dropdown(tableMenu.map(item => new MenuItem({
title: item.label,
label: item.label,
run: item.run,
select: item.select,
enable: item.enable,
})), {
label: "Table",
title: "Table operations",
icon: icons.table,
class: "prosemirror-menu-table-dropdown",
}),
};

return [
enabledMenuOptions
.filter((menuOptionKey) => !!availableMenuOptions[menuOptionKey])
.map((menuOptionKey) => availableMenuOptions[menuOptionKey]),
];
const menuItems = enabledMenuOptions
.filter((menuOptionKey) => !!availableMenuOptions[menuOptionKey])
.map((menuOptionKey) => availableMenuOptions[menuOptionKey]);

return [menuItems];
};

export default buildMenuOptions;
1 change: 1 addition & 0 deletions src/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { listInputRules } from './lists';
export { linksInputRules } from './links';
export { blocksInputRule } from './blocks';
export { hrInputRules } from './hr';
export { tableInputRule } from './tables';
export { baseKeyMaps } from '../keymap';

export { textFormattingInputRules } from './marks';
75 changes: 75 additions & 0 deletions src/rules/tables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Token from "markdown-it/lib/token";

const BREAK_REGEX = /(?:^|[^\\])\\n/;

export default function markdownTables(md) {
// insert a new rule after the "inline" rules are parsed
md.core.ruler.after("inline", "tables-pm", (state) => {
const tokens = state.tokens;
let inside = false;

for (let i = tokens.length - 1; i > 0; i--) {
if (inside) {
tokens[i].level--;
}

// convert unescaped \n in the text into real br tag
if (tokens[i].type === "inline" && tokens[i].content.match(BREAK_REGEX)) {
const existing = tokens[i].children || [];
tokens[i].children = [];

existing.forEach((child) => {
const breakParts = child.content.split(BREAK_REGEX);

// a schema agnostic way to know if a node is inline code would be
// great, for now we are stuck checking the node type.
if (breakParts.length > 1 && child.type !== "code_inline") {
breakParts.forEach((part, index) => {
const token = new Token("text", "", 1);
token.content = part.trim();
if (tokens[i].children) tokens[i].children.push(token);

if (index < breakParts.length - 1) {
const brToken = new Token("br", "br", 1);
if (tokens[i].children) tokens[i].children.push(brToken);
}
});
} else if (tokens[i].children) {
tokens[i].children.push(child);
}
});
}

// filter out incompatible tokens from markdown-it that we don't need
// in prosemirror. thead/tbody do nothing.
if (
["thead_open", "thead_close", "tbody_open", "tbody_close"].includes(
tokens[i].type
)
) {
inside = !inside;
tokens.splice(i, 1);
}

if (["th_open", "td_open"].includes(tokens[i].type)) {
// markdown-it table parser does not return paragraphs inside the cells
// but prosemirror requires them, so we add 'em in here.
tokens.splice(i + 1, 0, new Token("paragraph_open", "p", 1));

// markdown-it table parser stores alignment as html styles, convert
// to a simple string here
const tokenAttrs = tokens[i].attrs;
if (tokenAttrs) {
const style = tokenAttrs[0][1];
tokens[i].info = style.split(":")[1];
}
}

if (["th_close", "td_close"].includes(tokens[i].type)) {
tokens.splice(i, 0, new Token("paragraph_close", "p", -1));
}
}

return false;
});
}
17 changes: 17 additions & 0 deletions src/schema/article.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { orderedList, bulletList, listItem } from 'prosemirror-schema-list';
import { Schema } from 'prosemirror-model';
import { schema } from 'prosemirror-markdown';
import { tableNodes } from 'prosemirror-tables';

export const fullSchema = new Schema({
nodes: {
Expand All @@ -22,6 +23,22 @@ export const fullSchema = new Schema({
group: 'block',
}),
list_item: Object.assign(listItem, { content: 'paragraph block*' }),
...tableNodes({
tableGroup: 'block',
cellContent: 'paragraph+',
cellAttributes: {
background: {
default: null,
getFromDOM: (dom) => dom.style.backgroundColor || null,
setDOMAttr: (value, attrs) => {
if (value) attrs.style = (attrs.style || '') + `background-color: ${value};`;
},
},
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
},
}),
},
marks: {
link: schema.spec.marks.get('link'),
Expand Down
32 changes: 30 additions & 2 deletions src/schema/markdown/articleParser.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import MarkdownIt from 'markdown-it';
import MarkdownItSup from 'markdown-it-sup';
import { MarkdownParser } from 'prosemirror-markdown';
import markdownItTable from '../../rules/tables';

import {
baseSchemaToMdMapping,
baseNodesMdToPmMapping,
Expand All @@ -14,6 +16,7 @@ export const articleSchemaToMdMapping = {
rule: 'hr',
heading: ['heading'],
image: 'image',
// table: 'table',
},
marks: { ...baseSchemaToMdMapping.marks },
};
Expand All @@ -33,20 +36,44 @@ export const articleMdToPmMapping = {
return { userId, userFullName };
},
},
table: {
node: 'table',
getAttrs: tok => ({ alignment: tok.info })
},
tr: {
node: 'table_row',
},
td: {
node: 'table_cell',
getAttrs: tok => ({
colspan: +(tok.attrGet("colspan") || 1),
rowspan: +(tok.attrGet("rowspan") || 1),
alignment: tok.info,
}),
},
th: {
node: 'table_header',
getAttrs: tok => ({
colspan: +(tok.attrGet("colspan") || 1),
rowspan: +(tok.attrGet("rowspan") || 1),
alignment: tok.info,
}),
},
};

const md = MarkdownIt('commonmark', {
html: false,
linkify: true,
breaks: true,
}).use(MarkdownItSup);
}).use(MarkdownItSup).use(markdownItTable);

md.enable([
// Process html entity - &#123;, &#xAF;, &quot;, ...
'entity',
// Process escaped chars and hardbreaks
'escape',
'hr',
// 'table',
]);

export class ArticleMarkdownTransformer {
Expand All @@ -66,11 +93,12 @@ export class ArticleMarkdownTransformer {
filterMdToPmSchemaMapping(schema, articleMdToPmMapping)
);
}

encode(_node) {
throw new Error('This is not implemented yet');
}

parse(content) {
return this.markdownParser.parse(content);
}
}
}
Loading
Loading