Table improvements (#6958)
* Header toggling, resizable columns * Allow all blocks in table cells, disable column resizing in read-only * Fixed dynamic scroll shadows * Refactor, scroll styling * fix scrolling, tweaks * fix: Table layout lost on sort * fix: Caching of grip decorators * refactor * stash * fix first render shadows * stash * First add column grip, styles * Just add column/row click handlers left * fix: isTableSelected for single cell table * Refactor mousedown handlers * fix: 'Add row before' command missing on first row * fix overflow on rhs * fix: Error clicking column grip when menu is open * Hide table controls when printing * Restore table header background * fix: Header behavior when adding columns and rows at the edges * Tweak header styling * fix: Serialize and parsing of column attributes when copy/pasting fix: Column width is lost when changing column alignment
This commit is contained in:
@@ -230,7 +230,7 @@ export default function SelectionToolbar(props: Props) {
|
|||||||
if (isCodeSelection && selection.empty) {
|
if (isCodeSelection && selection.empty) {
|
||||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||||
} else if (isTableSelection) {
|
} else if (isTableSelection) {
|
||||||
items = getTableMenuItems(dictionary);
|
items = getTableMenuItems(state, dictionary);
|
||||||
} else if (colIndex !== undefined) {
|
} else if (colIndex !== undefined) {
|
||||||
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
|
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
|
||||||
} else if (rowIndex !== undefined) {
|
} else if (rowIndex !== undefined) {
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { TrashIcon } from "outline-icons";
|
import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
|
||||||
|
import { EditorState } from "prosemirror-state";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { MenuItem } from "@shared/editor/types";
|
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||||
|
import { MenuItem, TableLayout } from "@shared/editor/types";
|
||||||
import { Dictionary } from "~/hooks/useDictionary";
|
import { Dictionary } from "~/hooks/useDictionary";
|
||||||
|
|
||||||
export default function tableMenuItems(dictionary: Dictionary): MenuItem[] {
|
export default function tableMenuItems(
|
||||||
|
state: EditorState,
|
||||||
|
dictionary: Dictionary
|
||||||
|
): MenuItem[] {
|
||||||
|
const { schema } = state;
|
||||||
|
const isFullWidth = isNodeActive(schema.nodes.table, {
|
||||||
|
layout: TableLayout.fullWidth,
|
||||||
|
})(state);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
name: "setTableAttr",
|
||||||
|
tooltip: isFullWidth
|
||||||
|
? dictionary.alignDefaultWidth
|
||||||
|
: dictionary.alignFullWidth,
|
||||||
|
icon: <AlignFullWidthIcon />,
|
||||||
|
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||||
|
active: () => isFullWidth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "separator",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "deleteTable",
|
name: "deleteTable",
|
||||||
tooltip: dictionary.deleteTable,
|
tooltip: dictionary.deleteTable,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
InsertRightIcon,
|
InsertRightIcon,
|
||||||
ArrowIcon,
|
ArrowIcon,
|
||||||
MoreIcon,
|
MoreIcon,
|
||||||
|
TableHeaderColumnIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -78,15 +79,23 @@ export default function tableColMenuItems(
|
|||||||
{
|
{
|
||||||
icon: <MoreIcon />,
|
icon: <MoreIcon />,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
name: "toggleHeaderColumn",
|
||||||
|
label: dictionary.toggleHeader,
|
||||||
|
icon: <TableHeaderColumnIcon />,
|
||||||
|
visible: index === 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||||
label: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore,
|
label: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore,
|
||||||
icon: <InsertLeftIcon />,
|
icon: <InsertLeftIcon />,
|
||||||
|
attrs: { index },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||||
label: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter,
|
label: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter,
|
||||||
icon: <InsertRightIcon />,
|
icon: <InsertRightIcon />,
|
||||||
|
attrs: { index },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deleteColumn",
|
name: "deleteColumn",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
InsertAboveIcon,
|
InsertAboveIcon,
|
||||||
InsertBelowIcon,
|
InsertBelowIcon,
|
||||||
MoreIcon,
|
MoreIcon,
|
||||||
|
TableHeaderRowIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -19,11 +20,16 @@ export default function tableRowMenuItems(
|
|||||||
icon: <MoreIcon />,
|
icon: <MoreIcon />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
name: "addRowAfter",
|
name: "toggleHeaderRow",
|
||||||
|
label: dictionary.toggleHeader,
|
||||||
|
icon: <TableHeaderRowIcon />,
|
||||||
|
visible: index === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "addRowBefore",
|
||||||
label: dictionary.addRowBefore,
|
label: dictionary.addRowBefore,
|
||||||
icon: <InsertAboveIcon />,
|
icon: <InsertAboveIcon />,
|
||||||
attrs: { index: index - 1 },
|
attrs: { index },
|
||||||
visible: index !== 0,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "addRowAfter",
|
name: "addRowAfter",
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ export default function useDictionary() {
|
|||||||
|
|
||||||
return React.useMemo(
|
return React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
addColumnAfter: t("Insert after"),
|
addColumnAfter: t("Add column after"),
|
||||||
addColumnBefore: t("Insert before"),
|
addColumnBefore: t("Add column before"),
|
||||||
addRowAfter: t("Insert after"),
|
addRowAfter: t("Add row after"),
|
||||||
addRowBefore: t("Insert before"),
|
addRowBefore: t("Add row before"),
|
||||||
alignCenter: t("Align center"),
|
alignCenter: t("Align center"),
|
||||||
alignLeft: t("Align left"),
|
alignLeft: t("Align left"),
|
||||||
alignRight: t("Align right"),
|
alignRight: t("Align right"),
|
||||||
|
alignDefaultWidth: t("Default width"),
|
||||||
alignFullWidth: t("Full width"),
|
alignFullWidth: t("Full width"),
|
||||||
bulletList: t("Bulleted list"),
|
bulletList: t("Bulleted list"),
|
||||||
checkboxList: t("Todo list"),
|
checkboxList: t("Todo list"),
|
||||||
@@ -75,6 +76,7 @@ export default function useDictionary() {
|
|||||||
sortAsc: t("Sort ascending"),
|
sortAsc: t("Sort ascending"),
|
||||||
sortDesc: t("Sort descending"),
|
sortDesc: t("Sort descending"),
|
||||||
table: t("Table"),
|
table: t("Table"),
|
||||||
|
toggleHeader: t("Toggle header"),
|
||||||
mathInline: t("Math inline (LaTeX)"),
|
mathInline: t("Math inline (LaTeX)"),
|
||||||
mathBlock: t("Math block (LaTeX)"),
|
mathBlock: t("Math block (LaTeX)"),
|
||||||
tip: t("Tip"),
|
tip: t("Tip"),
|
||||||
|
|||||||
1
app/typings/styled-components.d.ts
vendored
1
app/typings/styled-components.d.ts
vendored
@@ -9,7 +9,6 @@ declare module "styled-components" {
|
|||||||
text: string;
|
text: string;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
divider: string;
|
divider: string;
|
||||||
tableDivider: string;
|
|
||||||
tableSelected: string;
|
tableSelected: string;
|
||||||
tableSelectedBackground: string;
|
tableSelectedBackground: string;
|
||||||
quote: string;
|
quote: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { JSDOM } from "jsdom";
|
|||||||
import { Node } from "prosemirror-model";
|
import { Node } from "prosemirror-model";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import textBetween from "@shared/editor/lib/textBetween";
|
import textBetween from "@shared/editor/lib/textBetween";
|
||||||
|
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||||
import { ProsemirrorData } from "@shared/types";
|
import { ProsemirrorData } from "@shared/types";
|
||||||
import { parser, serializer, schema } from "@server/editor";
|
import { parser, serializer, schema } from "@server/editor";
|
||||||
import { addTags } from "@server/logging/tracer";
|
import { addTags } from "@server/logging/tracer";
|
||||||
@@ -322,7 +323,7 @@ export class DocumentHelper {
|
|||||||
|
|
||||||
// Special case for largetables, as this block can get very large we
|
// Special case for largetables, as this block can get very large we
|
||||||
// want to clip it to only the changed rows and surrounding context.
|
// want to clip it to only the changed rows and surrounding context.
|
||||||
if (childNode.classList.contains("table-wrapper")) {
|
if (childNode.classList.contains(EditorStyleHelper.table)) {
|
||||||
const rows = childNode.querySelectorAll("tr");
|
const rows = childNode.querySelectorAll("tr");
|
||||||
if (rows.length < 3) {
|
if (rows.length < 3) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Fragment, Node, NodeType } from "prosemirror-model";
|
import { Fragment, Node, NodeType } from "prosemirror-model";
|
||||||
import {
|
import { Command, EditorState, TextSelection } from "prosemirror-state";
|
||||||
Command,
|
|
||||||
EditorState,
|
|
||||||
TextSelection,
|
|
||||||
Transaction,
|
|
||||||
} from "prosemirror-state";
|
|
||||||
import {
|
import {
|
||||||
CellSelection,
|
CellSelection,
|
||||||
addRow,
|
addRow,
|
||||||
isInTable,
|
isInTable,
|
||||||
selectedRect,
|
selectedRect,
|
||||||
tableNodeTypes,
|
tableNodeTypes,
|
||||||
|
toggleHeader,
|
||||||
|
addColumn,
|
||||||
} from "prosemirror-tables";
|
} from "prosemirror-tables";
|
||||||
import { getCellsInColumn } from "../queries/table";
|
import { chainTransactions } from "../lib/chainTransactions";
|
||||||
|
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
|
||||||
|
import { TableLayout } from "../types";
|
||||||
|
import collapseSelection from "./collapseSelection";
|
||||||
|
|
||||||
export function createTable({
|
export function createTable({
|
||||||
rowsCount,
|
rowsCount,
|
||||||
@@ -34,7 +34,7 @@ export function createTable({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTableInner(
|
function createTableInner(
|
||||||
state: EditorState,
|
state: EditorState,
|
||||||
rowsCount: number,
|
rowsCount: number,
|
||||||
colsCount: number,
|
colsCount: number,
|
||||||
@@ -93,6 +93,7 @@ export function sortTable({
|
|||||||
if (!isInTable(state)) {
|
if (!isInTable(state)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
const rect = selectedRect(state);
|
const rect = selectedRect(state);
|
||||||
const table: Node[][] = [];
|
const table: Node[][] = [];
|
||||||
@@ -159,7 +160,10 @@ export function sortTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// replace the original table with this sorted one
|
// replace the original table with this sorted one
|
||||||
const nodes = state.schema.nodes.table.createChecked(null, rows);
|
const nodes = state.schema.nodes.table.createChecked(
|
||||||
|
rect.table.attrs,
|
||||||
|
rows
|
||||||
|
);
|
||||||
const { tr } = state;
|
const { tr } = state;
|
||||||
|
|
||||||
tr.replaceRangeWith(
|
tr.replaceRangeWith(
|
||||||
@@ -168,21 +172,76 @@ export function sortTable({
|
|||||||
nodes
|
nodes
|
||||||
);
|
);
|
||||||
|
|
||||||
dispatch(
|
dispatch(tr.scrollIntoView());
|
||||||
tr
|
|
||||||
// .setSelection(
|
|
||||||
// CellSelection.create(
|
|
||||||
// tr.doc,
|
|
||||||
// rect.map.positionAt(0, index, rect.table)
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
.scrollIntoView()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command that safely adds a row taking into account any existing heading column at the top of
|
||||||
|
* the table, and preventing it moving "into" the table.
|
||||||
|
*
|
||||||
|
* @param index The index to add the row at, if undefined the current selection is used
|
||||||
|
* @returns The command
|
||||||
|
*/
|
||||||
|
export function addRowBefore({ index }: { index?: number }): Command {
|
||||||
|
return (state, dispatch) => {
|
||||||
|
if (!isInTable(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = selectedRect(state);
|
||||||
|
const isHeaderRowEnabled = isHeaderEnabled(state, "row", rect);
|
||||||
|
const position = index !== undefined ? index : rect.left;
|
||||||
|
|
||||||
|
// Special case when adding row to the beginning of the table to ensure the header does not
|
||||||
|
// move inwards.
|
||||||
|
const headerSpecialCase = position === 0 && isHeaderRowEnabled;
|
||||||
|
|
||||||
|
chainTransactions(
|
||||||
|
headerSpecialCase ? toggleHeader("row") : undefined,
|
||||||
|
(s, d) => !!d?.(addRow(s.tr, rect, position)),
|
||||||
|
headerSpecialCase ? toggleHeader("row") : undefined,
|
||||||
|
collapseSelection()
|
||||||
|
)(state, dispatch);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command that safely adds a column taking into account any existing heading column on the far
|
||||||
|
* left of the table, and preventing it moving "into" the table.
|
||||||
|
*
|
||||||
|
* @param index The index to add the column at, if undefined the current selection is used
|
||||||
|
* @returns The command
|
||||||
|
*/
|
||||||
|
export function addColumnBefore({ index }: { index?: number }): Command {
|
||||||
|
return (state, dispatch) => {
|
||||||
|
if (!isInTable(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = selectedRect(state);
|
||||||
|
const isHeaderColumnEnabled = isHeaderEnabled(state, "column", rect);
|
||||||
|
const position = index !== undefined ? index : rect.left;
|
||||||
|
|
||||||
|
// Special case when adding column to the beginning of the table to ensure the header does not
|
||||||
|
// move inwards.
|
||||||
|
const headerSpecialCase = position === 0 && isHeaderColumnEnabled;
|
||||||
|
|
||||||
|
chainTransactions(
|
||||||
|
headerSpecialCase ? toggleHeader("column") : undefined,
|
||||||
|
(s, d) => !!d?.(addColumn(s.tr, rect, position)),
|
||||||
|
headerSpecialCase ? toggleHeader("column") : undefined,
|
||||||
|
collapseSelection()
|
||||||
|
)(state, dispatch);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function addRowAndMoveSelection({
|
export function addRowAndMoveSelection({
|
||||||
index,
|
index,
|
||||||
}: {
|
}: {
|
||||||
@@ -222,6 +281,12 @@ export function addRowAndMoveSelection({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set column attributes. Passed attributes will be merged with existing.
|
||||||
|
*
|
||||||
|
* @param attrs The attributes to set
|
||||||
|
* @returns The command
|
||||||
|
*/
|
||||||
export function setColumnAttr({
|
export function setColumnAttr({
|
||||||
index,
|
index,
|
||||||
alignment,
|
alignment,
|
||||||
@@ -234,7 +299,9 @@ export function setColumnAttr({
|
|||||||
const cells = getCellsInColumn(index)(state) || [];
|
const cells = getCellsInColumn(index)(state) || [];
|
||||||
let transaction = state.tr;
|
let transaction = state.tr;
|
||||||
cells.forEach((pos) => {
|
cells.forEach((pos) => {
|
||||||
|
const node = state.doc.nodeAt(pos);
|
||||||
transaction = transaction.setNodeMarkup(pos, undefined, {
|
transaction = transaction.setNodeMarkup(pos, undefined, {
|
||||||
|
...node?.attrs,
|
||||||
alignment,
|
alignment,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -244,37 +311,78 @@ export function setColumnAttr({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectRow(index: number, expand = false) {
|
/**
|
||||||
return (state: EditorState): Transaction => {
|
* Set table attributes. Passed attributes will be merged with existing.
|
||||||
const rect = selectedRect(state);
|
*
|
||||||
const pos = rect.map.positionAt(index, 0, rect.table);
|
* @param attrs The attributes to set
|
||||||
const $pos = state.doc.resolve(rect.tableStart + pos);
|
* @returns The command
|
||||||
const rowSelection =
|
*/
|
||||||
expand && state.selection instanceof CellSelection
|
export function setTableAttr(attrs: { layout: TableLayout | null }): Command {
|
||||||
? CellSelection.rowSelection(state.selection.$anchorCell, $pos)
|
return (state, dispatch) => {
|
||||||
: CellSelection.rowSelection($pos);
|
if (!isInTable(state)) {
|
||||||
return state.tr.setSelection(rowSelection);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
const { tr } = state;
|
||||||
|
const rect = selectedRect(state);
|
||||||
|
|
||||||
|
tr.setNodeMarkup(rect.tableStart - 1, undefined, {
|
||||||
|
...rect.table.attrs,
|
||||||
|
...attrs,
|
||||||
|
}).setSelection(TextSelection.near(tr.doc.resolve(rect.tableStart)));
|
||||||
|
dispatch(tr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectColumn(index: number, expand = false) {
|
export function selectRow(index: number, expand = false): Command {
|
||||||
return (state: EditorState): Transaction => {
|
return (state: EditorState, dispatch): boolean => {
|
||||||
const rect = selectedRect(state);
|
if (dispatch) {
|
||||||
const pos = rect.map.positionAt(0, index, rect.table);
|
const rect = selectedRect(state);
|
||||||
const $pos = state.doc.resolve(rect.tableStart + pos);
|
const pos = rect.map.positionAt(index, 0, rect.table);
|
||||||
const colSelection =
|
const $pos = state.doc.resolve(rect.tableStart + pos);
|
||||||
expand && state.selection instanceof CellSelection
|
const rowSelection =
|
||||||
? CellSelection.colSelection(state.selection.$anchorCell, $pos)
|
expand && state.selection instanceof CellSelection
|
||||||
: CellSelection.colSelection($pos);
|
? CellSelection.rowSelection(state.selection.$anchorCell, $pos)
|
||||||
return state.tr.setSelection(colSelection);
|
: CellSelection.rowSelection($pos);
|
||||||
|
dispatch(state.tr.setSelection(rowSelection));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectTable(state: EditorState): Transaction {
|
export function selectColumn(index: number, expand = false): Command {
|
||||||
const rect = selectedRect(state);
|
return (state, dispatch): boolean => {
|
||||||
const map = rect.map.map;
|
if (dispatch) {
|
||||||
const $anchor = state.doc.resolve(rect.tableStart + map[0]);
|
const rect = selectedRect(state);
|
||||||
const $head = state.doc.resolve(rect.tableStart + map[map.length - 1]);
|
const pos = rect.map.positionAt(0, index, rect.table);
|
||||||
const tableSelection = new CellSelection($anchor, $head);
|
const $pos = state.doc.resolve(rect.tableStart + pos);
|
||||||
return state.tr.setSelection(tableSelection);
|
const colSelection =
|
||||||
|
expand && state.selection instanceof CellSelection
|
||||||
|
? CellSelection.colSelection(state.selection.$anchorCell, $pos)
|
||||||
|
: CellSelection.colSelection($pos);
|
||||||
|
dispatch(state.tr.setSelection(colSelection));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTable(): Command {
|
||||||
|
return (state, dispatch): boolean => {
|
||||||
|
if (dispatch) {
|
||||||
|
const rect = selectedRect(state);
|
||||||
|
const map = rect.map.map;
|
||||||
|
const $anchor = state.doc.resolve(rect.tableStart + map[0]);
|
||||||
|
const $head = state.doc.resolve(rect.tableStart + map[map.length - 1]);
|
||||||
|
const tableSelection = new CellSelection($anchor, $head);
|
||||||
|
dispatch(state.tr.setSelection(tableSelection));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NodeType } from "prosemirror-model";
|
import { NodeType } from "prosemirror-model";
|
||||||
import { wrapInList, liftListItem } from "prosemirror-schema-list";
|
import { wrapInList, liftListItem } from "prosemirror-schema-list";
|
||||||
import { Command } from "prosemirror-state";
|
import { Command } from "prosemirror-state";
|
||||||
import chainTransactions from "../lib/chainTransactions";
|
import { chainTransactions } from "../lib/chainTransactions";
|
||||||
import { findParentNode } from "../queries/findParentNode";
|
import { findParentNode } from "../queries/findParentNode";
|
||||||
import isList from "../queries/isList";
|
import isList from "../queries/isList";
|
||||||
import clearNodes from "./clearNodes";
|
import clearNodes from "./clearNodes";
|
||||||
@@ -35,10 +35,7 @@ export default function toggleList(
|
|||||||
) {
|
) {
|
||||||
tr.setNodeMarkup(parentList.pos, listType);
|
tr.setNodeMarkup(parentList.pos, listType);
|
||||||
|
|
||||||
if (dispatch) {
|
dispatch?.(tr);
|
||||||
dispatch(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable no-irregular-whitespace */
|
/* eslint-disable no-irregular-whitespace */
|
||||||
import { lighten, transparentize } from "polished";
|
import { lighten, transparentize } from "polished";
|
||||||
import styled, { DefaultTheme, css, keyframes } from "styled-components";
|
import styled, { DefaultTheme, css, keyframes } from "styled-components";
|
||||||
|
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||||
import { videoStyle } from "./Video";
|
import { videoStyle } from "./Video";
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
@@ -13,6 +14,11 @@ export type Props = {
|
|||||||
theme: DefaultTheme;
|
theme: DefaultTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fadeIn = keyframes`
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
export const pulse = keyframes`
|
export const pulse = keyframes`
|
||||||
0% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
|
0% { box-shadow: 0 0 0 1px rgba(255, 213, 0, 0.75) }
|
||||||
50% { box-shadow: 0 0 0 4px rgba(255, 213, 0, 0.75) }
|
50% { box-shadow: 0 0 0 4px rgba(255, 213, 0, 0.75) }
|
||||||
@@ -267,7 +273,7 @@ const emailStyle = (props: Props) => css`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const style = (props: Props) => `
|
const style = (props: Props) => css`
|
||||||
flex-grow: ${props.grow ? 1 : 0};
|
flex-grow: ${props.grow ? 1 : 0};
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
color: ${props.theme.text};
|
color: ${props.theme.text};
|
||||||
@@ -563,6 +569,40 @@ iframe.embed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.${EditorStyleHelper.tableFullWidth} {
|
||||||
|
transform: translateX(calc(50% + ${
|
||||||
|
EditorStyleHelper.padding
|
||||||
|
}px + var(--container-width) * -0.5));
|
||||||
|
|
||||||
|
.${EditorStyleHelper.tableScrollable},
|
||||||
|
table {
|
||||||
|
width: calc(var(--container-width) - ${EditorStyleHelper.padding * 2}px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.${EditorStyleHelper.tableShadowRight}::after {
|
||||||
|
left: calc(var(--container-width) - ${EditorStyleHelper.padding * 3}px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resize-handle {
|
||||||
|
animation: ${fadeIn} 150ms ease-in-out;
|
||||||
|
${props.readOnly ? "display: none;" : ""}
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
top: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
width: 2px;
|
||||||
|
z-index: 20;
|
||||||
|
background-color: ${props.theme.text};
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
${props.readOnly ? "pointer-events: none;" : ""}
|
||||||
|
cursor: ew-resize;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror-hideselection *::selection {
|
.ProseMirror-hideselection *::selection {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -1284,26 +1324,25 @@ table {
|
|||||||
|
|
||||||
tr {
|
tr {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-bottom: 1px solid ${props.theme.tableDivider};
|
border-bottom: 1px solid ${props.theme.divider};
|
||||||
}
|
|
||||||
|
|
||||||
tr:first-of-type {
|
|
||||||
background: ${props.theme.secondaryBackground};
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td,
|
td,
|
||||||
th {
|
th {
|
||||||
position: relative;
|
position: relative;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
border: 1px solid ${props.theme.tableDivider};
|
border: 1px solid ${props.theme.divider};
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
text-align: ${props.rtl ? "right" : "left"};
|
text-align: ${props.rtl ? "right" : "left"};
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: ${transparentize(0.75, props.theme.divider)};
|
||||||
|
color: ${props.theme.textSecondary};
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
td .component-embed {
|
td .component-embed {
|
||||||
@@ -1320,7 +1359,135 @@ table {
|
|||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grip-column {
|
.${EditorStyleHelper.tableAddRow},
|
||||||
|
.${EditorStyleHelper.tableAddColumn},
|
||||||
|
.${EditorStyleHelper.tableGrip},
|
||||||
|
.${EditorStyleHelper.tableGripColumn},
|
||||||
|
.${EditorStyleHelper.tableGripRow} {
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.${EditorStyleHelper.tableAddRow},
|
||||||
|
.${EditorStyleHelper.tableAddColumn} {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
background: ${props.theme.accent};
|
||||||
|
cursor: var(--pointer);
|
||||||
|
|
||||||
|
&:hover::after {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
background-color: ${props.theme.accent};
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-image: url("data:image/svg+xml;base64,${btoa(
|
||||||
|
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 5C11.4477 5 11 5.44772 11 6V11H6C5.44772 11 5 11.4477 5 12C5 12.5523 5.44772 13 6 13H11V18C11 18.5523 11.4477 19 12 19C12.5523 19 13 18.5523 13 18V13H18C18.5523 13 19 12.5523 19 12C19 11.4477 18.5523 11 18 11H13V6C13 5.44772 12.5523 5 12 5Z" fill="white"/></svg>'
|
||||||
|
)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// extra clickable area
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
cursor: var(--pointer);
|
||||||
|
position: absolute;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.${EditorStyleHelper.tableAddRow} {
|
||||||
|
bottom: -1px;
|
||||||
|
left: -16px;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: -10px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
display: ${props.readOnly ? "none" : "block"};
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: ${props.theme.divider};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
width: calc(var(--table-width) - ${EditorStyleHelper.padding * 1.5}px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::after {
|
||||||
|
bottom: -7.5px;
|
||||||
|
left: -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extra clickable area
|
||||||
|
&::before {
|
||||||
|
bottom: -12px;
|
||||||
|
left: -18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.first {
|
||||||
|
bottom: auto;
|
||||||
|
top: -1px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
bottom: auto;
|
||||||
|
top: -12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.${EditorStyleHelper.tableAddColumn} {
|
||||||
|
top: -16px;
|
||||||
|
right: -1px;
|
||||||
|
width: 2px;
|
||||||
|
height: 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -1px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
display: ${props.readOnly ? "none" : "block"};
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: ${props.theme.divider};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
height: calc(var(--table-height) - ${EditorStyleHelper.padding}px + 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::after {
|
||||||
|
top: -16px;
|
||||||
|
right: -7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extra clickable area
|
||||||
|
&::before {
|
||||||
|
top: -16px;
|
||||||
|
right: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.first {
|
||||||
|
right: auto;
|
||||||
|
left: -1px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
right: auto;
|
||||||
|
left: -12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.${EditorStyleHelper.tableGripColumn} {
|
||||||
/* usage of ::after for all of the table grips works around a bug in
|
/* usage of ::after for all of the table grips works around a bug in
|
||||||
* prosemirror-tables that causes Safari to hang when selecting a cell
|
* prosemirror-tables that causes Safari to hang when selecting a cell
|
||||||
* in an empty table:
|
* in an empty table:
|
||||||
@@ -1333,8 +1500,7 @@ table {
|
|||||||
${props.rtl ? "right" : "left"}: 0;
|
${props.rtl ? "right" : "left"}: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
background: ${props.theme.tableDivider};
|
background: ${props.theme.divider};
|
||||||
border-bottom: 3px solid ${props.theme.background};
|
|
||||||
display: ${props.readOnly ? "none" : "block"};
|
display: ${props.readOnly ? "none" : "block"};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,16 +1509,18 @@ table {
|
|||||||
}
|
}
|
||||||
&.first::after {
|
&.first::after {
|
||||||
border-top-${props.rtl ? "right" : "left"}-radius: 3px;
|
border-top-${props.rtl ? "right" : "left"}-radius: 3px;
|
||||||
|
border-bottom-${props.rtl ? "right" : "left"}-radius: 3px;
|
||||||
}
|
}
|
||||||
&.last::after {
|
&.last::after {
|
||||||
border-top-${props.rtl ? "left" : "right"}-radius: 3px;
|
border-top-${props.rtl ? "left" : "right"}-radius: 3px;
|
||||||
|
border-bottom-${props.rtl ? "left" : "right"}-radius: 3px;
|
||||||
}
|
}
|
||||||
&.selected::after {
|
&.selected::after {
|
||||||
background: ${props.theme.tableSelected};
|
background: ${props.theme.tableSelected};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grip-row {
|
.${EditorStyleHelper.tableGripRow} {
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
cursor: var(--pointer);
|
cursor: var(--pointer);
|
||||||
@@ -1361,8 +1529,7 @@ table {
|
|||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
background: ${props.theme.tableDivider};
|
background: ${props.theme.divider};
|
||||||
border-${props.rtl ? "left" : "right"}: 3px solid;
|
|
||||||
border-color: ${props.theme.background};
|
border-color: ${props.theme.background};
|
||||||
display: ${props.readOnly ? "none" : "block"};
|
display: ${props.readOnly ? "none" : "block"};
|
||||||
}
|
}
|
||||||
@@ -1371,21 +1538,23 @@ table {
|
|||||||
background: ${props.theme.text};
|
background: ${props.theme.text};
|
||||||
}
|
}
|
||||||
&.first::after {
|
&.first::after {
|
||||||
border-top-${props.rtl ? "right" : "left"}-radius: 3px;
|
border-top-left-radius: 3px;
|
||||||
|
border-top-right-radius: 3px;
|
||||||
}
|
}
|
||||||
&.last::after {
|
&.last::after {
|
||||||
border-bottom-${props.rtl ? "right" : "left"}-radius: 3px;
|
border-bottom-left-radius: 3px;
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
}
|
}
|
||||||
&.selected::after {
|
&.selected::after {
|
||||||
background: ${props.theme.tableSelected};
|
background: ${props.theme.tableSelected};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grip-table {
|
.${EditorStyleHelper.tableGrip} {
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
cursor: var(--pointer);
|
cursor: var(--pointer);
|
||||||
background: ${props.theme.tableDivider};
|
background: ${props.theme.divider};
|
||||||
width: 13px;
|
width: 13px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
@@ -1394,6 +1563,7 @@ table {
|
|||||||
top: -18px;
|
top: -18px;
|
||||||
${props.rtl ? "right" : "left"}: -18px;
|
${props.rtl ? "right" : "left"}: -18px;
|
||||||
display: ${props.readOnly ? "none" : "block"};
|
display: ${props.readOnly ? "none" : "block"};
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover::after {
|
&:hover::after {
|
||||||
@@ -1405,11 +1575,22 @@ table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable-wrapper {
|
.${EditorStyleHelper.table} {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0.5em 0px;
|
}
|
||||||
|
|
||||||
|
.${EditorStyleHelper.tableScrollable} {
|
||||||
|
position: relative;
|
||||||
|
margin: -1em ${-EditorStyleHelper.padding}px -0.5em;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: transparent transparent;
|
scrollbar-color: transparent transparent;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-top: 1em;
|
||||||
|
padding-bottom: .5em;
|
||||||
|
padding-left: ${EditorStyleHelper.padding}px;
|
||||||
|
padding-right: ${EditorStyleHelper.padding}px;
|
||||||
|
transition: border 250ms ease-in-out 0s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
scrollbar-color: ${props.theme.scrollbarThumb} ${
|
scrollbar-color: ${props.theme.scrollbarThumb} ${
|
||||||
@@ -1438,39 +1619,36 @@ table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable {
|
.${EditorStyleHelper.tableShadowLeft}::before,
|
||||||
overflow-y: hidden;
|
.${EditorStyleHelper.tableShadowRight}::after {
|
||||||
overflow-x: auto;
|
content: "";
|
||||||
padding-${props.rtl ? "right" : "left"}: 1em;
|
|
||||||
margin-${props.rtl ? "right" : "left"}: -1em;
|
|
||||||
transition: border 250ms ease-in-out 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollable-shadow {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 1px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
${props.rtl ? "right" : "left"}: -1em;
|
${props.rtl ? "right" : "left"}: -1em;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
z-index: 1;
|
z-index: 20;
|
||||||
transition: box-shadow 250ms ease-in-out;
|
transition: box-shadow 250ms ease-in-out;
|
||||||
border: 0px solid transparent;
|
border: 0px solid transparent;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
&.left {
|
.${EditorStyleHelper.tableShadowLeft}::before {
|
||||||
box-shadow: 16px 0 16px -16px inset rgba(0, 0, 0, ${
|
left: -${EditorStyleHelper.padding}px;
|
||||||
props.theme.isDark ? 1 : 0.25
|
right: auto;
|
||||||
});
|
box-shadow: 16px 0 16px -16px inset rgba(0, 0, 0, ${
|
||||||
border-left: 1em solid ${props.theme.background};
|
props.theme.isDark ? 1 : 0.25
|
||||||
}
|
});
|
||||||
|
border-left: ${EditorStyleHelper.padding}px solid ${props.theme.background};
|
||||||
|
}
|
||||||
|
|
||||||
&.right {
|
.${EditorStyleHelper.tableShadowRight}::after {
|
||||||
right: 0;
|
right: -${EditorStyleHelper.padding}px;
|
||||||
left: auto;
|
left: auto;
|
||||||
box-shadow: -16px 0 16px -16px inset rgba(0, 0, 0, ${
|
box-shadow: -16px 0 16px -16px inset rgba(0, 0, 0, ${
|
||||||
props.theme.isDark ? 1 : 0.25
|
props.theme.isDark ? 1 : 0.25
|
||||||
});
|
});
|
||||||
}
|
border-right: ${EditorStyleHelper.padding}px solid ${props.theme.background};
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-menu-trigger {
|
.block-menu-trigger {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { Command, Transaction } from "prosemirror-state";
|
import { Command, Transaction } from "prosemirror-state";
|
||||||
|
|
||||||
export default function chainTransactions(...commands: Command[]): Command {
|
/**
|
||||||
|
* Chain multiple commands into a single command and collects state as it goes.
|
||||||
|
*
|
||||||
|
* @param commands The commands to chain
|
||||||
|
* @returns The chained command
|
||||||
|
*/
|
||||||
|
export function chainTransactions(
|
||||||
|
...commands: (Command | undefined)[]
|
||||||
|
): Command {
|
||||||
return (state, dispatch): boolean => {
|
return (state, dispatch): boolean => {
|
||||||
const dispatcher = (tr: Transaction): void => {
|
const dispatcher = (tr: Transaction): void => {
|
||||||
state = state.apply(tr);
|
state = state.apply(tr);
|
||||||
dispatch?.(tr);
|
dispatch?.(tr);
|
||||||
};
|
};
|
||||||
const last = commands.pop();
|
const last = commands.pop();
|
||||||
const reduced = commands.reduce(
|
commands.map((command) => command?.(state, dispatcher));
|
||||||
(result, command) => result || command(state, dispatcher),
|
return last !== undefined && last(state, dispatch);
|
||||||
false
|
|
||||||
);
|
|
||||||
return reduced && last !== undefined && last(state, dispatch);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
67
shared/editor/lib/table.ts
Normal file
67
shared/editor/lib/table.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Attrs, Node } from "prosemirror-model";
|
||||||
|
import { MutableAttrs } from "prosemirror-tables";
|
||||||
|
import { TableLayout } from "../types";
|
||||||
|
|
||||||
|
export interface TableAttrs {
|
||||||
|
layout: TableLayout | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellAttrs {
|
||||||
|
colspan: number;
|
||||||
|
rowspan: number;
|
||||||
|
colwidth: number[] | null;
|
||||||
|
alignment: "center" | "left" | "right" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get cell attributes from a DOM node, used when pasting table content.
|
||||||
|
*
|
||||||
|
* @param dom DOM node to get attributes from
|
||||||
|
* @returns Cell attributes
|
||||||
|
*/
|
||||||
|
export function getCellAttrs(dom: HTMLElement | string): Attrs {
|
||||||
|
if (typeof dom === "string") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthAttr = dom.getAttribute("data-colwidth");
|
||||||
|
const widths =
|
||||||
|
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
|
||||||
|
? widthAttr.split(",").map((s) => Number(s))
|
||||||
|
: null;
|
||||||
|
const colspan = Number(dom.getAttribute("colspan") || 1);
|
||||||
|
return {
|
||||||
|
colspan,
|
||||||
|
rowspan: Number(dom.getAttribute("rowspan") || 1),
|
||||||
|
colwidth: widths && widths.length === colspan ? widths : null,
|
||||||
|
alignment:
|
||||||
|
dom.style.textAlign === "center"
|
||||||
|
? "center"
|
||||||
|
: dom.style.textAlign === "right"
|
||||||
|
? "right"
|
||||||
|
: null,
|
||||||
|
} satisfies CellAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to serialize cell attributes on a node, used when copying table content.
|
||||||
|
*
|
||||||
|
* @param node Node to get attributes from
|
||||||
|
* @returns Attributes for the cell
|
||||||
|
*/
|
||||||
|
export function setCellAttrs(node: Node): Attrs {
|
||||||
|
const attrs: MutableAttrs = {};
|
||||||
|
if (node.attrs.colspan !== 1) {
|
||||||
|
attrs.colspan = node.attrs.colspan;
|
||||||
|
}
|
||||||
|
if (node.attrs.rowspan !== 1) {
|
||||||
|
attrs.rowspan = node.attrs.rowspan;
|
||||||
|
}
|
||||||
|
if (node.attrs.colwidth) {
|
||||||
|
attrs["data-colwidth"] = node.attrs.colwidth.join(",");
|
||||||
|
}
|
||||||
|
if (node.attrs.alignment) {
|
||||||
|
attrs.style = `text-align: ${node.attrs.alignment}`;
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { MarkSpec, MarkType, Schema, Mark as PMMark } from "prosemirror-model";
|
|||||||
import { Command, Plugin } from "prosemirror-state";
|
import { Command, Plugin } from "prosemirror-state";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import collapseSelection from "../commands/collapseSelection";
|
import collapseSelection from "../commands/collapseSelection";
|
||||||
import chainTransactions from "../lib/chainTransactions";
|
import { chainTransactions } from "../lib/chainTransactions";
|
||||||
import isMarkActive from "../queries/isMarkActive";
|
import isMarkActive from "../queries/isMarkActive";
|
||||||
import Mark from "./Mark";
|
import Mark from "./Mark";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Token from "markdown-it/lib/token";
|
import Token from "markdown-it/lib/token";
|
||||||
import { OpenIcon } from "outline-icons";
|
|
||||||
import { toggleMark } from "prosemirror-commands";
|
import { toggleMark } from "prosemirror-commands";
|
||||||
import { InputRule } from "prosemirror-inputrules";
|
import { InputRule } from "prosemirror-inputrules";
|
||||||
import { MarkdownSerializerState } from "prosemirror-markdown";
|
import { MarkdownSerializerState } from "prosemirror-markdown";
|
||||||
@@ -11,8 +10,6 @@ import {
|
|||||||
} from "prosemirror-model";
|
} from "prosemirror-model";
|
||||||
import { Command, EditorState, Plugin, TextSelection } from "prosemirror-state";
|
import { Command, EditorState, Plugin, TextSelection } from "prosemirror-state";
|
||||||
import { EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import * as React from "react";
|
|
||||||
import ReactDOM from "react-dom";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { sanitizeUrl } from "../../utils/urls";
|
import { sanitizeUrl } from "../../utils/urls";
|
||||||
import getMarkRange from "../queries/getMarkRange";
|
import getMarkRange from "../queries/getMarkRange";
|
||||||
@@ -21,14 +18,6 @@ import { EventType } from "../types";
|
|||||||
import Mark from "./Mark";
|
import Mark from "./Mark";
|
||||||
|
|
||||||
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
|
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
|
||||||
let icon: HTMLSpanElement;
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const component = <OpenIcon size={16} />;
|
|
||||||
icon = document.createElement("span");
|
|
||||||
icon.className = "external-link";
|
|
||||||
ReactDOM.render(component, icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainURL(
|
function isPlainURL(
|
||||||
link: ProsemirrorMark,
|
link: ProsemirrorMark,
|
||||||
@@ -198,6 +187,10 @@ export default class Link extends Mark {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (target.role === "button") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// clicking a link while editing should show the link toolbar,
|
// clicking a link while editing should show the link toolbar,
|
||||||
// clicking in read-only will navigate
|
// clicking in read-only will navigate
|
||||||
if (!view.editable || (view.editable && !view.hasFocus())) {
|
if (!view.editable || (view.editable && !view.hasFocus())) {
|
||||||
|
|||||||
@@ -1,28 +1,35 @@
|
|||||||
import { chainCommands } from "prosemirror-commands";
|
import { chainCommands } from "prosemirror-commands";
|
||||||
import { NodeSpec, Node as ProsemirrorNode } from "prosemirror-model";
|
import { NodeSpec, Node as ProsemirrorNode } from "prosemirror-model";
|
||||||
import { Plugin } from "prosemirror-state";
|
|
||||||
import {
|
import {
|
||||||
addColumnAfter,
|
addColumnAfter,
|
||||||
addColumnBefore,
|
addRowAfter,
|
||||||
|
columnResizing,
|
||||||
deleteColumn,
|
deleteColumn,
|
||||||
deleteRow,
|
deleteRow,
|
||||||
deleteTable,
|
deleteTable,
|
||||||
goToNextCell,
|
goToNextCell,
|
||||||
tableEditing,
|
tableEditing,
|
||||||
toggleHeaderCell,
|
toggleHeader,
|
||||||
toggleHeaderColumn,
|
|
||||||
toggleHeaderRow,
|
|
||||||
} from "prosemirror-tables";
|
} from "prosemirror-tables";
|
||||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
||||||
import {
|
import {
|
||||||
|
addRowBefore,
|
||||||
|
addColumnBefore,
|
||||||
addRowAndMoveSelection,
|
addRowAndMoveSelection,
|
||||||
setColumnAttr,
|
setColumnAttr,
|
||||||
createTable,
|
createTable,
|
||||||
sortTable,
|
sortTable,
|
||||||
|
setTableAttr,
|
||||||
} from "../commands/table";
|
} from "../commands/table";
|
||||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||||
import tablesRule from "../rules/tables";
|
import tablesRule from "../rules/tables";
|
||||||
|
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||||
|
import { TableLayout } from "../types";
|
||||||
import Node from "./Node";
|
import Node from "./Node";
|
||||||
|
import { TableView } from "./TableView";
|
||||||
|
|
||||||
|
export type TableAttrs = {
|
||||||
|
layout: TableLayout | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default class Table extends Node {
|
export default class Table extends Node {
|
||||||
get name() {
|
get name() {
|
||||||
@@ -36,15 +43,17 @@ export default class Table extends Node {
|
|||||||
isolating: true,
|
isolating: true,
|
||||||
group: "block",
|
group: "block",
|
||||||
parseDOM: [{ tag: "table" }],
|
parseDOM: [{ tag: "table" }],
|
||||||
|
attrs: {
|
||||||
|
layout: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
toDOM() {
|
toDOM() {
|
||||||
|
// Note: This is overridden by TableView
|
||||||
return [
|
return [
|
||||||
"div",
|
"div",
|
||||||
{ class: "scrollable-wrapper table-wrapper" },
|
{ class: EditorStyleHelper.table },
|
||||||
[
|
["table", {}, ["tbody", 0]],
|
||||||
"div",
|
|
||||||
{ class: "scrollable" },
|
|
||||||
["table", { class: "rme-table" }, ["tbody", 0]],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -58,16 +67,17 @@ export default class Table extends Node {
|
|||||||
return {
|
return {
|
||||||
createTable,
|
createTable,
|
||||||
setColumnAttr,
|
setColumnAttr,
|
||||||
|
setTableAttr,
|
||||||
sortTable,
|
sortTable,
|
||||||
addColumnBefore: () => addColumnBefore,
|
addColumnBefore,
|
||||||
addColumnAfter: () => addColumnAfter,
|
addColumnAfter: () => addColumnAfter,
|
||||||
deleteColumn: () => deleteColumn,
|
deleteColumn: () => deleteColumn,
|
||||||
addRowAfter: addRowAndMoveSelection,
|
addRowBefore,
|
||||||
|
addRowAfter: () => addRowAfter,
|
||||||
deleteRow: () => deleteRow,
|
deleteRow: () => deleteRow,
|
||||||
deleteTable: () => deleteTable,
|
deleteTable: () => deleteTable,
|
||||||
toggleHeaderColumn: () => toggleHeaderColumn,
|
toggleHeaderColumn: () => toggleHeader("column"),
|
||||||
toggleHeaderRow: () => toggleHeaderRow,
|
toggleHeaderRow: () => toggleHeader("row"),
|
||||||
toggleHeaderCell: () => toggleHeaderCell,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,52 +100,12 @@ export default class Table extends Node {
|
|||||||
|
|
||||||
get plugins() {
|
get plugins() {
|
||||||
return [
|
return [
|
||||||
tableEditing(),
|
// Note: Important to register columnResizing before tableEditing
|
||||||
new Plugin({
|
columnResizing({
|
||||||
props: {
|
View: TableView,
|
||||||
decorations: (state) => {
|
lastColumnResizable: false,
|
||||||
const { doc } = state;
|
|
||||||
const decorations: Decoration[] = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
|
||||||
if (node.type.name !== this.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elements = document.getElementsByClassName("rme-table");
|
|
||||||
const table = elements[index];
|
|
||||||
if (!table) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = table.parentElement;
|
|
||||||
const shadowRight = !!(
|
|
||||||
element && element.scrollWidth > element.clientWidth
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shadowRight) {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget(
|
|
||||||
pos + 1,
|
|
||||||
() => {
|
|
||||||
const shadow = document.createElement("div");
|
|
||||||
shadow.className = "scrollable-shadow right";
|
|
||||||
return shadow;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "table-shadow-right",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
});
|
|
||||||
|
|
||||||
return DecorationSet.create(doc, decorations);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
tableEditing(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import Token from "markdown-it/lib/token";
|
|||||||
import { NodeSpec } from "prosemirror-model";
|
import { NodeSpec } from "prosemirror-model";
|
||||||
import { Plugin } from "prosemirror-state";
|
import { Plugin } from "prosemirror-state";
|
||||||
import { DecorationSet, Decoration } from "prosemirror-view";
|
import { DecorationSet, Decoration } from "prosemirror-view";
|
||||||
import { selectRow, selectTable } from "../commands/table";
|
import { addRowBefore, selectRow, selectTable } from "../commands/table";
|
||||||
|
import { getCellAttrs, setCellAttrs } from "../lib/table";
|
||||||
import {
|
import {
|
||||||
getCellsInColumn,
|
getCellsInColumn,
|
||||||
isRowSelected,
|
isRowSelected,
|
||||||
isTableSelected,
|
isTableSelected,
|
||||||
} from "../queries/table";
|
} from "../queries/table";
|
||||||
|
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||||
|
import { cn } from "../styles/utils";
|
||||||
import Node from "./Node";
|
import Node from "./Node";
|
||||||
|
|
||||||
export default class TableCell extends Node {
|
export default class TableCell extends Node {
|
||||||
@@ -17,23 +20,18 @@ export default class TableCell extends Node {
|
|||||||
|
|
||||||
get schema(): NodeSpec {
|
get schema(): NodeSpec {
|
||||||
return {
|
return {
|
||||||
content: "(paragraph | embed)+",
|
content: "block+",
|
||||||
tableRole: "cell",
|
tableRole: "cell",
|
||||||
isolating: true,
|
isolating: true,
|
||||||
parseDOM: [{ tag: "td" }],
|
parseDOM: [{ tag: "td", getAttrs: getCellAttrs }],
|
||||||
toDOM(node) {
|
toDOM(node) {
|
||||||
return [
|
return ["td", setCellAttrs(node), 0];
|
||||||
"td",
|
|
||||||
node.attrs.alignment
|
|
||||||
? { style: `text-align: ${node.attrs.alignment}` }
|
|
||||||
: {},
|
|
||||||
0,
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
attrs: {
|
attrs: {
|
||||||
colspan: { default: 1 },
|
colspan: { default: 1 },
|
||||||
rowspan: { default: 1 },
|
rowspan: { default: 1 },
|
||||||
alignment: { default: null },
|
alignment: { default: null },
|
||||||
|
colwidth: { default: null },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -50,61 +48,129 @@ export default class TableCell extends Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get plugins() {
|
get plugins() {
|
||||||
|
function buildAddRowDecoration(pos: number, index: number) {
|
||||||
|
const className = cn(EditorStyleHelper.tableAddRow, {
|
||||||
|
first: index === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Decoration.widget(
|
||||||
|
pos + 1,
|
||||||
|
() => {
|
||||||
|
const plus = document.createElement("a");
|
||||||
|
plus.role = "button";
|
||||||
|
plus.className = className;
|
||||||
|
plus.dataset.index = index.toString();
|
||||||
|
return plus;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: cn(className, index),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
props: {
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
mousedown: (view, event) => {
|
||||||
|
if (!(event.target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAddRow = event.target.closest(
|
||||||
|
`.${EditorStyleHelper.tableAddRow}`
|
||||||
|
);
|
||||||
|
if (targetAddRow) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
const index = Number(targetAddRow.getAttribute("data-index"));
|
||||||
|
|
||||||
|
addRowBefore({ index })(view.state, view.dispatch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetGrip = event.target.closest(
|
||||||
|
`.${EditorStyleHelper.tableGrip}`
|
||||||
|
);
|
||||||
|
if (targetGrip) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
selectTable()(view.state, view.dispatch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetGripRow = event.target.closest(
|
||||||
|
`.${EditorStyleHelper.tableGripRow}`
|
||||||
|
);
|
||||||
|
if (targetGripRow) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
selectRow(
|
||||||
|
Number(targetGripRow.getAttribute("data-index")),
|
||||||
|
event.metaKey || event.shiftKey
|
||||||
|
)(view.state, view.dispatch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
decorations: (state) => {
|
decorations: (state) => {
|
||||||
const { doc } = state;
|
const { doc } = state;
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = [];
|
||||||
const cells = getCellsInColumn(0)(state);
|
const rows = getCellsInColumn(0)(state);
|
||||||
|
|
||||||
if (cells) {
|
if (rows) {
|
||||||
cells.forEach((pos, index) => {
|
rows.forEach((pos, index) => {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
|
const className = cn(EditorStyleHelper.tableGrip, {
|
||||||
|
selected: isTableSelected(state),
|
||||||
|
});
|
||||||
|
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.widget(pos + 1, () => {
|
Decoration.widget(
|
||||||
let className = "grip-table";
|
pos + 1,
|
||||||
const selected = isTableSelected(state);
|
() => {
|
||||||
if (selected) {
|
const grip = document.createElement("a");
|
||||||
className += " selected";
|
grip.role = "button";
|
||||||
|
grip.className = className;
|
||||||
|
return grip;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: className,
|
||||||
}
|
}
|
||||||
const grip = document.createElement("a");
|
)
|
||||||
grip.className = className;
|
|
||||||
grip.addEventListener("mousedown", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
this.editor.view.dispatch(selectTable(state));
|
|
||||||
});
|
|
||||||
return grip;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
decorations.push(
|
|
||||||
Decoration.widget(pos + 1, () => {
|
|
||||||
const rowSelected = isRowSelected(index)(state);
|
|
||||||
|
|
||||||
let className = "grip-row";
|
const className = cn(EditorStyleHelper.tableGripRow, {
|
||||||
if (rowSelected) {
|
selected: isRowSelected(index)(state),
|
||||||
className += " selected";
|
first: index === 0,
|
||||||
|
last: index === rows.length - 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(
|
||||||
|
pos + 1,
|
||||||
|
() => {
|
||||||
|
const grip = document.createElement("a");
|
||||||
|
grip.role = "button";
|
||||||
|
grip.className = className;
|
||||||
|
grip.dataset.index = index.toString();
|
||||||
|
return grip;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: cn(className, index),
|
||||||
}
|
}
|
||||||
if (index === 0) {
|
)
|
||||||
className += " first";
|
|
||||||
}
|
|
||||||
if (index === cells.length - 1) {
|
|
||||||
className += " last";
|
|
||||||
}
|
|
||||||
const grip = document.createElement("a");
|
|
||||||
grip.className = className;
|
|
||||||
grip.addEventListener("mousedown", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
this.editor.view.dispatch(
|
|
||||||
selectRow(index, event.metaKey || event.shiftKey)(state)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return grip;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
decorations.push(buildAddRowDecoration(pos, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
decorations.push(buildAddRowDecoration(pos, index + 1));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import Token from "markdown-it/lib/token";
|
|
||||||
import { NodeSpec } from "prosemirror-model";
|
|
||||||
import { Plugin } from "prosemirror-state";
|
|
||||||
import { DecorationSet, Decoration } from "prosemirror-view";
|
|
||||||
import { selectColumn } from "../commands/table";
|
|
||||||
import { getCellsInRow, isColumnSelected } from "../queries/table";
|
|
||||||
|
|
||||||
import Node from "./Node";
|
|
||||||
|
|
||||||
export default class TableHeadCell extends Node {
|
|
||||||
get name() {
|
|
||||||
return "th";
|
|
||||||
}
|
|
||||||
|
|
||||||
get schema(): NodeSpec {
|
|
||||||
return {
|
|
||||||
content: "(paragraph | embed)+",
|
|
||||||
tableRole: "header_cell",
|
|
||||||
isolating: true,
|
|
||||||
parseDOM: [{ tag: "th" }],
|
|
||||||
toDOM(node) {
|
|
||||||
return [
|
|
||||||
"th",
|
|
||||||
node.attrs.alignment
|
|
||||||
? { style: `text-align: ${node.attrs.alignment}` }
|
|
||||||
: {},
|
|
||||||
0,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
attrs: {
|
|
||||||
colspan: { default: 1 },
|
|
||||||
rowspan: { default: 1 },
|
|
||||||
alignment: { default: null },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toMarkdown() {
|
|
||||||
// see: renderTable
|
|
||||||
}
|
|
||||||
|
|
||||||
parseMarkdown() {
|
|
||||||
return {
|
|
||||||
block: "th",
|
|
||||||
getAttrs: (tok: Token) => ({ alignment: tok.info }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
get plugins() {
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
props: {
|
|
||||||
decorations: (state) => {
|
|
||||||
const { doc } = state;
|
|
||||||
const decorations: Decoration[] = [];
|
|
||||||
const cells = getCellsInRow(0)(state);
|
|
||||||
|
|
||||||
if (cells) {
|
|
||||||
cells.forEach((pos, index) => {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget(pos + 1, () => {
|
|
||||||
const colSelected = isColumnSelected(index)(state);
|
|
||||||
let className = "grip-column";
|
|
||||||
if (colSelected) {
|
|
||||||
className += " selected";
|
|
||||||
}
|
|
||||||
if (index === 0) {
|
|
||||||
className += " first";
|
|
||||||
} else if (index === cells.length - 1) {
|
|
||||||
className += " last";
|
|
||||||
}
|
|
||||||
const grip = document.createElement("a");
|
|
||||||
grip.className = className;
|
|
||||||
grip.addEventListener("mousedown", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
this.editor.view.dispatch(
|
|
||||||
selectColumn(
|
|
||||||
index,
|
|
||||||
event.metaKey || event.shiftKey
|
|
||||||
)(state)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return grip;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return DecorationSet.create(doc, decorations);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
149
shared/editor/nodes/TableHeader.ts
Normal file
149
shared/editor/nodes/TableHeader.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import Token from "markdown-it/lib/token";
|
||||||
|
import { NodeSpec } from "prosemirror-model";
|
||||||
|
import { Plugin } from "prosemirror-state";
|
||||||
|
import { DecorationSet, Decoration, EditorView } from "prosemirror-view";
|
||||||
|
import { addColumnBefore, selectColumn } from "../commands/table";
|
||||||
|
import { getCellAttrs, setCellAttrs } from "../lib/table";
|
||||||
|
import { getCellsInRow, isColumnSelected } from "../queries/table";
|
||||||
|
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||||
|
import { cn } from "../styles/utils";
|
||||||
|
import Node from "./Node";
|
||||||
|
|
||||||
|
export default class TableHeader extends Node {
|
||||||
|
get name() {
|
||||||
|
return "th";
|
||||||
|
}
|
||||||
|
|
||||||
|
get schema(): NodeSpec {
|
||||||
|
return {
|
||||||
|
content: "block+",
|
||||||
|
tableRole: "header_cell",
|
||||||
|
isolating: true,
|
||||||
|
parseDOM: [{ tag: "th", getAttrs: getCellAttrs }],
|
||||||
|
toDOM(node) {
|
||||||
|
return ["th", setCellAttrs(node), 0];
|
||||||
|
},
|
||||||
|
attrs: {
|
||||||
|
colspan: { default: 1 },
|
||||||
|
rowspan: { default: 1 },
|
||||||
|
alignment: { default: null },
|
||||||
|
colwidth: { default: null },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toMarkdown() {
|
||||||
|
// see: renderTable
|
||||||
|
}
|
||||||
|
|
||||||
|
parseMarkdown() {
|
||||||
|
return {
|
||||||
|
block: "th",
|
||||||
|
getAttrs: (tok: Token) => ({ alignment: tok.info }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get plugins() {
|
||||||
|
function buildAddColumnDecoration(pos: number, index: number) {
|
||||||
|
const className = cn(EditorStyleHelper.tableAddColumn, {
|
||||||
|
first: index === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Decoration.widget(
|
||||||
|
pos + 1,
|
||||||
|
() => {
|
||||||
|
const plus = document.createElement("a");
|
||||||
|
plus.role = "button";
|
||||||
|
plus.className = className;
|
||||||
|
plus.dataset.index = index.toString();
|
||||||
|
return plus;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: cn(className, index),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
mousedown: (view: EditorView, event: MouseEvent) => {
|
||||||
|
if (!(event.target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAddColumn = event.target.closest(
|
||||||
|
`.${EditorStyleHelper.tableAddColumn}`
|
||||||
|
);
|
||||||
|
if (targetAddColumn) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
const index = Number(
|
||||||
|
targetAddColumn.getAttribute("data-index")
|
||||||
|
);
|
||||||
|
addColumnBefore({ index })(view.state, view.dispatch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetGripColumn = event.target.closest(
|
||||||
|
`.${EditorStyleHelper.tableGripColumn}`
|
||||||
|
);
|
||||||
|
if (targetGripColumn) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
selectColumn(
|
||||||
|
Number(targetGripColumn.getAttribute("data-index")),
|
||||||
|
event.metaKey || event.shiftKey
|
||||||
|
)(view.state, view.dispatch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorations: (state) => {
|
||||||
|
const { doc } = state;
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
const cols = getCellsInRow(0)(state);
|
||||||
|
|
||||||
|
if (cols) {
|
||||||
|
cols.forEach((pos, index) => {
|
||||||
|
const className = cn(EditorStyleHelper.tableGripColumn, {
|
||||||
|
selected: isColumnSelected(index)(state),
|
||||||
|
first: index === 0,
|
||||||
|
last: index === cols.length - 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(
|
||||||
|
pos + 1,
|
||||||
|
() => {
|
||||||
|
const grip = document.createElement("a");
|
||||||
|
grip.role = "button";
|
||||||
|
grip.className = className;
|
||||||
|
grip.dataset.index = index.toString();
|
||||||
|
return grip;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: cn(className, index),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
decorations.push(buildAddColumnDecoration(pos, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
decorations.push(buildAddColumnDecoration(pos, index + 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
91
shared/editor/nodes/TableView.ts
Normal file
91
shared/editor/nodes/TableView.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Node } from "prosemirror-model";
|
||||||
|
import { TableView as ProsemirrorTableView } from "prosemirror-tables";
|
||||||
|
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||||
|
import { TableLayout } from "../types";
|
||||||
|
|
||||||
|
export class TableView extends ProsemirrorTableView {
|
||||||
|
public constructor(public node: Node, public cellMinWidth: number) {
|
||||||
|
super(node, cellMinWidth);
|
||||||
|
|
||||||
|
this.dom.removeChild(this.table);
|
||||||
|
this.dom.classList.add(EditorStyleHelper.table);
|
||||||
|
|
||||||
|
// Add an extra wrapper to enable scrolling
|
||||||
|
this.scrollable = this.dom.appendChild(document.createElement("div"));
|
||||||
|
this.scrollable.appendChild(this.table);
|
||||||
|
this.scrollable.classList.add(EditorStyleHelper.tableScrollable);
|
||||||
|
|
||||||
|
this.scrollable.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
() => {
|
||||||
|
this.updateClassList(this.node);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
passive: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateClassList(node);
|
||||||
|
|
||||||
|
// We need to wait for the next tick to ensure dom is rendered and scroll shadows are correct.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.dom) {
|
||||||
|
this.updateClassList(node);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override update(node: Node) {
|
||||||
|
this.updateClassList(node);
|
||||||
|
return super.update(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ignoreMutation(record: MutationRecord): boolean {
|
||||||
|
if (
|
||||||
|
record.type === "attributes" &&
|
||||||
|
record.target === this.dom &&
|
||||||
|
(record.attributeName === "class" || record.attributeName === "style")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
record.type === "attributes" &&
|
||||||
|
(record.target === this.table || this.colgroup.contains(record.target))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateClassList(node: Node) {
|
||||||
|
this.dom.classList.toggle(
|
||||||
|
EditorStyleHelper.tableFullWidth,
|
||||||
|
node.attrs.layout === TableLayout.fullWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
const shadowLeft = !!(this.scrollable && this.scrollable.scrollLeft > 0);
|
||||||
|
const shadowRight = !!(
|
||||||
|
this.scrollable &&
|
||||||
|
this.scrollable.scrollWidth > this.scrollable.clientWidth &&
|
||||||
|
this.scrollable.scrollLeft + this.scrollable.clientWidth <
|
||||||
|
this.scrollable.scrollWidth - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dom.classList.toggle(EditorStyleHelper.tableShadowLeft, shadowLeft);
|
||||||
|
this.dom.classList.toggle(EditorStyleHelper.tableShadowRight, shadowRight);
|
||||||
|
|
||||||
|
if (this.scrollable) {
|
||||||
|
this.dom.style.setProperty(
|
||||||
|
"--table-height",
|
||||||
|
`${this.scrollable?.clientHeight}px`
|
||||||
|
);
|
||||||
|
this.dom.style.setProperty(
|
||||||
|
"--table-width",
|
||||||
|
`${this.scrollable?.clientWidth}px`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.dom.style.removeProperty("--table-height");
|
||||||
|
this.dom.style.removeProperty("--table-width");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollable: HTMLDivElement | null = null;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ import Paragraph from "./Paragraph";
|
|||||||
import SimpleImage from "./SimpleImage";
|
import SimpleImage from "./SimpleImage";
|
||||||
import Table from "./Table";
|
import Table from "./Table";
|
||||||
import TableCell from "./TableCell";
|
import TableCell from "./TableCell";
|
||||||
import TableHeadCell from "./TableHeadCell";
|
import TableHeader from "./TableHeader";
|
||||||
import TableRow from "./TableRow";
|
import TableRow from "./TableRow";
|
||||||
import Text from "./Text";
|
import Text from "./Text";
|
||||||
import Video from "./Video";
|
import Video from "./Video";
|
||||||
@@ -77,12 +77,7 @@ export const listExtensions: Nodes = [
|
|||||||
ListItem,
|
ListItem,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const tableExtensions: Nodes = [
|
export const tableExtensions: Nodes = [Table, TableCell, TableHeader, TableRow];
|
||||||
Table,
|
|
||||||
TableCell,
|
|
||||||
TableHeadCell,
|
|
||||||
TableRow,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full set of nodes that are used in the editor. This is used for rich
|
* The full set of nodes that are used in the editor. This is used for rich
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
import { CellSelection, isInTable, selectedRect } from "prosemirror-tables";
|
import {
|
||||||
|
CellSelection,
|
||||||
|
TableRect,
|
||||||
|
isInTable,
|
||||||
|
selectedRect,
|
||||||
|
} from "prosemirror-tables";
|
||||||
|
|
||||||
export function getColumnIndex(state: EditorState): number | undefined {
|
export function getColumnIndex(state: EditorState): number | undefined {
|
||||||
if (state.selection instanceof CellSelection) {
|
if (state.selection instanceof CellSelection) {
|
||||||
@@ -70,6 +75,37 @@ export function isColumnSelected(index: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the header is enabled for the given type and table rect
|
||||||
|
*
|
||||||
|
* @param state The editor state
|
||||||
|
* @param type The type of header to check
|
||||||
|
* @param rect The table rect
|
||||||
|
* @returns Boolean indicating if the header is enabled
|
||||||
|
*/
|
||||||
|
export function isHeaderEnabled(
|
||||||
|
state: EditorState,
|
||||||
|
type: "row" | "column",
|
||||||
|
rect: TableRect
|
||||||
|
): boolean {
|
||||||
|
// Get cell positions for first row or first column
|
||||||
|
const cellPositions = rect.map.cellsInRect({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: type === "row" ? rect.map.width : 1,
|
||||||
|
bottom: type === "column" ? rect.map.height : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < cellPositions.length; i++) {
|
||||||
|
const cell = rect.table.nodeAt(cellPositions[i]);
|
||||||
|
if (cell && cell.type !== state.schema.nodes.th) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function isRowSelected(index: number) {
|
export function isRowSelected(index: number) {
|
||||||
return (state: EditorState): boolean => {
|
return (state: EditorState): boolean => {
|
||||||
if (state.selection instanceof CellSelection) {
|
if (state.selection instanceof CellSelection) {
|
||||||
@@ -90,6 +126,8 @@ export function isTableSelected(state: EditorState): boolean {
|
|||||||
rect.top === 0 &&
|
rect.top === 0 &&
|
||||||
rect.left === 0 &&
|
rect.left === 0 &&
|
||||||
rect.bottom === rect.map.height &&
|
rect.bottom === rect.map.height &&
|
||||||
rect.right === rect.map.width
|
rect.right === rect.map.width &&
|
||||||
|
!state.selection.empty &&
|
||||||
|
state.selection instanceof CellSelection
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
shared/editor/styles/EditorStyleHelper.ts
Normal file
39
shared/editor/styles/EditorStyleHelper.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Class names and values used by the editor.
|
||||||
|
*/
|
||||||
|
export class EditorStyleHelper {
|
||||||
|
// Tables
|
||||||
|
|
||||||
|
/** Table wrapper */
|
||||||
|
static readonly table = "table-wrapper";
|
||||||
|
|
||||||
|
/** Table grip (circle in top left) */
|
||||||
|
static readonly tableGrip = "table-grip";
|
||||||
|
|
||||||
|
/** Table row grip */
|
||||||
|
static readonly tableGripRow = "table-grip-row";
|
||||||
|
|
||||||
|
/** Table column grip */
|
||||||
|
static readonly tableGripColumn = "table-grip-column";
|
||||||
|
|
||||||
|
/** "Plus" to add column on tables */
|
||||||
|
static readonly tableAddColumn = "table-add-column";
|
||||||
|
|
||||||
|
/** "Plus" to add row on tables */
|
||||||
|
static readonly tableAddRow = "table-add-row";
|
||||||
|
|
||||||
|
/** Scrollable area of table */
|
||||||
|
static readonly tableScrollable = "table-scrollable";
|
||||||
|
|
||||||
|
/** Full-width table layout */
|
||||||
|
static readonly tableFullWidth = "table-full-width";
|
||||||
|
|
||||||
|
/** Shadow on the right side of the table */
|
||||||
|
static readonly tableShadowRight = "table-shadow-right";
|
||||||
|
|
||||||
|
/** Shadow on the left side of the table */
|
||||||
|
static readonly tableShadowLeft = "table-shadow-left";
|
||||||
|
|
||||||
|
/** Minimum padding around editor */
|
||||||
|
static readonly padding = 32;
|
||||||
|
}
|
||||||
23
shared/editor/styles/utils.ts
Normal file
23
shared/editor/styles/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Combines class names into a single string. If the value is an object, it will only include keys
|
||||||
|
* with a truthy value.
|
||||||
|
*
|
||||||
|
* @param classNames An array of class names
|
||||||
|
* @returns A single string of class names
|
||||||
|
*/
|
||||||
|
export function cn(
|
||||||
|
...classNames: (string | number | Record<string, boolean> | undefined)[]
|
||||||
|
) {
|
||||||
|
return classNames
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "object") {
|
||||||
|
return Object.entries(item)
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([key]) => key)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ export enum EventType {
|
|||||||
LinkToolbarOpen = "linkMenuOpen",
|
LinkToolbarOpen = "linkMenuOpen",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TableLayout {
|
||||||
|
fullWidth = "full-width",
|
||||||
|
}
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
icon?: React.ReactElement;
|
icon?: React.ReactElement;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -352,11 +352,14 @@
|
|||||||
"Replace": "Replace",
|
"Replace": "Replace",
|
||||||
"Replace all": "Replace all",
|
"Replace all": "Replace all",
|
||||||
"Profile picture": "Profile picture",
|
"Profile picture": "Profile picture",
|
||||||
"Insert after": "Insert after",
|
"Add column after": "Add column after",
|
||||||
"Insert before": "Insert before",
|
"Add column before": "Add column before",
|
||||||
|
"Add row after": "Add row after",
|
||||||
|
"Add row before": "Add row before",
|
||||||
"Align center": "Align center",
|
"Align center": "Align center",
|
||||||
"Align left": "Align left",
|
"Align left": "Align left",
|
||||||
"Align right": "Align right",
|
"Align right": "Align right",
|
||||||
|
"Default width": "Default width",
|
||||||
"Full width": "Full width",
|
"Full width": "Full width",
|
||||||
"Bulleted list": "Bulleted list",
|
"Bulleted list": "Bulleted list",
|
||||||
"Todo list": "Task list",
|
"Todo list": "Task list",
|
||||||
@@ -410,6 +413,7 @@
|
|||||||
"Sort ascending": "Sort ascending",
|
"Sort ascending": "Sort ascending",
|
||||||
"Sort descending": "Sort descending",
|
"Sort descending": "Sort descending",
|
||||||
"Table": "Table",
|
"Table": "Table",
|
||||||
|
"Toggle header": "Toggle header",
|
||||||
"Math inline (LaTeX)": "Math inline (LaTeX)",
|
"Math inline (LaTeX)": "Math inline (LaTeX)",
|
||||||
"Math block (LaTeX)": "Math block (LaTeX)",
|
"Math block (LaTeX)": "Math block (LaTeX)",
|
||||||
"Tip": "Tip",
|
"Tip": "Tip",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default createGlobalStyle<Props>`
|
|||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
--pointer: ${(props) => (props.useCursorPointer ? "pointer" : "default")};
|
--pointer: ${(props) => (props.useCursorPointer ? "pointer" : "default")};
|
||||||
|
overscroll-behavior-x: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const buildBaseTheme = (input: Partial<Colors>) => {
|
|||||||
noticeWarningText: colors.almostBlack,
|
noticeWarningText: colors.almostBlack,
|
||||||
noticeSuccessBackground: colors.brand.green,
|
noticeSuccessBackground: colors.brand.green,
|
||||||
noticeSuccessText: colors.almostBlack,
|
noticeSuccessText: colors.almostBlack,
|
||||||
tableSelectedBackground: transparentize(0.8, colors.accent),
|
tableSelectedBackground: transparentize(0.9, colors.accent),
|
||||||
breakpoints,
|
breakpoints,
|
||||||
...colors,
|
...colors,
|
||||||
...spacing,
|
...spacing,
|
||||||
@@ -145,7 +145,6 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
|
|||||||
inputBorderFocused: colors.slate,
|
inputBorderFocused: colors.slate,
|
||||||
listItemHoverBackground: colors.warmGrey,
|
listItemHoverBackground: colors.warmGrey,
|
||||||
mentionBackground: colors.warmGrey,
|
mentionBackground: colors.warmGrey,
|
||||||
tableDivider: colors.smokeDark,
|
|
||||||
tableSelected: colors.accent,
|
tableSelected: colors.accent,
|
||||||
buttonNeutralBackground: colors.white,
|
buttonNeutralBackground: colors.white,
|
||||||
buttonNeutralText: colors.almostBlack,
|
buttonNeutralText: colors.almostBlack,
|
||||||
@@ -208,7 +207,6 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
|
|||||||
inputBorderFocused: colors.slate,
|
inputBorderFocused: colors.slate,
|
||||||
listItemHoverBackground: colors.white10,
|
listItemHoverBackground: colors.white10,
|
||||||
mentionBackground: colors.white10,
|
mentionBackground: colors.white10,
|
||||||
tableDivider: colors.lightBlack,
|
|
||||||
tableSelected: colors.accent,
|
tableSelected: colors.accent,
|
||||||
buttonNeutralBackground: colors.almostBlack,
|
buttonNeutralBackground: colors.almostBlack,
|
||||||
buttonNeutralText: colors.white,
|
buttonNeutralText: colors.white,
|
||||||
|
|||||||
Reference in New Issue
Block a user