mirror of
https://github.com/docmost/docmost.git
synced 2026-05-13 11:44:07 +08:00
Compare commits
3 Commits
feat/labels
..
table
| Author | SHA1 | Date | |
|---|---|---|---|
| 1260d60d38 | |||
| bf1ddd8320 | |||
| a689cca7a0 |
@@ -10,6 +10,8 @@
|
|||||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
|
||||||
"@casl/react": "^5.0.1",
|
"@casl/react": "^5.0.1",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
|||||||
@@ -276,6 +276,7 @@
|
|||||||
"Align left": "Align left",
|
"Align left": "Align left",
|
||||||
"Align right": "Align right",
|
"Align right": "Align right",
|
||||||
"Align center": "Align center",
|
"Align center": "Align center",
|
||||||
|
"Text alignment": "Text alignment",
|
||||||
"Justify": "Justify",
|
"Justify": "Justify",
|
||||||
"Merge cells": "Merge cells",
|
"Merge cells": "Merge cells",
|
||||||
"Split cell": "Split cell",
|
"Split cell": "Split cell",
|
||||||
@@ -286,6 +287,20 @@
|
|||||||
"Add row above": "Add row above",
|
"Add row above": "Add row above",
|
||||||
"Add row below": "Add row below",
|
"Add row below": "Add row below",
|
||||||
"Delete table": "Delete table",
|
"Delete table": "Delete table",
|
||||||
|
"Add column left": "Add column left",
|
||||||
|
"Add column right": "Add column right",
|
||||||
|
"Clear cell": "Clear cell",
|
||||||
|
"Clear cells": "Clear cells",
|
||||||
|
"Distribute columns": "Distribute columns",
|
||||||
|
"Toggle header cell": "Toggle header cell",
|
||||||
|
"Toggle header column": "Toggle header column",
|
||||||
|
"Toggle header row": "Toggle header row",
|
||||||
|
"Move column left": "Move column left",
|
||||||
|
"Move column right": "Move column right",
|
||||||
|
"Move row down": "Move row down",
|
||||||
|
"Move row up": "Move row up",
|
||||||
|
"Sort A → Z": "Sort A → Z",
|
||||||
|
"Sort Z → A": "Sort Z → A",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
"Note": "Note",
|
"Note": "Note",
|
||||||
"Success": "Success",
|
"Success": "Success",
|
||||||
@@ -997,5 +1012,8 @@
|
|||||||
"No pages with this label": "No pages with this label",
|
"No pages with this label": "No pages with this label",
|
||||||
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
|
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
|
||||||
"No pages match your search.": "No pages match your search.",
|
"No pages match your search.": "No pages match your search.",
|
||||||
"Updated {{date}}": "Updated {{date}}"
|
"Updated {{date}}": "Updated {{date}}",
|
||||||
|
"Cell actions": "Cell actions",
|
||||||
|
"Column actions": "Column actions",
|
||||||
|
"Row actions": "Row actions"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
|
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
|
||||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||||
z-index: 50;
|
z-index: 99;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={classes.fixedToolbar}
|
className={classes.fixedToolbar}
|
||||||
|
data-fixed-toolbar="true"
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label="Editor toolbar"
|
aria-label="Editor toolbar"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useCallback, useEffect } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { useEditorState } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
|
import { columnResizingPluginKey } from "@tiptap/pm/tables";
|
||||||
|
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
|
||||||
|
import { Menu, UnstyledButton } from "@mantine/core";
|
||||||
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
|
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
||||||
|
import classes from "./handle.module.css";
|
||||||
|
|
||||||
|
interface CellChevronProps {
|
||||||
|
editor: Editor;
|
||||||
|
cellPos: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CellChevron = React.memo(function CellChevron({
|
||||||
|
editor,
|
||||||
|
cellPos,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
}: CellChevronProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
|
||||||
|
|
||||||
|
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||||
|
placement: "top-end",
|
||||||
|
// crossAxis pulls the chevron INWARD from the cell's right edge. We need
|
||||||
|
// enough inset that we don't overlap PM-tables' column-resize hot zone
|
||||||
|
// (~5px wide around the column boundary). Without this, hovering near the
|
||||||
|
// column edge picks up the chevron's `cursor: pointer` instead of
|
||||||
|
// `col-resize`, and a drag near the edge clicks the chevron.
|
||||||
|
middleware: [offset({ mainAxis: -22, crossAxis: -10 }), hide()],
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
strategy: "absolute",
|
||||||
|
});
|
||||||
|
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refs.setReference(cellDom);
|
||||||
|
}, [cellDom, refs]);
|
||||||
|
|
||||||
|
// Hide the chevron while the user is resizing a column. PM-tables sets
|
||||||
|
// `activeHandle > -1` whenever the mouse is near a column boundary OR
|
||||||
|
// actively dragging it. Either way we don't want the chevron in the way.
|
||||||
|
const isResizingColumn = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => {
|
||||||
|
if (!ctx.editor) return false;
|
||||||
|
const state = columnResizingPluginKey.getState(ctx.editor.state) as
|
||||||
|
| { activeHandle: number }
|
||||||
|
| undefined;
|
||||||
|
return !!state && state.activeHandle > -1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpen = useCallback(() => {
|
||||||
|
const current = editor.state.selection;
|
||||||
|
|
||||||
|
// Preserve an existing multi-cell CellSelection that already covers
|
||||||
|
// this cell so merge etc. operate on the user's whole range.
|
||||||
|
let preserveExisting = false;
|
||||||
|
if (isCellSelection(current)) {
|
||||||
|
current.forEachCell((_node, pos) => {
|
||||||
|
if (pos === cellPos) preserveExisting = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preserveExisting) {
|
||||||
|
// Drop a collapsed cursor inside the cell rather than a single-cell
|
||||||
|
// CellSelection — PM-tables paints the latter as a text-range
|
||||||
|
// highlight on the cell content.
|
||||||
|
try {
|
||||||
|
const $inside = editor.state.doc.resolve(cellPos + 1);
|
||||||
|
const sel = TextSelection.near($inside, 1);
|
||||||
|
editor.view.dispatch(editor.state.tr.setSelection(sel));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
editor.commands.freezeHandles();
|
||||||
|
}, [editor, cellPos]);
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
editor.commands.unfreezeHandles();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
if (!cellDom) return null;
|
||||||
|
if (isResizingColumn) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
position="bottom-end"
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<UnstyledButton
|
||||||
|
ref={refs.setFloating}
|
||||||
|
style={{
|
||||||
|
...floatingStyles,
|
||||||
|
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
|
||||||
|
}}
|
||||||
|
className={clsx(classes.cellChevron)}
|
||||||
|
aria-label={t("Cell actions")}
|
||||||
|
>
|
||||||
|
<IconChevronDown size={14} />
|
||||||
|
</UnstyledButton>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<CellChevronMenu
|
||||||
|
editor={editor}
|
||||||
|
cellPos={cellPos}
|
||||||
|
tableNode={tableNode}
|
||||||
|
tablePos={tablePos}
|
||||||
|
/>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
|
||||||
|
import { Menu } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
|
||||||
|
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
|
||||||
|
import { ColumnHandleMenu } from "./menus/column-handle-menu";
|
||||||
|
import classes from "./handle.module.css";
|
||||||
|
|
||||||
|
interface ColumnHandleProps {
|
||||||
|
editor: Editor;
|
||||||
|
index: number;
|
||||||
|
anchorPos: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHandle = React.memo(function ColumnHandle({
|
||||||
|
editor,
|
||||||
|
index,
|
||||||
|
anchorPos,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
}: ColumnHandleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Hold the cell DOM in a ref-backed state so we never unmount the handle
|
||||||
|
// mid-drag. A remote edit can transiently flip `nodeDOM(anchorPos)` to null
|
||||||
|
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
|
||||||
|
// unmounting the source element here would make pragmatic-dnd silently
|
||||||
|
// abort the active drag.
|
||||||
|
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
||||||
|
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||||
|
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||||
|
useEffect(() => {
|
||||||
|
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
|
||||||
|
lastCellDomRef.current = lookupCellDom;
|
||||||
|
setCellDom(lookupCellDom);
|
||||||
|
}
|
||||||
|
}, [lookupCellDom]);
|
||||||
|
|
||||||
|
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||||
|
placement: "top",
|
||||||
|
middleware: [offset(-4), hide()],
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
});
|
||||||
|
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refs.setReference(cellDom);
|
||||||
|
}, [cellDom, refs]);
|
||||||
|
|
||||||
|
// `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
|
||||||
|
// wrapper for this drag's auto-scroll. The handle itself lives in a
|
||||||
|
// floating layer outside the editor DOM, so we can't walk up from it.
|
||||||
|
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
|
||||||
|
useTableHandleDrag(editor, "col", handleEl, wrapper);
|
||||||
|
|
||||||
|
const { onOpen, onClose } = useColumnRowMenuLifecycle({
|
||||||
|
editor,
|
||||||
|
orientation: "col",
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cellDom) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
position="bottom-start"
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
refs.setFloating(node);
|
||||||
|
setHandleEl(node);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...floatingStyles,
|
||||||
|
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
|
||||||
|
}}
|
||||||
|
className={clsx(classes.handle, classes.columnHandle)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t("Column actions")}
|
||||||
|
>
|
||||||
|
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
|
||||||
|
<GripIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<ColumnHandleMenu
|
||||||
|
editor={editor}
|
||||||
|
index={index}
|
||||||
|
tableNode={tableNode}
|
||||||
|
tablePos={tablePos}
|
||||||
|
/>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function GripIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(55, 53, 47, 0.45);
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
border: 1px solid rgba(55, 53, 47, 0.12);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
cursor: grab;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 120ms ease, color 120ms ease;
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
background: var(--mantine-color-dark-7);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:hover {
|
||||||
|
background: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
color: light-dark(
|
||||||
|
var(--mantine-color-gray-7),
|
||||||
|
var(--mantine-color-dark-0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnHandle {
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnHandle svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowHandle {
|
||||||
|
width: 16px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellChevron {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: light-dark(
|
||||||
|
var(--mantine-color-gray-7),
|
||||||
|
var(--mantine-color-dark-1)
|
||||||
|
);
|
||||||
|
background: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
border: 1px solid rgba(55, 53, 47, 0.12);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 120ms ease, color 120ms ease;
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellChevron:hover {
|
||||||
|
background: light-dark(
|
||||||
|
var(--mantine-color-gray-2),
|
||||||
|
var(--mantine-color-dark-4)
|
||||||
|
);
|
||||||
|
color: light-dark(
|
||||||
|
var(--mantine-color-gray-8),
|
||||||
|
var(--mantine-color-dark-0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.cellChevron {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.handle,
|
||||||
|
.cellChevron {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
editor: Editor;
|
||||||
|
orientation: Orientation;
|
||||||
|
index: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColumnRowMenuLifecycle({
|
||||||
|
editor,
|
||||||
|
orientation,
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
}: Args) {
|
||||||
|
const onOpen = useCallback(() => {
|
||||||
|
const selection = buildRowOrColumnSelection(
|
||||||
|
editor.state,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
orientation,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
const tr = editor.state.tr;
|
||||||
|
if (selection) tr.setSelection(selection);
|
||||||
|
editor.view.dispatch(tr);
|
||||||
|
editor.commands.freezeHandles();
|
||||||
|
}, [editor, orientation, index, tableNode, tablePos]);
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
editor.commands.unfreezeHandles();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return { onOpen, onClose };
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { TableMap } from "@tiptap/pm/tables";
|
||||||
|
|
||||||
|
type Scope =
|
||||||
|
| { kind: "col"; index: number }
|
||||||
|
| { kind: "row"; index: number }
|
||||||
|
| { kind: "cell"; cellPos: number };
|
||||||
|
|
||||||
|
export function useTableClear(
|
||||||
|
editor: Editor,
|
||||||
|
tableNode: ProseMirrorNode,
|
||||||
|
tablePos: number,
|
||||||
|
scope: Scope,
|
||||||
|
) {
|
||||||
|
return useCallback(() => {
|
||||||
|
const tr = editor.state.tr;
|
||||||
|
const tableStart = tablePos + 1;
|
||||||
|
const map = TableMap.get(tableNode);
|
||||||
|
const paragraph = editor.schema.nodes.paragraph;
|
||||||
|
if (!paragraph) return;
|
||||||
|
|
||||||
|
const cellOffsets: number[] = [];
|
||||||
|
|
||||||
|
if (scope.kind === "col") {
|
||||||
|
for (let row = 0; row < map.height; row++) {
|
||||||
|
cellOffsets.push(map.map[row * map.width + scope.index]);
|
||||||
|
}
|
||||||
|
} else if (scope.kind === "row") {
|
||||||
|
for (let col = 0; col < map.width; col++) {
|
||||||
|
cellOffsets.push(map.map[scope.index * map.width + col]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets =
|
||||||
|
scope.kind === "cell"
|
||||||
|
? [scope.cellPos]
|
||||||
|
: Array.from(new Set(cellOffsets)).map((o) => tableStart + o);
|
||||||
|
|
||||||
|
// Process in reverse position order so earlier replacements don't shift later ones.
|
||||||
|
targets.sort((a, b) => b - a);
|
||||||
|
|
||||||
|
for (const cellPos of targets) {
|
||||||
|
const node = tr.doc.nodeAt(cellPos);
|
||||||
|
if (!node) continue;
|
||||||
|
const start = cellPos + 1;
|
||||||
|
const end = cellPos + node.nodeSize - 1;
|
||||||
|
tr.replaceWith(start, end, paragraph.create());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tr.docChanged) editor.view.dispatch(tr);
|
||||||
|
}, [editor, tableNode, tablePos, scope]);
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
|
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
|
||||||
|
import {
|
||||||
|
autoScrollForElements,
|
||||||
|
autoScrollWindowForElements,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||||
|
import { getTableHandlePluginSpec } from "@docmost/editor-ext";
|
||||||
|
|
||||||
|
// Uses pragmatic-drag-and-drop instead of native HTML5 DnD because the native
|
||||||
|
// dragstart→dragover→drop lifecycle was being silently cancelled in this app.
|
||||||
|
export function useTableHandleDrag(
|
||||||
|
editor: Editor,
|
||||||
|
orientation: "col" | "row",
|
||||||
|
element: HTMLElement | null,
|
||||||
|
wrapper: HTMLElement | null,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
return combine(
|
||||||
|
draggable({
|
||||||
|
element,
|
||||||
|
getInitialData: () => ({ type: `table-${orientation}` }),
|
||||||
|
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||||
|
// We render our own floating preview via PreviewController, so hide
|
||||||
|
// the native drag image entirely.
|
||||||
|
disableNativeDragPreview({ nativeSetDragImage });
|
||||||
|
},
|
||||||
|
onDragStart: ({ location }) => {
|
||||||
|
const spec = getTableHandlePluginSpec(editor);
|
||||||
|
if (!spec) return;
|
||||||
|
const { clientX, clientY } = location.initial.input;
|
||||||
|
spec.startDragFromHandle(orientation, clientX, clientY);
|
||||||
|
},
|
||||||
|
onDrag: ({ location }) => {
|
||||||
|
const spec = getTableHandlePluginSpec(editor);
|
||||||
|
if (!spec) return;
|
||||||
|
const { clientX, clientY } = location.current.input;
|
||||||
|
spec.updateDragPosition(clientX, clientY);
|
||||||
|
},
|
||||||
|
onDrop: ({ location }) => {
|
||||||
|
const spec = getTableHandlePluginSpec(editor);
|
||||||
|
if (!spec) return;
|
||||||
|
const { clientX, clientY } = location.current.input;
|
||||||
|
// Make sure the final position is recorded before committing the drop.
|
||||||
|
spec.updateDragPosition(clientX, clientY);
|
||||||
|
spec.commitDrop();
|
||||||
|
spec.endDrag();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Wrapper owns horizontal auto-scroll (it has `overflow-x: auto`);
|
||||||
|
// window owns vertical. Locking each axis prevents the window's
|
||||||
|
// horizontal auto-scroll from running when the cursor approaches
|
||||||
|
// the viewport edge — without the cap, the preview's `left` follows
|
||||||
|
// the cursor past the viewport, the page widens to contain it, the
|
||||||
|
// plugin scrolls the now-wider page further, and the loop never
|
||||||
|
// ends.
|
||||||
|
// Only the column handle registers wrapper auto-scroll (rows can't
|
||||||
|
// scroll horizontally) — registering twice on the same wrapper
|
||||||
|
// triggers a dev-mode warning from pragmatic-dnd-auto-scroll.
|
||||||
|
orientation === "col" &&
|
||||||
|
wrapper &&
|
||||||
|
!wrapper.classList.contains("tableWrapperNoOverflow")
|
||||||
|
? autoScrollForElements({
|
||||||
|
element: wrapper,
|
||||||
|
getAllowedAxis: () => "horizontal",
|
||||||
|
})
|
||||||
|
: () => {},
|
||||||
|
autoScrollWindowForElements({ getAllowedAxis: () => "vertical" }),
|
||||||
|
);
|
||||||
|
}, [editor, orientation, element, wrapper]);
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { useEditorState } from "@tiptap/react";
|
||||||
|
import { TableDndKey, TableHandleState } from "@docmost/editor-ext";
|
||||||
|
|
||||||
|
const FALLBACK: TableHandleState = {
|
||||||
|
hoveringCell: null,
|
||||||
|
tableNode: null,
|
||||||
|
tablePos: null,
|
||||||
|
dragging: null,
|
||||||
|
frozen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTableHandleState(editor: Editor | null): TableHandleState {
|
||||||
|
const state = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => {
|
||||||
|
if (!ctx.editor) return null;
|
||||||
|
return TableDndKey.getState(ctx.editor.state) ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return state ?? FALLBACK;
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { TableMap } from "@tiptap/pm/tables";
|
||||||
|
import { moveColumn, moveRow } from "@docmost/editor-ext";
|
||||||
|
|
||||||
|
export type MoveDirection = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
export function useTableMoveRowColumn(
|
||||||
|
editor: Editor,
|
||||||
|
orientation: "col" | "row",
|
||||||
|
index: number,
|
||||||
|
direction: MoveDirection,
|
||||||
|
tableNode: ProseMirrorNode,
|
||||||
|
tablePos: number,
|
||||||
|
) {
|
||||||
|
const target =
|
||||||
|
direction === "left" || direction === "up" ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
const maxIndex = useMemo(() => {
|
||||||
|
const map = TableMap.get(tableNode);
|
||||||
|
return orientation === "col" ? map.width - 1 : map.height - 1;
|
||||||
|
}, [tableNode, orientation]);
|
||||||
|
|
||||||
|
const canMove = target >= 0 && target <= maxIndex;
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!canMove) return;
|
||||||
|
const tr = editor.state.tr;
|
||||||
|
const moved =
|
||||||
|
orientation === "col"
|
||||||
|
? moveColumn({
|
||||||
|
tr,
|
||||||
|
originIndex: index,
|
||||||
|
targetIndex: target,
|
||||||
|
select: true,
|
||||||
|
pos: tablePos + 1,
|
||||||
|
})
|
||||||
|
: moveRow({
|
||||||
|
tr,
|
||||||
|
originIndex: index,
|
||||||
|
targetIndex: target,
|
||||||
|
select: true,
|
||||||
|
pos: tablePos + 1,
|
||||||
|
});
|
||||||
|
if (moved) editor.view.dispatch(tr);
|
||||||
|
}, [editor, orientation, index, target, tablePos, canMove]);
|
||||||
|
|
||||||
|
return { canMove, handleMove };
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import {
|
||||||
|
convertArrayOfRowsToTableNode,
|
||||||
|
convertTableNodeToArrayOfRows,
|
||||||
|
transpose,
|
||||||
|
} from "@docmost/editor-ext";
|
||||||
|
import {
|
||||||
|
getCellSortText,
|
||||||
|
isCellEmpty,
|
||||||
|
isHeaderCell,
|
||||||
|
type SortDirection,
|
||||||
|
type SortableItem,
|
||||||
|
sortItems,
|
||||||
|
weaveItems,
|
||||||
|
} from "../lib/sort-cells";
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
editor: Editor;
|
||||||
|
orientation: "col" | "row";
|
||||||
|
index: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableHasMergedCells(tableNode: ProseMirrorNode): boolean {
|
||||||
|
for (let r = 0; r < tableNode.childCount; r++) {
|
||||||
|
const row = tableNode.child(r);
|
||||||
|
for (let c = 0; c < row.childCount; c++) {
|
||||||
|
const { colspan = 1, rowspan = 1 } = row.child(c).attrs;
|
||||||
|
if (colspan > 1 || rowspan > 1) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllHeader(cells: (ProseMirrorNode | null)[]): boolean {
|
||||||
|
return cells.every((c) => c !== null && isHeaderCell(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTableSort({
|
||||||
|
editor,
|
||||||
|
orientation,
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
direction,
|
||||||
|
}: Args) {
|
||||||
|
const canSort = useMemo(() => {
|
||||||
|
if (tableHasMergedCells(tableNode)) return false;
|
||||||
|
|
||||||
|
const rows = convertTableNodeToArrayOfRows(tableNode);
|
||||||
|
const axes = orientation === "col" ? rows : transpose(rows);
|
||||||
|
if (axes.length < 2) return false;
|
||||||
|
|
||||||
|
return axes.some((cells) => {
|
||||||
|
if (isAllHeader(cells)) return false;
|
||||||
|
const sortCell = cells[index];
|
||||||
|
return !!sortCell && !isCellEmpty(sortCell);
|
||||||
|
});
|
||||||
|
}, [tableNode, orientation, index]);
|
||||||
|
|
||||||
|
const handleSort = useCallback(() => {
|
||||||
|
if (!canSort) return;
|
||||||
|
|
||||||
|
const rows = convertTableNodeToArrayOfRows(tableNode);
|
||||||
|
const axes = orientation === "col" ? rows : transpose(rows);
|
||||||
|
|
||||||
|
const items: SortableItem<(ProseMirrorNode | null)[]>[] = axes.map(
|
||||||
|
(cells, originalOrder) => {
|
||||||
|
const sortCell = cells[index];
|
||||||
|
return {
|
||||||
|
payload: cells,
|
||||||
|
text: sortCell ? getCellSortText(sortCell) : "",
|
||||||
|
isHeader: isAllHeader(cells),
|
||||||
|
isEmpty: !sortCell || isCellEmpty(sortCell),
|
||||||
|
originalOrder,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataItems = items.filter((it) => !it.isHeader);
|
||||||
|
const sortedData = sortItems(dataItems, direction);
|
||||||
|
const woven = weaveItems(items, sortedData);
|
||||||
|
|
||||||
|
const newAxes = woven.map((it) => it.payload);
|
||||||
|
const newRows = orientation === "col" ? newAxes : transpose(newAxes);
|
||||||
|
|
||||||
|
const newTable = convertArrayOfRowsToTableNode(tableNode, newRows);
|
||||||
|
|
||||||
|
const tr = editor.state.tr;
|
||||||
|
tr.replaceWith(tablePos, tablePos + tableNode.nodeSize, newTable);
|
||||||
|
|
||||||
|
if (tr.docChanged) editor.view.dispatch(tr);
|
||||||
|
}, [editor, tableNode, tablePos, orientation, index, direction, canSort]);
|
||||||
|
|
||||||
|
return { canSort, handleSort };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import type { EditorState } from "@tiptap/pm/state";
|
||||||
|
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||||
|
|
||||||
|
export type Orientation = "col" | "row";
|
||||||
|
|
||||||
|
export function buildRowOrColumnSelection(
|
||||||
|
state: EditorState,
|
||||||
|
tableNode: ProseMirrorNode,
|
||||||
|
tablePos: number,
|
||||||
|
orientation: Orientation,
|
||||||
|
index: number,
|
||||||
|
): CellSelection | null {
|
||||||
|
const map = TableMap.get(tableNode);
|
||||||
|
const tableStart = tablePos + 1;
|
||||||
|
|
||||||
|
if (orientation === "col") {
|
||||||
|
if (index < 0 || index >= map.width) return null;
|
||||||
|
const firstCellPos = tableStart + map.map[index];
|
||||||
|
const lastCellPos =
|
||||||
|
tableStart + map.map[(map.height - 1) * map.width + index];
|
||||||
|
const $first = state.doc.resolve(firstCellPos);
|
||||||
|
const $last = state.doc.resolve(lastCellPos);
|
||||||
|
return CellSelection.colSelection($first, $last);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0 || index >= map.height) return null;
|
||||||
|
const firstCellPos = tableStart + map.map[index * map.width];
|
||||||
|
const lastCellPos =
|
||||||
|
tableStart + map.map[index * map.width + (map.width - 1)];
|
||||||
|
const $first = state.doc.resolve(firstCellPos);
|
||||||
|
const $last = state.doc.resolve(lastCellPos);
|
||||||
|
return CellSelection.rowSelection($first, $last);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
|
||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface SortableItem<T> {
|
||||||
|
payload: T;
|
||||||
|
text: string;
|
||||||
|
isHeader: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
originalOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADER_TYPE_NAMES = new Set(["tableHeader", "table_header"]);
|
||||||
|
|
||||||
|
export function isHeaderCell(node: ProseMirrorNode): boolean {
|
||||||
|
if (HEADER_TYPE_NAMES.has(node.type.name)) return true;
|
||||||
|
return node.attrs?.header === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCellSortText(node: ProseMirrorNode): string {
|
||||||
|
let text = "";
|
||||||
|
node.descendants((child) => {
|
||||||
|
if (child.isText) text += child.text ?? "";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return text.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellEmpty(node: ProseMirrorNode): boolean {
|
||||||
|
return getCellSortText(node) === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collator = new Intl.Collator(undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
numeric: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function sortItems<T>(
|
||||||
|
data: SortableItem<T>[],
|
||||||
|
direction: SortDirection,
|
||||||
|
): SortableItem<T>[] {
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
if (a.isEmpty && !b.isEmpty) return 1;
|
||||||
|
if (!a.isEmpty && b.isEmpty) return -1;
|
||||||
|
if (a.isEmpty && b.isEmpty) return a.originalOrder - b.originalOrder;
|
||||||
|
const cmp = collator.compare(a.text, b.text);
|
||||||
|
return direction === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weaveItems<T>(
|
||||||
|
all: SortableItem<T>[],
|
||||||
|
sortedData: SortableItem<T>[],
|
||||||
|
): SortableItem<T>[] {
|
||||||
|
const dataQueue = [...sortedData];
|
||||||
|
return all.map((item) => (item.isHeader ? item : dataQueue.shift()!));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { Menu } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconAlignCenter,
|
||||||
|
IconAlignLeft,
|
||||||
|
IconAlignRight,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface AlignmentSubmenuProps {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlignmentSubmenu = React.memo(function AlignmentSubmenu({
|
||||||
|
editor,
|
||||||
|
}: AlignmentSubmenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Sub position="right-start">
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<IconAlignLeft size={16} />}>
|
||||||
|
{t("Text alignment")}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconAlignLeft size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||||
|
>
|
||||||
|
{t("Align left")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconAlignCenter size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||||
|
>
|
||||||
|
{t("Align center")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconAlignRight size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||||
|
>
|
||||||
|
{t("Align right")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { ColorSwatch, Menu } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBoxMargin,
|
||||||
|
IconColumnInsertRight,
|
||||||
|
IconColumnRemove,
|
||||||
|
IconEraser,
|
||||||
|
IconPalette,
|
||||||
|
IconRowInsertBottom,
|
||||||
|
IconRowRemove,
|
||||||
|
IconSquareToggle,
|
||||||
|
IconTableRow,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTableClear } from "../hooks/use-table-clear";
|
||||||
|
import { TABLE_COLORS } from "../../table-background-color";
|
||||||
|
import { AlignmentSubmenu } from "./alignment-submenu";
|
||||||
|
|
||||||
|
interface CellChevronMenuProps {
|
||||||
|
editor: Editor;
|
||||||
|
cellPos: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CellChevronMenu = React.memo(function CellChevronMenu({
|
||||||
|
editor,
|
||||||
|
cellPos,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
}: CellChevronMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const clearCell = useTableClear(editor, tableNode, tablePos, {
|
||||||
|
kind: "cell",
|
||||||
|
cellPos,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setBackground = (color: string, name: string) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.updateAttributes("tableCell", {
|
||||||
|
backgroundColor: color || null,
|
||||||
|
backgroundColorName: color ? name : null,
|
||||||
|
})
|
||||||
|
.updateAttributes("tableHeader", {
|
||||||
|
backgroundColor: color || null,
|
||||||
|
backgroundColorName: color ? name : null,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.Sub position="right-start">
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
|
||||||
|
{t("Background color")}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
gap: 8,
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TABLE_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBackground(c.color, c.name)}
|
||||||
|
aria-label={t(c.name)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColorSwatch
|
||||||
|
color={c.color || "#ffffff"}
|
||||||
|
size={22}
|
||||||
|
style={{
|
||||||
|
border: c.color === "" ? "1px solid #e5e7eb" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
|
||||||
|
<AlignmentSubmenu editor={editor} />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconBoxMargin size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().mergeCells().run()}
|
||||||
|
disabled={!editor.can().mergeCells()}
|
||||||
|
>
|
||||||
|
{t("Merge cells")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconSquareToggle size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().splitCell().run()}
|
||||||
|
disabled={!editor.can().splitCell()}
|
||||||
|
>
|
||||||
|
{t("Split cell")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconTableRow size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeaderCell().run()}
|
||||||
|
>
|
||||||
|
{t("Toggle header cell")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconColumnInsertRight size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
||||||
|
>
|
||||||
|
{t("Add column right")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconRowInsertBottom size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().addRowAfter().run()}
|
||||||
|
>
|
||||||
|
{t("Add row below")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearCell}>
|
||||||
|
{t("Clear cell")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconColumnRemove size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||||
|
>
|
||||||
|
{t("Delete column")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconRowRemove size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||||
|
>
|
||||||
|
{t("Delete row")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { ColorSwatch, Menu } from "@mantine/core";
|
||||||
|
import { TABLE_COLORS } from "../../table-background-color";
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconColumnInsertLeft,
|
||||||
|
IconColumnInsertRight,
|
||||||
|
IconColumnRemove,
|
||||||
|
IconEraser,
|
||||||
|
IconPalette,
|
||||||
|
IconSortAscendingLetters,
|
||||||
|
IconSortDescendingLetters,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
|
||||||
|
import { useTableClear } from "../hooks/use-table-clear";
|
||||||
|
import { useTableSort } from "../hooks/use-table-sort";
|
||||||
|
import { AlignmentSubmenu } from "./alignment-submenu";
|
||||||
|
|
||||||
|
interface ColumnHandleMenuProps {
|
||||||
|
editor: Editor;
|
||||||
|
index: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHandleMenu = React.memo(function ColumnHandleMenu({
|
||||||
|
editor,
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
}: ColumnHandleMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const moveLeft = useTableMoveRowColumn(editor, "col", index, "left", tableNode, tablePos);
|
||||||
|
const moveRight = useTableMoveRowColumn(editor, "col", index, "right", tableNode, tablePos);
|
||||||
|
const clearCol = useTableClear(editor, tableNode, tablePos, {
|
||||||
|
kind: "col",
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setBackground = (color: string, name: string) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.updateAttributes("tableCell", {
|
||||||
|
backgroundColor: color || null,
|
||||||
|
backgroundColorName: color ? name : null,
|
||||||
|
})
|
||||||
|
.updateAttributes("tableHeader", {
|
||||||
|
backgroundColor: color || null,
|
||||||
|
backgroundColorName: color ? name : null,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortAsc = useTableSort({
|
||||||
|
editor,
|
||||||
|
orientation: "col",
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
direction: "asc",
|
||||||
|
});
|
||||||
|
const sortDesc = useTableSort({
|
||||||
|
editor,
|
||||||
|
orientation: "col",
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
direction: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconSortAscendingLetters size={16} />}
|
||||||
|
onClick={sortAsc.handleSort}
|
||||||
|
disabled={!sortAsc.canSort}
|
||||||
|
>
|
||||||
|
{t("Sort A → Z")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconSortDescendingLetters size={16} />}
|
||||||
|
onClick={sortDesc.handleSort}
|
||||||
|
disabled={!sortDesc.canSort}
|
||||||
|
>
|
||||||
|
{t("Sort Z → A")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Sub position="right-start">
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
|
||||||
|
{t("Background color")}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
|
||||||
|
{TABLE_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBackground(c.color, c.name)}
|
||||||
|
aria-label={t(c.name)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColorSwatch
|
||||||
|
color={c.color || "#ffffff"}
|
||||||
|
size={22}
|
||||||
|
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
|
||||||
|
<AlignmentSubmenu editor={editor} />
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconColumnInsertLeft size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().addColumnBefore().run()}
|
||||||
|
>
|
||||||
|
{t("Add column left")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconColumnInsertRight size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
||||||
|
>
|
||||||
|
{t("Add column right")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconEraser size={16} />}
|
||||||
|
onClick={clearCol}
|
||||||
|
>
|
||||||
|
{t("Clear cells")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconColumnRemove size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||||
|
>
|
||||||
|
{t("Delete column")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconArrowLeft size={16} />}
|
||||||
|
onClick={moveLeft.handleMove}
|
||||||
|
disabled={!moveLeft.canMove}
|
||||||
|
>
|
||||||
|
{t("Move column left")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconArrowRight size={16} />}
|
||||||
|
onClick={moveRight.handleMove}
|
||||||
|
disabled={!moveRight.canMove}
|
||||||
|
>
|
||||||
|
{t("Move column right")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { ColorSwatch, Menu } from "@mantine/core";
|
||||||
|
import { TABLE_COLORS } from "../../table-background-color";
|
||||||
|
import {
|
||||||
|
IconArrowDown,
|
||||||
|
IconArrowUp,
|
||||||
|
IconEraser,
|
||||||
|
IconPalette,
|
||||||
|
IconRowInsertBottom,
|
||||||
|
IconRowInsertTop,
|
||||||
|
IconRowRemove,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
|
||||||
|
import { useTableClear } from "../hooks/use-table-clear";
|
||||||
|
import { AlignmentSubmenu } from "./alignment-submenu";
|
||||||
|
|
||||||
|
interface RowHandleMenuProps {
|
||||||
|
editor: Editor;
|
||||||
|
index: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RowHandleMenu = React.memo(function RowHandleMenu({
|
||||||
|
editor,
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
}: RowHandleMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const setBackground = (color: string, name: string) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.updateAttributes("tableCell", {
|
||||||
|
backgroundColor: color || null,
|
||||||
|
backgroundColorName: color ? name : null,
|
||||||
|
})
|
||||||
|
.updateAttributes("tableHeader", {
|
||||||
|
backgroundColor: color || null,
|
||||||
|
backgroundColorName: color ? name : null,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveUp = useTableMoveRowColumn(editor, "row", index, "up", tableNode, tablePos);
|
||||||
|
const moveDown = useTableMoveRowColumn(editor, "row", index, "down", tableNode, tablePos);
|
||||||
|
const clearRow = useTableClear(editor, tableNode, tablePos, {
|
||||||
|
kind: "row",
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.Sub position="right-start">
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
|
||||||
|
{t("Background color")}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
|
||||||
|
{TABLE_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBackground(c.color, c.name)}
|
||||||
|
aria-label={t(c.name)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColorSwatch
|
||||||
|
color={c.color || "#ffffff"}
|
||||||
|
size={22}
|
||||||
|
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
|
||||||
|
<AlignmentSubmenu editor={editor} />
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconRowInsertTop size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().addRowBefore().run()}
|
||||||
|
>
|
||||||
|
{t("Add row above")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconRowInsertBottom size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().addRowAfter().run()}
|
||||||
|
>
|
||||||
|
{t("Add row below")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearRow}>
|
||||||
|
{t("Clear cells")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconRowRemove size={16} />}
|
||||||
|
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||||
|
>
|
||||||
|
{t("Delete row")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconArrowUp size={16} />}
|
||||||
|
onClick={moveUp.handleMove}
|
||||||
|
disabled={!moveUp.canMove}
|
||||||
|
>
|
||||||
|
{t("Move row up")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconArrowDown size={16} />}
|
||||||
|
onClick={moveDown.handleMove}
|
||||||
|
disabled={!moveDown.canMove}
|
||||||
|
>
|
||||||
|
{t("Move row down")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
|
||||||
|
import { Menu } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
|
||||||
|
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
|
||||||
|
import { RowHandleMenu } from "./menus/row-handle-menu";
|
||||||
|
import classes from "./handle.module.css";
|
||||||
|
|
||||||
|
interface RowHandleProps {
|
||||||
|
editor: Editor;
|
||||||
|
index: number;
|
||||||
|
anchorPos: number;
|
||||||
|
tableNode: ProseMirrorNode;
|
||||||
|
tablePos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RowHandle = React.memo(function RowHandle({
|
||||||
|
editor,
|
||||||
|
index,
|
||||||
|
anchorPos,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
}: RowHandleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
|
||||||
|
// so the handle div stays mounted across stale-anchor renders, otherwise
|
||||||
|
// pragmatic-dnd silently aborts an in-flight drag.
|
||||||
|
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
||||||
|
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||||
|
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||||
|
useEffect(() => {
|
||||||
|
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
|
||||||
|
lastCellDomRef.current = lookupCellDom;
|
||||||
|
setCellDom(lookupCellDom);
|
||||||
|
}
|
||||||
|
}, [lookupCellDom]);
|
||||||
|
|
||||||
|
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||||
|
placement: "left",
|
||||||
|
middleware: [offset(-4), hide()],
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
});
|
||||||
|
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refs.setReference(cellDom);
|
||||||
|
}, [cellDom, refs]);
|
||||||
|
|
||||||
|
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
|
||||||
|
useTableHandleDrag(editor, "row", handleEl, wrapper);
|
||||||
|
|
||||||
|
const { onOpen, onClose } = useColumnRowMenuLifecycle({
|
||||||
|
editor,
|
||||||
|
orientation: "row",
|
||||||
|
index,
|
||||||
|
tableNode,
|
||||||
|
tablePos,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cellDom) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
position="right-start"
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
refs.setFloating(node);
|
||||||
|
setHandleEl(node);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...floatingStyles,
|
||||||
|
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
|
||||||
|
}}
|
||||||
|
className={clsx(classes.handle, classes.rowHandle)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t("Row actions")}
|
||||||
|
>
|
||||||
|
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
|
||||||
|
<GripIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<RowHandleMenu
|
||||||
|
editor={editor}
|
||||||
|
index={index}
|
||||||
|
tableNode={tableNode}
|
||||||
|
tablePos={tablePos}
|
||||||
|
/>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function GripIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { useTableHandleState } from "./hooks/use-table-handle-state";
|
||||||
|
import { ColumnHandle } from "./column-handle";
|
||||||
|
import { RowHandle } from "./row-handle";
|
||||||
|
import { CellChevron } from "./cell-chevron";
|
||||||
|
|
||||||
|
interface TableHandlesLayerProps {
|
||||||
|
editor: Editor | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableHandlesLayer = React.memo(function TableHandlesLayer({
|
||||||
|
editor,
|
||||||
|
}: TableHandlesLayerProps) {
|
||||||
|
const state = useTableHandleState(editor);
|
||||||
|
|
||||||
|
if (!editor || !editor.isEditable) return null;
|
||||||
|
if (!state.hoveringCell || !state.tableNode || state.tablePos == null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColumnHandle
|
||||||
|
editor={editor}
|
||||||
|
index={state.hoveringCell.colIndex}
|
||||||
|
anchorPos={state.hoveringCell.colFirstCellPos}
|
||||||
|
tableNode={state.tableNode!}
|
||||||
|
tablePos={state.tablePos!}
|
||||||
|
/>
|
||||||
|
<RowHandle
|
||||||
|
editor={editor}
|
||||||
|
index={state.hoveringCell.rowIndex}
|
||||||
|
anchorPos={state.hoveringCell.rowFirstCellPos}
|
||||||
|
tableNode={state.tableNode!}
|
||||||
|
tablePos={state.tablePos!}
|
||||||
|
/>
|
||||||
|
<CellChevron
|
||||||
|
editor={editor}
|
||||||
|
cellPos={state.hoveringCell.cellPos}
|
||||||
|
tableNode={state.tableNode!}
|
||||||
|
tablePos={state.tablePos!}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -22,7 +22,7 @@ interface TableBackgroundColorProps {
|
|||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABLE_COLORS: TableColorItem[] = [
|
export const TABLE_COLORS: TableColorItem[] = [
|
||||||
{ name: "Default", color: "" },
|
{ name: "Default", color: "" },
|
||||||
{ name: "Blue", color: "#b4d5ff" },
|
{ name: "Blue", color: "#b4d5ff" },
|
||||||
{ name: "Green", color: "#acf5d2" },
|
{ name: "Green", color: "#acf5d2" },
|
||||||
|
|||||||
@@ -104,12 +104,12 @@ export const TableMenu = React.memo(
|
|||||||
element.style.zIndex = "99";
|
element.style.zIndex = "99";
|
||||||
}}
|
}}
|
||||||
options={{
|
options={{
|
||||||
placement: "top",
|
placement: "bottom",
|
||||||
offset: {
|
offset: {
|
||||||
mainAxis: 15,
|
mainAxis: 15,
|
||||||
},
|
},
|
||||||
flip: {
|
flip: {
|
||||||
fallbackPlacements: ["top", "bottom"],
|
fallbackPlacements: ["bottom", "top"],
|
||||||
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
||||||
boundary: editor.options.element as HTMLElement,
|
boundary: editor.options.element as HTMLElement,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ function nodeDOMAtCoords(
|
|||||||
options: GlobalDragHandleOptions,
|
options: GlobalDragHandleOptions,
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
) {
|
) {
|
||||||
|
// Custom nodes (transclusion, …) render via tiptap's React node-view
|
||||||
|
// renderer, which emits `class="react-renderer node-${name}"` on the
|
||||||
|
// live wrapper — the `data-type` attribute is for static HTML
|
||||||
|
// serialization only. Match both so we cover live and parsed DOM.
|
||||||
|
// Inside a custom node, also match plain `p` so the first paragraph
|
||||||
|
// (which doesn't match `:not(:first-child)`) still gets its own
|
||||||
|
// handle; only hovers on the custom node's padding/border fall
|
||||||
|
// through to the wrapper.
|
||||||
|
const customSelectors = options.customNodes.flatMap((node) => [
|
||||||
|
`[data-type=${node}]`,
|
||||||
|
`.node-${node}`,
|
||||||
|
]);
|
||||||
|
const customParagraphSelectors = options.customNodes.flatMap((node) => [
|
||||||
|
`[data-type=${node}] p`,
|
||||||
|
`.node-${node} p`,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectors = [
|
const selectors = [
|
||||||
"li",
|
"li",
|
||||||
"p:not(:first-child)",
|
"p:not(:first-child)",
|
||||||
@@ -71,7 +88,13 @@ function nodeDOMAtCoords(
|
|||||||
"h4",
|
"h4",
|
||||||
"h5",
|
"h5",
|
||||||
"h6",
|
"h6",
|
||||||
...options.customNodes.map((node) => `[data-type=${node}]`),
|
// Tables nested in another block (toggle, transclusion, …) have a
|
||||||
|
// wrapper that isn't a direct child of .ProseMirror, so the
|
||||||
|
// parent-check below skips it. Match the wrapper explicitly so the
|
||||||
|
// handle shows up even with empty cells.
|
||||||
|
".tableWrapper",
|
||||||
|
...customParagraphSelectors,
|
||||||
|
...customSelectors,
|
||||||
].join(", ");
|
].join(", ");
|
||||||
return document
|
return document
|
||||||
.elementsFromPoint(coords.x, coords.y)
|
.elementsFromPoint(coords.x, coords.y)
|
||||||
@@ -99,6 +122,22 @@ function nodePosAtDOM(
|
|||||||
})?.inside;
|
})?.inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCustomNodeDOM(
|
||||||
|
elem: Element | null | undefined,
|
||||||
|
options: GlobalDragHandleOptions,
|
||||||
|
): boolean {
|
||||||
|
if (!elem) return false;
|
||||||
|
for (const name of options.customNodes) {
|
||||||
|
if (
|
||||||
|
elem.getAttribute("data-type") === name ||
|
||||||
|
elem.classList.contains(`node-${name}`)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function calcNodePos(pos: number, view: EditorView) {
|
function calcNodePos(pos: number, view: EditorView) {
|
||||||
const $pos = view.state.doc.resolve(pos);
|
const $pos = view.state.doc.resolve(pos);
|
||||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||||
@@ -137,7 +176,6 @@ export function DragHandlePlugin(
|
|||||||
|
|
||||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||||
|
|
||||||
// Check if nodePos points to the top level node
|
|
||||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||||
else {
|
else {
|
||||||
const nodeSelection = NodeSelection.create(
|
const nodeSelection = NodeSelection.create(
|
||||||
@@ -166,14 +204,46 @@ export function DragHandlePlugin(
|
|||||||
} else {
|
} else {
|
||||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||||
|
|
||||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
const $sel = view.state.doc.resolve(selection.from);
|
||||||
// if table row is selected, go to the parent node to select the whole node
|
|
||||||
if (
|
if (isCustomNodeDOM(node, options)) {
|
||||||
(selection as NodeSelection).node.type.isInline ||
|
// The drag landed on a custom-node container (transclusion etc.).
|
||||||
(selection as NodeSelection).node.type.name === "tableRow"
|
// Walk up to the matching node so the drag moves the whole
|
||||||
) {
|
// container, not whatever inner element the click landed on.
|
||||||
let $pos = view.state.doc.resolve(selection.from);
|
const customTypes = new Set(options.customNodes);
|
||||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
for (let d = $sel.depth; d > 0; d--) {
|
||||||
|
if (customTypes.has($sel.node(d).type.name)) {
|
||||||
|
selection = NodeSelection.create(
|
||||||
|
view.state.doc,
|
||||||
|
$sel.before(d),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the selected node lives inside a table (at any nesting
|
||||||
|
// depth), promote to the whole table — the global drag handle is
|
||||||
|
// meant to move the table as a single block, not a row/cell. The
|
||||||
|
// earlier tableRow-only check only worked when the table sat at
|
||||||
|
// the doc root; once wrapped in another node (toggle, layout,
|
||||||
|
// etc.) the selection lands on a cell/paragraph and that check
|
||||||
|
// never fired.
|
||||||
|
let tableDepth = -1;
|
||||||
|
for (let d = $sel.depth; d > 0; d--) {
|
||||||
|
if ($sel.node(d).type.name === "table") {
|
||||||
|
tableDepth = d;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tableDepth > 0) {
|
||||||
|
selection = NodeSelection.create(
|
||||||
|
view.state.doc,
|
||||||
|
$sel.before(tableDepth),
|
||||||
|
);
|
||||||
|
} else if ((selection as NodeSelection).node.type.isInline) {
|
||||||
|
// Inline node (e.g. mention): walk up to the parent block.
|
||||||
|
selection = NodeSelection.create(view.state.doc, $sel.before());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
view.dispatch(view.state.tr.setSelection(selection));
|
view.dispatch(view.state.tr.setSelection(selection));
|
||||||
@@ -313,6 +383,27 @@ export function DragHandlePlugin(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCustomNode = isCustomNodeDOM(node, options);
|
||||||
|
|
||||||
|
// Custom nodes pin the handle to the inner NodeViewWrapper's top-left:
|
||||||
|
// the natural anchor sits in transient/empty space outside the visible block.
|
||||||
|
if (isCustomNode) {
|
||||||
|
// tiptap React node-views emit an outer `.react-renderer` whose first
|
||||||
|
// child is the visible NodeViewWrapper; walk to that outer first since
|
||||||
|
// `node` may be either the outer or an inner element with data-type.
|
||||||
|
const rendererOuter =
|
||||||
|
(node.closest(".react-renderer") as HTMLElement | null) ?? node;
|
||||||
|
const inner =
|
||||||
|
(rendererOuter.firstElementChild as HTMLElement | null) ??
|
||||||
|
rendererOuter;
|
||||||
|
const innerRect = absoluteRect(inner);
|
||||||
|
if (!dragHandleElement) return;
|
||||||
|
dragHandleElement.style.left = `${innerRect.left + 4}px`;
|
||||||
|
dragHandleElement.style.top = `${innerRect.top + 4}px`;
|
||||||
|
showDragHandle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const compStyle = window.getComputedStyle(node);
|
const compStyle = window.getComputedStyle(node);
|
||||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||||
const lineHeight = isNaN(parsedLineHeight)
|
const lineHeight = isNaN(parsedLineHeight)
|
||||||
@@ -328,6 +419,13 @@ export function DragHandlePlugin(
|
|||||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||||
rect.left -= options.dragHandleWidth;
|
rect.left -= options.dragHandleWidth;
|
||||||
}
|
}
|
||||||
|
// Tables: clear the table's own row-drag handle so the two
|
||||||
|
// grips don't stack on each other. `nodeDOMAtCoords` returns
|
||||||
|
// the wrapper for top-level hovers (wrapper is direct child of
|
||||||
|
// .ProseMirror) and a descendant for deeper hovers — cover both.
|
||||||
|
if (node.closest(".tableWrapper")) {
|
||||||
|
rect.left -= options.dragHandleWidth;
|
||||||
|
}
|
||||||
rect.width = options.dragHandleWidth;
|
rect.width = options.dragHandleWidth;
|
||||||
|
|
||||||
if (!dragHandleElement) return;
|
if (!dragHandleElement) return;
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ import {
|
|||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
|
TableHandleCommandsExtension,
|
||||||
|
TableHeaderPin,
|
||||||
|
TableReadonlySort,
|
||||||
Subpages,
|
Subpages,
|
||||||
Heading,
|
Heading,
|
||||||
Highlight,
|
Highlight,
|
||||||
@@ -56,6 +59,7 @@ import {
|
|||||||
Status,
|
Status,
|
||||||
TransclusionSource,
|
TransclusionSource,
|
||||||
TransclusionReference,
|
TransclusionReference,
|
||||||
|
TableView,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -259,11 +263,16 @@ export const mainExtensions = [
|
|||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: true,
|
lastColumnResizable: true,
|
||||||
allowTableNodeSelection: true,
|
allowTableNodeSelection: true,
|
||||||
|
cellMinWidth: 49,
|
||||||
|
View: TableView,
|
||||||
}),
|
}),
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
|
TableHandleCommandsExtension,
|
||||||
|
TableHeaderPin,
|
||||||
|
TableReadonlySort,
|
||||||
MathInline.configure({
|
MathInline.configure({
|
||||||
view: MathInlineView,
|
view: MathInlineView,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
|
|||||||
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
|
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
|
||||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||||
|
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
|
||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
@@ -424,7 +425,7 @@ export default function PageEditor({
|
|||||||
<EditorLinkMenu editor={editor} />
|
<EditorLinkMenu editor={editor} />
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
<TableHandlesLayer editor={editor} />
|
||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<PdfMenu editor={editor} />
|
<PdfMenu editor={editor} />
|
||||||
|
|||||||
@@ -203,7 +203,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-cursor {
|
&.resize-cursor,
|
||||||
|
&.resize-cursor * {
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-dnd-drop-indicator {
|
.table-dnd-drop-indicator {
|
||||||
background-color: #adf;
|
background-color: var(--mantine-color-blue-5);
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
@@ -57,13 +58,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column-resize-handle {
|
.column-resize-handle {
|
||||||
background-color: #adf;
|
background-color: var(--mantine-color-blue-5);
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -2px;
|
right: -1px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 4px;
|
width: 2px;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectedCell:after {
|
.selectedCell:after {
|
||||||
@@ -129,6 +131,139 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Header-row pinning. Two CSS paths, picked by `header-pin/controller.ts`:
|
||||||
|
- native sticky (preferred): wrapper drops its overflow constraint so
|
||||||
|
`position: sticky` on the row can resolve against the document scroll.
|
||||||
|
- transform fallback: wrapper keeps `overflow-x: auto` for horizontal
|
||||||
|
scrolling; the row is positioned imperatively per scroll frame.
|
||||||
|
|
||||||
|
`--editor-pin-offset` is published to :root by `pinOffsetWatcher` in
|
||||||
|
`header-pin/offset.ts`, measured against the lowest fixed surface above
|
||||||
|
the editor (app shell header, page header, fixed toolbar). */
|
||||||
|
|
||||||
|
.tableWrapper.tableWrapperNoOverflow,
|
||||||
|
.tableWrapper.tableWrapperNoOverflow table {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper.tableHeaderPinned table tr:first-child {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper.tableWrapperNoOverflow.tableHeaderPinned table tr:first-child {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--editor-pin-offset, 90px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper.tableHeaderPinned:not(.tableWrapperNoOverflow) table tr:first-child {
|
||||||
|
position: relative;
|
||||||
|
transform: translateY(var(--table-pin-offset, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.tableWrapper.tableHeaderPinned table tr:first-child {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableReadonlySortChevron {
|
||||||
|
/* Anchor to the cell's right edge, vertically centered with the cell
|
||||||
|
content. The cell content (a <p>) is block-level so an inline chevron
|
||||||
|
would wrap to a new line; absolute positioning takes it out of flow. */
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: light-dark(
|
||||||
|
rgba(55, 53, 47, 0.08),
|
||||||
|
rgba(255, 255, 255, 0.08)
|
||||||
|
);
|
||||||
|
color: light-dark(
|
||||||
|
rgba(55, 53, 47, 0.55),
|
||||||
|
rgba(255, 255, 255, 0.55)
|
||||||
|
);
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
/* Hidden by default; revealed on header-cell hover or when this column is
|
||||||
|
the active sort (see selectors below). */
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table th:hover .tableReadonlySortChevron,
|
||||||
|
.tableReadonlySortChevron[data-sort] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table th:has(.tableReadonlySortChevron) {
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableReadonlySortChevron:hover {
|
||||||
|
background: light-dark(
|
||||||
|
rgba(55, 53, 47, 0.16),
|
||||||
|
rgba(255, 255, 255, 0.16)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Immediate tooltip on the chevron — same style language as the rest of the
|
||||||
|
app (small, dark, rounded), unlike the native `title` tooltip which only
|
||||||
|
appears after a long delay. */
|
||||||
|
.tableReadonlySortChevron::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
/* Below the chevron — placing it above the cell hits the table's
|
||||||
|
overflow clipping (the wrapper has `overflow-x: auto` which forces
|
||||||
|
`overflow-y: auto` per spec). */
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--mantine-color-dark-7);
|
||||||
|
color: var(--mantine-color-white);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableReadonlySortChevron:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableReadonlySortChevron svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableReadonlySortChevron[data-sort="asc"],
|
||||||
|
.tableReadonlySortChevron[data-sort="desc"] {
|
||||||
|
background: light-dark(
|
||||||
|
var(--mantine-color-blue-1),
|
||||||
|
var(--mantine-color-blue-9)
|
||||||
|
);
|
||||||
|
color: light-dark(
|
||||||
|
var(--mantine-color-blue-7),
|
||||||
|
var(--mantine-color-blue-2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableReadonlySortChevron[data-sort="asc"] svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
|
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
|
||||||
.prosemirror-dropcursor-block {
|
.prosemirror-dropcursor-block {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export default function PageHeader({ readOnly }: Props) {
|
export default function PageHeader({ readOnly }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.header}>
|
<div className={classes.header} data-page-header="true">
|
||||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
||||||
<Breadcrumb />
|
<Breadcrumb />
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { DraggingDOMs } from "./utils";
|
|
||||||
|
|
||||||
const EDGE_THRESHOLD = 100;
|
|
||||||
const SCROLL_SPEED = 10;
|
|
||||||
|
|
||||||
export class AutoScrollController {
|
|
||||||
private _autoScrollInterval?: number;
|
|
||||||
|
|
||||||
checkYAutoScroll = (clientY: number) => {
|
|
||||||
const scrollContainer = document.documentElement;
|
|
||||||
|
|
||||||
if (clientY < 0 + EDGE_THRESHOLD) {
|
|
||||||
this._startYAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
|
|
||||||
} else if (clientY > window.innerHeight - EDGE_THRESHOLD) {
|
|
||||||
this._startYAutoScroll(scrollContainer!, SCROLL_SPEED);
|
|
||||||
} else {
|
|
||||||
this._stopYAutoScroll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkXAutoScroll = (clientX: number, draggingDOMs: DraggingDOMs) => {
|
|
||||||
const table = draggingDOMs?.table;
|
|
||||||
if (!table) return;
|
|
||||||
|
|
||||||
const scrollContainer = table.closest<HTMLElement>('.tableWrapper');
|
|
||||||
const editorRect = scrollContainer.getBoundingClientRect();
|
|
||||||
if (!scrollContainer) return;
|
|
||||||
|
|
||||||
if (clientX < editorRect.left + EDGE_THRESHOLD) {
|
|
||||||
this._startXAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
|
|
||||||
} else if (clientX > editorRect.right - EDGE_THRESHOLD) {
|
|
||||||
this._startXAutoScroll(scrollContainer!, SCROLL_SPEED);
|
|
||||||
} else {
|
|
||||||
this._stopXAutoScroll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop = () => {
|
|
||||||
this._stopXAutoScroll();
|
|
||||||
this._stopYAutoScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _startXAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
|
|
||||||
if (this._autoScrollInterval) {
|
|
||||||
clearInterval(this._autoScrollInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._autoScrollInterval = window.setInterval(() => {
|
|
||||||
scrollContainer.scrollLeft += speed;
|
|
||||||
}, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _stopXAutoScroll = () => {
|
|
||||||
if (this._autoScrollInterval) {
|
|
||||||
clearInterval(this._autoScrollInterval);
|
|
||||||
this._autoScrollInterval = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _startYAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
|
|
||||||
if (this._autoScrollInterval) {
|
|
||||||
clearInterval(this._autoScrollInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._autoScrollInterval = window.setInterval(() => {
|
|
||||||
scrollContainer.scrollTop += speed;
|
|
||||||
}, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _stopYAutoScroll = () => {
|
|
||||||
if (this._autoScrollInterval) {
|
|
||||||
clearInterval(this._autoScrollInterval);
|
|
||||||
this._autoScrollInterval = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,316 +1,393 @@
|
|||||||
import { Editor, Extension } from "@tiptap/core";
|
import { Editor, Extension } from "@tiptap/core";
|
||||||
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
|
import { PluginKey, Plugin, PluginSpec, TextSelection, Transaction } from "@tiptap/pm/state";
|
||||||
|
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
import { EditorProps, EditorView } from "@tiptap/pm/view";
|
import { EditorProps, EditorView } from "@tiptap/pm/view";
|
||||||
|
import { columnResizingPluginKey } from "@tiptap/pm/tables";
|
||||||
|
import { cellAround } from "@tiptap/pm/tables";
|
||||||
import {
|
import {
|
||||||
|
cellInfoFromResolvedCell,
|
||||||
DraggingDOMs,
|
DraggingDOMs,
|
||||||
getDndRelatedDOMs,
|
getDndRelatedDOMs,
|
||||||
getHoveringCell,
|
getHoveringCell,
|
||||||
HoveringCellInfo,
|
HoveringCellInfo,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
|
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
|
||||||
|
import { findTable } from "../utils/query";
|
||||||
import { moveColumn, moveRow } from "../utils";
|
import { moveColumn, moveRow } from "../utils";
|
||||||
import { PreviewController } from "./preview/preview-controller";
|
import { PreviewController } from "./preview/preview-controller";
|
||||||
import { DropIndicatorController } from "./preview/drop-indicator-controller";
|
import { DropIndicatorController } from "./preview/drop-indicator-controller";
|
||||||
import { DragHandleController } from "./handle/drag-handle-controller";
|
|
||||||
import { EmptyImageController } from "./handle/empty-image-controller";
|
|
||||||
import { AutoScrollController } from "./auto-scroll-controller";
|
|
||||||
|
|
||||||
export const TableDndKey = new PluginKey("table-drag-and-drop");
|
export interface TableHandleState {
|
||||||
|
hoveringCell: HoveringCellInfo | null;
|
||||||
|
tableNode: ProseMirrorNode | null;
|
||||||
|
tablePos: number | null;
|
||||||
|
dragging: { orientation: "col" | "row"; index: number } | null;
|
||||||
|
frozen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class TableDragHandlePluginSpec implements PluginSpec<void> {
|
const INITIAL_STATE: TableHandleState = {
|
||||||
|
hoveringCell: null,
|
||||||
|
tableNode: null,
|
||||||
|
tablePos: null,
|
||||||
|
dragging: null,
|
||||||
|
frozen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TableDndKey = new PluginKey<TableHandleState>("table-handles");
|
||||||
|
|
||||||
|
class TableHandlePluginSpec implements PluginSpec<TableHandleState> {
|
||||||
key = TableDndKey;
|
key = TableDndKey;
|
||||||
props: EditorProps<Plugin<void>>;
|
props: EditorProps<Plugin<TableHandleState>>;
|
||||||
|
|
||||||
|
private _previewController: PreviewController;
|
||||||
|
private _dropIndicatorController: DropIndicatorController;
|
||||||
|
|
||||||
private _colDragHandle: HTMLElement;
|
|
||||||
private _rowDragHandle: HTMLElement;
|
|
||||||
private _hoveringCell?: HoveringCellInfo;
|
private _hoveringCell?: HoveringCellInfo;
|
||||||
private _disposables: (() => void)[] = [];
|
private _disposables: (() => void)[] = [];
|
||||||
private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 };
|
|
||||||
private _dragging = false;
|
|
||||||
private _draggingDirection: "col" | "row" = "col";
|
private _draggingDirection: "col" | "row" = "col";
|
||||||
private _draggingIndex = -1;
|
private _draggingIndex = -1;
|
||||||
private _droppingIndex = -1;
|
private _droppingIndex = -1;
|
||||||
private _draggingDOMs?: DraggingDOMs | undefined;
|
private _draggingDOMs?: DraggingDOMs;
|
||||||
private _startCoords: { x: number; y: number } = { x: 0, y: 0 };
|
private _startCoords = { x: 0, y: 0 };
|
||||||
private _previewController: PreviewController;
|
private _dragging = false;
|
||||||
private _dropIndicatorController: DropIndicatorController;
|
|
||||||
private _dragHandleController: DragHandleController;
|
state = {
|
||||||
private _emptyImageController: EmptyImageController;
|
init: (): TableHandleState => INITIAL_STATE,
|
||||||
private _autoScrollController: AutoScrollController;
|
apply: (tr: Transaction, prev: TableHandleState): TableHandleState => {
|
||||||
|
const meta = tr.getMeta(TableDndKey) as Partial<TableHandleState> | null;
|
||||||
|
if (!meta) return prev;
|
||||||
|
let changed = false;
|
||||||
|
for (const key in meta) {
|
||||||
|
if (!Object.is(prev[key as keyof TableHandleState], meta[key as keyof TableHandleState])) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { ...prev, ...meta } : prev;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
constructor(public editor: Editor) {
|
constructor(public editor: Editor) {
|
||||||
this.props = {
|
this.props = {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
pointerover: this._pointerOver,
|
pointermove: this._pointerMove,
|
||||||
|
// Force-unfreeze on any pointerdown that lands on the editor.
|
||||||
|
// Mantine's `Menu.onClose` doesn't always fire on outside click
|
||||||
|
// (the dropdown vanishes visually but the callback is skipped),
|
||||||
|
// which would otherwise leave `frozen=true` permanently.
|
||||||
|
pointerdown: this._pointerDown,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this._dragHandleController = new DragHandleController();
|
|
||||||
this._colDragHandle = this._dragHandleController.colDragHandle;
|
|
||||||
this._rowDragHandle = this._dragHandleController.rowDragHandle;
|
|
||||||
|
|
||||||
this._previewController = new PreviewController();
|
this._previewController = new PreviewController();
|
||||||
this._dropIndicatorController = new DropIndicatorController();
|
this._dropIndicatorController = new DropIndicatorController();
|
||||||
this._emptyImageController = new EmptyImageController();
|
|
||||||
|
|
||||||
this._autoScrollController = new AutoScrollController();
|
|
||||||
|
|
||||||
this._bindDragEvents();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view = () => {
|
view = () => {
|
||||||
const wrapper = this.editor.options.element;
|
const wrapper = this.editor.options.element;
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
wrapper.appendChild(this._colDragHandle);
|
|
||||||
//@ts-ignore
|
|
||||||
wrapper.appendChild(this._rowDragHandle);
|
|
||||||
//@ts-ignore
|
|
||||||
wrapper.appendChild(this._previewController.previewRoot);
|
wrapper.appendChild(this._previewController.previewRoot);
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
|
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
|
||||||
|
|
||||||
|
// Track the cursor cell so handles follow keyboard nav and clicks too.
|
||||||
|
this.editor.on("selectionUpdate", this._onSelectionUpdate);
|
||||||
|
this._disposables.push(() =>
|
||||||
|
this.editor.off("selectionUpdate", this._onSelectionUpdate),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update: this.update,
|
|
||||||
destroy: this.destroy,
|
destroy: this.destroy,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
update = () => {};
|
|
||||||
|
|
||||||
destroy = () => {
|
destroy = () => {
|
||||||
if (!this.editor.isDestroyed) return;
|
|
||||||
this._dragHandleController.destroy();
|
|
||||||
this._emptyImageController.destroy();
|
|
||||||
this._previewController.destroy();
|
this._previewController.destroy();
|
||||||
this._dropIndicatorController.destroy();
|
this._dropIndicatorController.destroy();
|
||||||
this._autoScrollController.stop();
|
this._disposables.forEach((d) => d());
|
||||||
|
|
||||||
this._disposables.forEach((disposable) => disposable());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private _pointerOver = (view: EditorView, event: PointerEvent) => {
|
private _pointerDown = (view: EditorView, _event: PointerEvent): boolean => {
|
||||||
if (this._dragging) return;
|
const current = TableDndKey.getState(view.state);
|
||||||
|
if (current?.frozen) this.editor.commands.unfreezeHandles();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _pointerMove = (view: EditorView, event: PointerEvent) => {
|
||||||
|
const current = TableDndKey.getState(view.state);
|
||||||
|
if (current?.frozen || current?.dragging) return;
|
||||||
|
|
||||||
|
const resizeState = columnResizingPluginKey.getState(view.state);
|
||||||
|
if (resizeState?.dragging) return;
|
||||||
|
|
||||||
// Don't show drag handles in readonly mode
|
|
||||||
if (!this.editor.isEditable) {
|
if (!this.editor.isEditable) {
|
||||||
this._dragHandleController.hide();
|
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
|
||||||
|
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoveringCell = getHoveringCell(view, event);
|
const hoveringCell = getHoveringCell(view, event);
|
||||||
this._hoveringCell = hoveringCell;
|
if (hoveringCell) {
|
||||||
if (!hoveringCell) {
|
if (current?.hoveringCell?.cellPos === hoveringCell.cellPos) return;
|
||||||
this._dragHandleController.hide();
|
this._hoveringCell = hoveringCell;
|
||||||
} else {
|
const $cell = view.state.doc.resolve(hoveringCell.cellPos);
|
||||||
this._dragHandleController.show(this.editor, hoveringCell);
|
const tableInfo = findTable($cell);
|
||||||
|
this._dispatchMeta({
|
||||||
|
hoveringCell,
|
||||||
|
tableNode: tableInfo?.node ?? null,
|
||||||
|
tablePos: tableInfo?.pos ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pointer isn't over a cell but may be transiting toward a handle that
|
||||||
|
// floats outside the cell — fall back to the selection's cell so the
|
||||||
|
// handles stay visible.
|
||||||
|
const $cellPos = cellAround(view.state.selection.$head);
|
||||||
|
if ($cellPos) {
|
||||||
|
const cellInfo = cellInfoFromResolvedCell($cellPos);
|
||||||
|
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
|
||||||
|
this._hoveringCell = cellInfo;
|
||||||
|
const tableInfo = findTable($cellPos);
|
||||||
|
this._dispatchMeta({
|
||||||
|
hoveringCell: cellInfo,
|
||||||
|
tableNode: tableInfo?.node ?? null,
|
||||||
|
tablePos: tableInfo?.pos ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._hoveringCell = undefined;
|
||||||
|
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
|
||||||
|
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
private _onDragColStart = (event: DragEvent) => {
|
private _onSelectionUpdate = () => {
|
||||||
this._onDragStart(event, "col");
|
if (!this.editor.isEditable) return;
|
||||||
|
|
||||||
|
const current = TableDndKey.getState(this.editor.state);
|
||||||
|
if (current?.frozen || current?.dragging) return;
|
||||||
|
|
||||||
|
const $cellPos = cellAround(this.editor.state.selection.$head);
|
||||||
|
if (!$cellPos) return;
|
||||||
|
|
||||||
|
const cellInfo = cellInfoFromResolvedCell($cellPos);
|
||||||
|
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
|
||||||
|
|
||||||
|
this._hoveringCell = cellInfo;
|
||||||
|
const tableInfo = findTable($cellPos);
|
||||||
|
this._dispatchMeta({
|
||||||
|
hoveringCell: cellInfo,
|
||||||
|
tableNode: tableInfo?.node ?? null,
|
||||||
|
tablePos: tableInfo?.pos ?? null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private _onDraggingCol = (event: DragEvent) => {
|
private _dispatchMeta = (patch: Partial<TableHandleState>) => {
|
||||||
|
const tr = this.editor.state.tr.setMeta(TableDndKey, patch);
|
||||||
|
tr.setMeta("addToHistory", false);
|
||||||
|
this.editor.view.dispatch(tr);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Public API for the React handle layer ----
|
||||||
|
|
||||||
|
// Returns true if the drag was set up successfully.
|
||||||
|
startDragFromHandle = (
|
||||||
|
orientation: "col" | "row",
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): boolean => {
|
||||||
|
if (!this._hoveringCell) return false;
|
||||||
|
this._dragging = true;
|
||||||
|
this._draggingDirection = orientation;
|
||||||
|
this._startCoords = { x: clientX, y: clientY };
|
||||||
|
|
||||||
|
const draggingIndex =
|
||||||
|
(orientation === "col"
|
||||||
|
? this._hoveringCell.colIndex
|
||||||
|
: this._hoveringCell.rowIndex) ?? 0;
|
||||||
|
this._draggingIndex = draggingIndex;
|
||||||
|
|
||||||
|
const relatedDoms = getDndRelatedDOMs(
|
||||||
|
this.editor.view,
|
||||||
|
this._hoveringCell.cellPos,
|
||||||
|
draggingIndex,
|
||||||
|
orientation,
|
||||||
|
);
|
||||||
|
if (!relatedDoms) {
|
||||||
|
this._dragging = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._draggingDOMs = relatedDoms;
|
||||||
|
|
||||||
|
this._previewController.onDragStart(relatedDoms, draggingIndex, orientation);
|
||||||
|
this._dropIndicatorController.onDragStart(relatedDoms, orientation);
|
||||||
|
|
||||||
|
// Park the selection inside the dragged cell unless it's already in the
|
||||||
|
// same table. PM auto-maps `selection.from` through concurrent remote
|
||||||
|
// transactions, so commitDrop can resolve the table even if the doc
|
||||||
|
// shifted mid-drag — same trick the pre-pragmatic-dnd implementation
|
||||||
|
// relied on.
|
||||||
|
const state = this.editor.state;
|
||||||
|
const currentTable = findTable(state.selection.$from);
|
||||||
|
const hoverTable = (() => {
|
||||||
|
try {
|
||||||
|
return findTable(state.doc.resolve(this._hoveringCell.cellPos));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const tr = state.tr;
|
||||||
|
if (
|
||||||
|
hoverTable &&
|
||||||
|
(!currentTable || currentTable.pos !== hoverTable.pos)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const $inside = state.doc.resolve(this._hoveringCell.cellPos + 1);
|
||||||
|
tr.setSelection(TextSelection.near($inside, 1));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
tr.setMeta(TableDndKey, {
|
||||||
|
dragging: { orientation, index: draggingIndex },
|
||||||
|
});
|
||||||
|
tr.setMeta("addToHistory", false);
|
||||||
|
this.editor.view.dispatch(tr);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDragPosition = (clientX: number, clientY: number) => {
|
||||||
const draggingDOMs = this._draggingDOMs;
|
const draggingDOMs = this._draggingDOMs;
|
||||||
if (!draggingDOMs) return;
|
if (!draggingDOMs || !this._dragging) return;
|
||||||
|
|
||||||
this._draggingCoords = { x: event.clientX, y: event.clientY };
|
if (this._draggingDirection === "col") {
|
||||||
this._previewController.onDragging(
|
this._previewController.onDragging(
|
||||||
draggingDOMs,
|
draggingDOMs,
|
||||||
this._draggingCoords.x,
|
clientX,
|
||||||
this._draggingCoords.y,
|
clientY,
|
||||||
"col",
|
"col",
|
||||||
);
|
);
|
||||||
|
const direction = this._startCoords.x > clientX ? "left" : "right";
|
||||||
|
const dragOverColumn = getDragOverColumn(draggingDOMs.table, clientX);
|
||||||
|
if (!dragOverColumn) return;
|
||||||
|
const [col, index] = dragOverColumn;
|
||||||
|
this._droppingIndex = index;
|
||||||
|
this._dropIndicatorController.onDragging(col, direction, "col");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs);
|
this._previewController.onDragging(draggingDOMs, clientX, clientY, "row");
|
||||||
|
const direction = this._startCoords.y > clientY ? "up" : "down";
|
||||||
const direction =
|
const dragOverRow = getDragOverRow(draggingDOMs.table, clientY);
|
||||||
this._startCoords.x > this._draggingCoords.x ? "left" : "right";
|
|
||||||
const dragOverColumn = getDragOverColumn(
|
|
||||||
draggingDOMs.table,
|
|
||||||
this._draggingCoords.x,
|
|
||||||
);
|
|
||||||
if (!dragOverColumn) return;
|
|
||||||
|
|
||||||
const [col, index] = dragOverColumn;
|
|
||||||
this._droppingIndex = index;
|
|
||||||
this._dropIndicatorController.onDragging(col, direction, "col");
|
|
||||||
};
|
|
||||||
|
|
||||||
private _onDragRowStart = (event: DragEvent) => {
|
|
||||||
this._onDragStart(event, "row");
|
|
||||||
};
|
|
||||||
|
|
||||||
private _onDraggingRow = (event: DragEvent) => {
|
|
||||||
const draggingDOMs = this._draggingDOMs;
|
|
||||||
if (!draggingDOMs) return;
|
|
||||||
|
|
||||||
this._draggingCoords = { x: event.clientX, y: event.clientY };
|
|
||||||
this._previewController.onDragging(
|
|
||||||
draggingDOMs,
|
|
||||||
this._draggingCoords.x,
|
|
||||||
this._draggingCoords.y,
|
|
||||||
"row",
|
|
||||||
);
|
|
||||||
|
|
||||||
this._autoScrollController.checkYAutoScroll(event.clientY);
|
|
||||||
|
|
||||||
const direction =
|
|
||||||
this._startCoords.y > this._draggingCoords.y ? "up" : "down";
|
|
||||||
const dragOverRow = getDragOverRow(
|
|
||||||
draggingDOMs.table,
|
|
||||||
this._draggingCoords.y,
|
|
||||||
);
|
|
||||||
if (!dragOverRow) return;
|
if (!dragOverRow) return;
|
||||||
|
|
||||||
const [row, index] = dragOverRow;
|
const [row, index] = dragOverRow;
|
||||||
this._droppingIndex = index;
|
this._droppingIndex = index;
|
||||||
this._dropIndicatorController.onDragging(row, direction, "row");
|
this._dropIndicatorController.onDragging(row, direction, "row");
|
||||||
};
|
};
|
||||||
|
|
||||||
private _onDragEnd = () => {
|
commitDrop = () => {
|
||||||
this._dragging = false;
|
|
||||||
this._draggingIndex = -1;
|
|
||||||
this._droppingIndex = -1;
|
|
||||||
this._startCoords = { x: 0, y: 0 };
|
|
||||||
this._autoScrollController.stop();
|
|
||||||
this._dropIndicatorController.onDragEnd();
|
|
||||||
this._previewController.onDragEnd();
|
|
||||||
};
|
|
||||||
|
|
||||||
private _bindDragEvents = () => {
|
|
||||||
this._colDragHandle.addEventListener("dragstart", this._onDragColStart);
|
|
||||||
this._disposables.push(() => {
|
|
||||||
this._colDragHandle.removeEventListener(
|
|
||||||
"dragstart",
|
|
||||||
this._onDragColStart,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
this._colDragHandle.addEventListener("dragend", this._onDragEnd);
|
|
||||||
this._disposables.push(() => {
|
|
||||||
this._colDragHandle.removeEventListener("dragend", this._onDragEnd);
|
|
||||||
});
|
|
||||||
|
|
||||||
this._rowDragHandle.addEventListener("dragstart", this._onDragRowStart);
|
|
||||||
this._disposables.push(() => {
|
|
||||||
this._rowDragHandle.removeEventListener(
|
|
||||||
"dragstart",
|
|
||||||
this._onDragRowStart,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
this._rowDragHandle.addEventListener("dragend", this._onDragEnd);
|
|
||||||
this._disposables.push(() => {
|
|
||||||
this._rowDragHandle.removeEventListener("dragend", this._onDragEnd);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ownerDocument = this.editor.view.dom?.ownerDocument;
|
|
||||||
if (ownerDocument) {
|
|
||||||
// To make `drop` event work, we need to prevent the default behavior of the
|
|
||||||
// `dragover` event for drop zone. Here we set the whole document as the
|
|
||||||
// drop zone so that even the mouse moves outside the editor, the `drop`
|
|
||||||
// event will still be triggered.
|
|
||||||
ownerDocument.addEventListener("drop", this._onDrop);
|
|
||||||
ownerDocument.addEventListener("dragover", this._onDrag);
|
|
||||||
this._disposables.push(() => {
|
|
||||||
ownerDocument.removeEventListener("drop", this._onDrop);
|
|
||||||
ownerDocument.removeEventListener("dragover", this._onDrag);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private _onDragStart = (event: DragEvent, type: "col" | "row") => {
|
|
||||||
const dataTransfer = event.dataTransfer;
|
|
||||||
if (dataTransfer) {
|
|
||||||
dataTransfer.effectAllowed = "move";
|
|
||||||
this._emptyImageController.hideDragImage(dataTransfer);
|
|
||||||
}
|
|
||||||
this._dragging = true;
|
|
||||||
this._draggingDirection = type;
|
|
||||||
this._startCoords = { x: event.clientX, y: event.clientY };
|
|
||||||
const draggingIndex =
|
|
||||||
(type === "col"
|
|
||||||
? this._hoveringCell?.colIndex
|
|
||||||
: this._hoveringCell?.rowIndex) ?? 0;
|
|
||||||
|
|
||||||
this._draggingIndex = draggingIndex;
|
|
||||||
|
|
||||||
const relatedDoms = getDndRelatedDOMs(
|
|
||||||
this.editor.view,
|
|
||||||
this._hoveringCell?.cellPos,
|
|
||||||
draggingIndex,
|
|
||||||
type,
|
|
||||||
);
|
|
||||||
this._draggingDOMs = relatedDoms;
|
|
||||||
|
|
||||||
const index =
|
|
||||||
type === "col"
|
|
||||||
? this._hoveringCell?.colIndex
|
|
||||||
: this._hoveringCell?.rowIndex;
|
|
||||||
|
|
||||||
this._previewController.onDragStart(relatedDoms, index, type);
|
|
||||||
this._dropIndicatorController.onDragStart(relatedDoms, type);
|
|
||||||
};
|
|
||||||
|
|
||||||
private _onDrag = (event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!this._dragging) return;
|
|
||||||
if (this._draggingDirection === "col") {
|
|
||||||
this._onDraggingCol(event);
|
|
||||||
} else {
|
|
||||||
this._onDraggingRow(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private _onDrop = () => {
|
|
||||||
if (!this._dragging) return;
|
if (!this._dragging) return;
|
||||||
const direction = this._draggingDirection;
|
const direction = this._draggingDirection;
|
||||||
const from = this._draggingIndex;
|
const from = this._draggingIndex;
|
||||||
const to = this._droppingIndex;
|
const to = this._droppingIndex;
|
||||||
|
|
||||||
|
if (from < 0 || to < 0 || from === to) return;
|
||||||
|
|
||||||
|
// Use the live (auto-mapped) selection as the table anchor — PM has
|
||||||
|
// already mapped it through any concurrent remote transactions, so
|
||||||
|
// it's safe to resolve even if the doc shifted mid-drag.
|
||||||
const tr = this.editor.state.tr;
|
const tr = this.editor.state.tr;
|
||||||
const pos = this.editor.state.selection.from;
|
const pos = this.editor.state.selection.from;
|
||||||
|
|
||||||
if (direction === "col") {
|
if (direction === "col") {
|
||||||
const canMove = moveColumn({
|
if (moveColumn({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
|
||||||
tr,
|
|
||||||
originIndex: from,
|
|
||||||
targetIndex: to,
|
|
||||||
select: true,
|
|
||||||
pos,
|
|
||||||
});
|
|
||||||
if (canMove) {
|
|
||||||
this.editor.view.dispatch(tr);
|
this.editor.view.dispatch(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (moveRow({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
|
||||||
if (direction === "row") {
|
this.editor.view.dispatch(tr);
|
||||||
const canMove = moveRow({
|
|
||||||
tr,
|
|
||||||
originIndex: from,
|
|
||||||
targetIndex: to,
|
|
||||||
select: true,
|
|
||||||
pos,
|
|
||||||
});
|
|
||||||
if (canMove) {
|
|
||||||
this.editor.view.dispatch(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
endDrag = () => {
|
||||||
|
this._dragging = false;
|
||||||
|
this._draggingIndex = -1;
|
||||||
|
this._droppingIndex = -1;
|
||||||
|
this._startCoords = { x: 0, y: 0 };
|
||||||
|
this._draggingDOMs = undefined;
|
||||||
|
this._dropIndicatorController.onDragEnd();
|
||||||
|
this._previewController.onDragEnd();
|
||||||
|
this._dispatchMeta({ dragging: null });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { TableHandlePluginSpec };
|
||||||
|
|
||||||
|
// Resolve via plugin key, not a module singleton — survives StrictMode / HMR.
|
||||||
|
export function getTableHandlePluginSpec(
|
||||||
|
editor: Editor,
|
||||||
|
): TableHandlePluginSpec | null {
|
||||||
|
const plugin = TableDndKey.get(editor.state);
|
||||||
|
if (!plugin) return null;
|
||||||
|
return plugin.spec as unknown as TableHandlePluginSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableDndExtension = Extension.create({
|
export const TableDndExtension = Extension.create({
|
||||||
name: "table-drag-and-drop",
|
name: "table-drag-and-drop",
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const editor = this.editor;
|
const editor = this.editor;
|
||||||
|
const spec = new TableHandlePluginSpec(editor);
|
||||||
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor);
|
return [new Plugin(spec)];
|
||||||
const dragHandlePlugin = new Plugin(dragHandlePluginSpec);
|
|
||||||
|
|
||||||
return [dragHandlePlugin];
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TableHandleCommandsExtension = Extension.create({
|
||||||
|
name: "table-handle-commands",
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
freezeHandles:
|
||||||
|
() =>
|
||||||
|
({ tr, dispatch }) => {
|
||||||
|
if (dispatch) {
|
||||||
|
tr.setMeta(TableDndKey, { frozen: true });
|
||||||
|
tr.setMeta("addToHistory", false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
unfreezeHandles:
|
||||||
|
() =>
|
||||||
|
({ tr, state, dispatch }) => {
|
||||||
|
if (dispatch) {
|
||||||
|
// Re-sync `hoveringCell` to the cursor's cell as we unfreeze:
|
||||||
|
// `selectionUpdate` was gated while frozen, so the stored
|
||||||
|
// hoveringCell may be stale.
|
||||||
|
const patch: Partial<TableHandleState> = { frozen: false };
|
||||||
|
const $cellPos = cellAround(state.selection.$head);
|
||||||
|
if ($cellPos) {
|
||||||
|
const cellInfo = cellInfoFromResolvedCell($cellPos);
|
||||||
|
const tableInfo = findTable($cellPos);
|
||||||
|
patch.hoveringCell = cellInfo;
|
||||||
|
patch.tableNode = tableInfo?.node ?? null;
|
||||||
|
patch.tablePos = tableInfo?.pos ?? null;
|
||||||
|
} else {
|
||||||
|
patch.hoveringCell = null;
|
||||||
|
patch.tableNode = null;
|
||||||
|
patch.tablePos = null;
|
||||||
|
}
|
||||||
|
tr.setMeta(TableDndKey, patch);
|
||||||
|
tr.setMeta("addToHistory", false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
tableHandleCommands: {
|
||||||
|
freezeHandles: () => ReturnType;
|
||||||
|
unfreezeHandles: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { HoveringCellInfo } from "../utils";
|
|
||||||
import { computePosition, offset } from "@floating-ui/dom";
|
|
||||||
|
|
||||||
export class DragHandleController {
|
|
||||||
private _colDragHandle: HTMLElement;
|
|
||||||
private _rowDragHandle: HTMLElement;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._colDragHandle = this._createDragHandleDom('col');
|
|
||||||
this._rowDragHandle = this._createDragHandleDom('row');
|
|
||||||
}
|
|
||||||
|
|
||||||
get colDragHandle() {
|
|
||||||
return this._colDragHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
get rowDragHandle() {
|
|
||||||
return this._rowDragHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
show = (editor: Editor, hoveringCell: HoveringCellInfo) => {
|
|
||||||
this._showColDragHandle(editor, hoveringCell);
|
|
||||||
this._showRowDragHandle(editor, hoveringCell);
|
|
||||||
}
|
|
||||||
|
|
||||||
hide = () => {
|
|
||||||
Object.assign(this._colDragHandle.style, {
|
|
||||||
display: 'none',
|
|
||||||
left: '-999px',
|
|
||||||
top: '-999px',
|
|
||||||
});
|
|
||||||
Object.assign(this._rowDragHandle.style, {
|
|
||||||
display: 'none',
|
|
||||||
left: '-999px',
|
|
||||||
top: '-999px',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy = () => {
|
|
||||||
this._colDragHandle.remove()
|
|
||||||
this._rowDragHandle.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
private _createDragHandleDom = (type: 'col' | 'row') => {
|
|
||||||
const dragHandle = document.createElement('div')
|
|
||||||
dragHandle.classList.add('drag-handle')
|
|
||||||
dragHandle.setAttribute('draggable', 'true')
|
|
||||||
dragHandle.setAttribute('data-direction', type === 'col' ? 'horizontal' : 'vertical')
|
|
||||||
dragHandle.setAttribute('data-drag-handle', '')
|
|
||||||
Object.assign(dragHandle.style, {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-999px',
|
|
||||||
left: '-999px',
|
|
||||||
display: 'none',
|
|
||||||
})
|
|
||||||
return dragHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _showColDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
|
|
||||||
const referenceCell = editor.view.nodeDOM(hoveringCell.colFirstCellPos);
|
|
||||||
if (!referenceCell) return;
|
|
||||||
|
|
||||||
const yOffset = -1 * parseInt(getComputedStyle(this._colDragHandle).height) / 2;
|
|
||||||
|
|
||||||
computePosition(
|
|
||||||
referenceCell as HTMLElement,
|
|
||||||
this._colDragHandle,
|
|
||||||
{
|
|
||||||
placement: 'top',
|
|
||||||
middleware: [offset(yOffset)]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(({ x, y }) => {
|
|
||||||
Object.assign(this._colDragHandle.style, {
|
|
||||||
display: 'block',
|
|
||||||
top: `${y}px`,
|
|
||||||
left: `${x}px`,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private _showRowDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
|
|
||||||
const referenceCell = editor.view.nodeDOM(hoveringCell.rowFirstCellPos);
|
|
||||||
if (!referenceCell) return;
|
|
||||||
|
|
||||||
const xOffset = -1 * parseInt(getComputedStyle(this._rowDragHandle).width) / 2;
|
|
||||||
|
|
||||||
computePosition(
|
|
||||||
referenceCell as HTMLElement,
|
|
||||||
this._rowDragHandle,
|
|
||||||
{
|
|
||||||
middleware: [offset(xOffset)],
|
|
||||||
placement: 'left'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(({ x, y}) => {
|
|
||||||
Object.assign(this._rowDragHandle.style, {
|
|
||||||
display: 'block',
|
|
||||||
top: `${y}px`,
|
|
||||||
left: `${x}px`,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export class EmptyImageController {
|
|
||||||
private _emptyImage: HTMLImageElement;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._emptyImage = new Image(1, 1);
|
|
||||||
this._emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
||||||
}
|
|
||||||
|
|
||||||
get emptyImage() {
|
|
||||||
return this._emptyImage;
|
|
||||||
}
|
|
||||||
|
|
||||||
hideDragImage = (dataTransfer: DataTransfer) => {
|
|
||||||
dataTransfer.effectAllowed = 'move';
|
|
||||||
dataTransfer.setDragImage(this._emptyImage, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy = () => {
|
|
||||||
this._emptyImage.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +1,7 @@
|
|||||||
export * from './dnd-extension'
|
export {
|
||||||
|
TableDndExtension,
|
||||||
|
TableHandleCommandsExtension,
|
||||||
|
TableDndKey,
|
||||||
|
getTableHandlePluginSpec,
|
||||||
|
} from "./dnd-extension";
|
||||||
|
export type { TableHandleState, TableHandlePluginSpec } from "./dnd-extension";
|
||||||
|
|||||||
@@ -99,4 +99,4 @@ export class DropIndicatorController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computePosition, offset, ReferenceElement } from "@floating-ui/dom";
|
import { computePosition, offset, shift, ReferenceElement } from "@floating-ui/dom";
|
||||||
import { DraggingDOMs } from "../utils";
|
import { DraggingDOMs } from "../utils";
|
||||||
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
|
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export class PreviewController {
|
|||||||
onDragStart = (relatedDoms: DraggingDOMs, index: number | undefined, type: 'col' | 'row') => {
|
onDragStart = (relatedDoms: DraggingDOMs, index: number | undefined, type: 'col' | 'row') => {
|
||||||
this._initPreviewStyle(relatedDoms.table, relatedDoms.cell, type);
|
this._initPreviewStyle(relatedDoms.table, relatedDoms.cell, type);
|
||||||
createPreviewDOM(relatedDoms.table, this._preview, index, type)
|
createPreviewDOM(relatedDoms.table, this._preview, index, type)
|
||||||
this._initPreviewPosition(relatedDoms.cell, type);
|
this._initPreviewPosition(relatedDoms.table, relatedDoms.cell, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
onDragEnd = () => {
|
onDragEnd = () => {
|
||||||
@@ -32,7 +32,7 @@ export class PreviewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
|
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
|
||||||
this._updatePreviewPosition(x, y, relatedDoms.cell, type);
|
this._updatePreviewPosition(x, y, relatedDoms.table, relatedDoms.cell, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy = () => {
|
destroy = () => {
|
||||||
@@ -60,7 +60,7 @@ export class PreviewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _initPreviewPosition(cell: HTMLElement, type: 'col' | 'row') {
|
private _initPreviewPosition(table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
|
||||||
void computePosition(cell, this._preview, {
|
void computePosition(cell, this._preview, {
|
||||||
placement: type === 'row' ? 'right' : 'bottom',
|
placement: type === 'row' ? 'right' : 'bottom',
|
||||||
middleware: [
|
middleware: [
|
||||||
@@ -70,6 +70,7 @@ export class PreviewController {
|
|||||||
}
|
}
|
||||||
return -rects.reference.width
|
return -rects.reference.width
|
||||||
}),
|
}),
|
||||||
|
shift({ boundary: table, padding: 0 }),
|
||||||
],
|
],
|
||||||
}).then(({ x, y }) => {
|
}).then(({ x, y }) => {
|
||||||
Object.assign(this._preview.style, {
|
Object.assign(this._preview.style, {
|
||||||
@@ -79,11 +80,20 @@ export class PreviewController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updatePreviewPosition(x: number, y: number, cell: HTMLElement, type: 'col' | 'row') {
|
// Clamp the preview to within the table's bounds via `shift({ boundary })`
|
||||||
|
// so it can't track the cursor past the table edge. Without the clamp,
|
||||||
|
// dragging near the viewport edge pushes the preview's `left` (or `top`)
|
||||||
|
// beyond the document's natural width/height, the browser extends the
|
||||||
|
// page to contain it, and the auto-scroll plugin then has a wider area
|
||||||
|
// to keep scrolling into — a feedback loop that grows the page forever.
|
||||||
|
private _updatePreviewPosition(x: number, y: number, table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
|
||||||
computePosition(
|
computePosition(
|
||||||
getVirtualElement(cell, x, y),
|
getVirtualElement(cell, x, y),
|
||||||
this._preview,
|
this._preview,
|
||||||
{ placement: type === 'row' ? 'right' : 'bottom' },
|
{
|
||||||
|
placement: type === 'row' ? 'right' : 'bottom',
|
||||||
|
middleware: [shift({ boundary: table, padding: 0 })],
|
||||||
|
},
|
||||||
).then(({ x, y }) => {
|
).then(({ x, y }) => {
|
||||||
if (type === 'row') {
|
if (type === 'row') {
|
||||||
Object.assign(this._preview.style, {
|
Object.assign(this._preview.style, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cellAround, TableMap } from "@tiptap/pm/tables"
|
import { cellAround, TableMap } from "@tiptap/pm/tables"
|
||||||
|
import { ResolvedPos } from "@tiptap/pm/model"
|
||||||
import { EditorView } from "@tiptap/pm/view"
|
import { EditorView } from "@tiptap/pm/view"
|
||||||
|
|
||||||
export function getHoveringCell(
|
export function getHoveringCell(
|
||||||
@@ -8,19 +9,30 @@ export function getHoveringCell(
|
|||||||
const domCell = domCellAround(event.target as HTMLElement | null)
|
const domCell = domCellAround(event.target as HTMLElement | null)
|
||||||
if (!domCell) return
|
if (!domCell) return
|
||||||
|
|
||||||
const { left, top, width, height } = domCell.getBoundingClientRect()
|
// Resolve directly from the cell DOM rather than via coords. The previous
|
||||||
const eventPos = view.posAtCoords({
|
// center-coords approach broke on tall merged cells — their visual center
|
||||||
// Use the center coordinates of the cell to ensure we're within the
|
// can land in empty space whose closest PM position resolves to an
|
||||||
// selected cell. This prevents potential issues when the mouse is on the
|
// adjacent cell. `posAtDOM(td, 0)` is always inside this cell, regardless
|
||||||
// border of two cells.
|
// of rowspan/colspan.
|
||||||
left: left + width / 2,
|
let pos: number
|
||||||
top: top + height / 2,
|
try {
|
||||||
})
|
pos = view.posAtDOM(domCell, 0)
|
||||||
if (!eventPos) return
|
} catch {
|
||||||
|
return
|
||||||
const $cellPos = cellAround(view.state.doc.resolve(eventPos.pos))
|
}
|
||||||
|
const $cellPos = cellAround(view.state.doc.resolve(pos))
|
||||||
if (!$cellPos) return
|
if (!$cellPos) return
|
||||||
|
|
||||||
|
return cellInfoFromResolvedCell($cellPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HoveringCellInfo from a resolved position whose parent is a
|
||||||
|
* table cell (i.e. the result of `cellAround` on some inner position).
|
||||||
|
*/
|
||||||
|
export function cellInfoFromResolvedCell(
|
||||||
|
$cellPos: ResolvedPos,
|
||||||
|
): HoveringCellInfo {
|
||||||
const map = TableMap.get($cellPos.node(-1))
|
const map = TableMap.get($cellPos.node(-1))
|
||||||
const tableStart = $cellPos.start(-1)
|
const tableStart = $cellPos.start(-1)
|
||||||
const cellRect = map.findCell($cellPos.pos - tableStart)
|
const cellRect = map.findCell($cellPos.pos - tableStart)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Per-table header-pin controller: native sticky when table fits its wrapper, transform fallback when it doesn't.
|
||||||
|
|
||||||
|
import { computePinTop, pinOffsetWatcher } from './offset';
|
||||||
|
|
||||||
|
const WRAPPER_NO_OVERFLOW = 'tableWrapperNoOverflow';
|
||||||
|
const HEADER_PINNED = 'tableHeaderPinned';
|
||||||
|
const PIN_OFFSET_VAR = '--table-pin-offset';
|
||||||
|
|
||||||
|
type PinMode = 'off' | 'native' | 'fallback';
|
||||||
|
|
||||||
|
function firstRowIsAllHeaders(row: HTMLTableRowElement | null): boolean {
|
||||||
|
if (!row) return false;
|
||||||
|
const cells = Array.from(row.cells);
|
||||||
|
return cells.length > 0 && cells.every((c) => c.tagName === 'TH');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNestedTable(wrapper: HTMLElement): boolean {
|
||||||
|
return wrapper.closest('table .tableWrapper') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLayoutInert(rect: DOMRectReadOnly): boolean {
|
||||||
|
return rect.width === 0 && rect.height === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackControllers = new Set<TablePinController>();
|
||||||
|
let fallbackScrollListener: (() => void) | null = null;
|
||||||
|
let fallbackRafPending = false;
|
||||||
|
|
||||||
|
function ensureFallbackListener() {
|
||||||
|
if (fallbackScrollListener) return;
|
||||||
|
fallbackScrollListener = () => {
|
||||||
|
if (fallbackRafPending) return;
|
||||||
|
fallbackRafPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fallbackRafPending = false;
|
||||||
|
for (const ctrl of fallbackControllers) ctrl.updateFallbackOffset();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('scroll', fallbackScrollListener, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeTeardownFallbackListener() {
|
||||||
|
if (!fallbackScrollListener || fallbackControllers.size > 0) return;
|
||||||
|
document.removeEventListener('scroll', fallbackScrollListener, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
fallbackScrollListener = null;
|
||||||
|
fallbackRafPending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TablePinController {
|
||||||
|
private wrapper: HTMLElement;
|
||||||
|
private table: HTMLTableElement;
|
||||||
|
private fitsObserver?: IntersectionObserver;
|
||||||
|
private mode: PinMode = 'off';
|
||||||
|
private cachedHeaderRow: HTMLTableRowElement | null = null;
|
||||||
|
|
||||||
|
constructor(wrapper: HTMLElement, table: HTMLTableElement) {
|
||||||
|
this.wrapper = wrapper;
|
||||||
|
this.table = table;
|
||||||
|
pinOffsetWatcher.acquire();
|
||||||
|
this.fitsObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) this.evaluateFit(entry);
|
||||||
|
},
|
||||||
|
{ root: this.wrapper, threshold: 1 },
|
||||||
|
);
|
||||||
|
this.fitsObserver.observe(this.table);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaderRow(): HTMLTableRowElement | null {
|
||||||
|
if (this.cachedHeaderRow && this.table.contains(this.cachedHeaderRow)) {
|
||||||
|
return this.cachedHeaderRow;
|
||||||
|
}
|
||||||
|
this.cachedHeaderRow = this.table.querySelector('tr');
|
||||||
|
return this.cachedHeaderRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluateFit(entry: IntersectionObserverEntry) {
|
||||||
|
if (!this.isEligible()) {
|
||||||
|
this.apply('off');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isLayoutInert(entry.boundingClientRect)) return;
|
||||||
|
this.apply(entry.isIntersecting ? 'native' : 'fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isEligible(): boolean {
|
||||||
|
return (
|
||||||
|
!isNestedTable(this.wrapper) && firstRowIsAllHeaders(this.getHeaderRow())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(next: PinMode) {
|
||||||
|
if (next === this.mode) return;
|
||||||
|
|
||||||
|
if (this.mode === 'fallback' && next !== 'fallback') {
|
||||||
|
fallbackControllers.delete(this);
|
||||||
|
maybeTeardownFallbackListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mode = next;
|
||||||
|
const cls = this.wrapper.classList;
|
||||||
|
|
||||||
|
if (next === 'off') {
|
||||||
|
cls.remove(HEADER_PINNED);
|
||||||
|
cls.remove(WRAPPER_NO_OVERFLOW);
|
||||||
|
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
|
||||||
|
} else if (next === 'native') {
|
||||||
|
cls.add(HEADER_PINNED);
|
||||||
|
cls.add(WRAPPER_NO_OVERFLOW);
|
||||||
|
// Native mode reads --editor-pin-offset from :root; clear stale per-wrapper var from fallback.
|
||||||
|
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
|
||||||
|
} else if (next === 'fallback') {
|
||||||
|
cls.add(HEADER_PINNED);
|
||||||
|
cls.remove(WRAPPER_NO_OVERFLOW);
|
||||||
|
fallbackControllers.add(this);
|
||||||
|
ensureFallbackListener();
|
||||||
|
// Avoid one stale-frame paint under translateY.
|
||||||
|
this.updateFallbackOffset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFallbackOffset() {
|
||||||
|
const pinTop = computePinTop();
|
||||||
|
const tableRect = this.table.getBoundingClientRect();
|
||||||
|
const headerRow = this.getHeaderRow();
|
||||||
|
if (!headerRow) return;
|
||||||
|
const rowHeight = headerRow.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
const active = tableRect.top < pinTop && tableRect.bottom > pinTop + rowHeight;
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
const offset = Math.min(pinTop - tableRect.top, tableRect.height - rowHeight);
|
||||||
|
this.wrapper.style.setProperty(PIN_OFFSET_VAR, `${offset}px`);
|
||||||
|
} else {
|
||||||
|
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
// The header <tr> may have been replaced by a PM transaction; drop
|
||||||
|
// the cached reference before checking eligibility.
|
||||||
|
this.cachedHeaderRow = null;
|
||||||
|
if (!this.isEligible()) {
|
||||||
|
this.apply('off');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mode === 'off') {
|
||||||
|
// Eligibility just flipped back on; re-trigger the observer so it
|
||||||
|
// emits the current intersection state.
|
||||||
|
this.fitsObserver?.unobserve(this.table);
|
||||||
|
this.fitsObserver?.observe(this.table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.fitsObserver?.disconnect();
|
||||||
|
this.fitsObserver = undefined;
|
||||||
|
this.apply('off');
|
||||||
|
pinOffsetWatcher.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controllers = new WeakMap<HTMLElement, TablePinController>();
|
||||||
|
|
||||||
|
export function attach(wrapper: HTMLElement) {
|
||||||
|
if (controllers.has(wrapper)) return;
|
||||||
|
const table = wrapper.querySelector(':scope > table') as HTMLTableElement | null;
|
||||||
|
if (!table) return;
|
||||||
|
controllers.set(wrapper, new TablePinController(wrapper, table));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detach(wrapper: HTMLElement) {
|
||||||
|
const ctrl = controllers.get(wrapper);
|
||||||
|
if (!ctrl) return;
|
||||||
|
ctrl.destroy();
|
||||||
|
controllers.delete(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getController(wrapper: HTMLElement): TablePinController | undefined {
|
||||||
|
return controllers.get(wrapper);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
import { attach, detach, getController } from './controller';
|
||||||
|
|
||||||
|
const tableHeaderPinKey = new PluginKey('tableHeaderPin');
|
||||||
|
|
||||||
|
export const TableHeaderPin = Extension.create({
|
||||||
|
name: 'tableHeaderPin',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
let editorRoot: HTMLElement | null = null;
|
||||||
|
let domObserver: MutationObserver | null = null;
|
||||||
|
const tracked = new Set<HTMLElement>();
|
||||||
|
let rafHandle: number | null = null;
|
||||||
|
|
||||||
|
const reconcile = () => {
|
||||||
|
rafHandle = null;
|
||||||
|
if (!editorRoot) return;
|
||||||
|
const current = new Set(
|
||||||
|
editorRoot.querySelectorAll<HTMLElement>('.tableWrapper'),
|
||||||
|
);
|
||||||
|
for (const w of tracked) {
|
||||||
|
if (!current.has(w)) {
|
||||||
|
detach(w);
|
||||||
|
tracked.delete(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const w of current) {
|
||||||
|
if (!tracked.has(w)) {
|
||||||
|
attach(w);
|
||||||
|
tracked.add(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (rafHandle !== null) return;
|
||||||
|
rafHandle = requestAnimationFrame(reconcile);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: tableHeaderPinKey,
|
||||||
|
|
||||||
|
view(editorView) {
|
||||||
|
editorRoot = editorView.dom as HTMLElement;
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
|
||||||
|
domObserver = new MutationObserver(schedule);
|
||||||
|
domObserver.observe(editorRoot, { subtree: true, childList: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(view, prevState) {
|
||||||
|
if (!editorRoot) return;
|
||||||
|
if (view.state.doc === prevState.doc) return;
|
||||||
|
editorRoot
|
||||||
|
.querySelectorAll<HTMLElement>('.tableWrapper')
|
||||||
|
.forEach((w) => getController(w)?.refresh());
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (rafHandle !== null) {
|
||||||
|
cancelAnimationFrame(rafHandle);
|
||||||
|
rafHandle = null;
|
||||||
|
}
|
||||||
|
domObserver?.disconnect();
|
||||||
|
domObserver = null;
|
||||||
|
for (const w of tracked) detach(w);
|
||||||
|
tracked.clear();
|
||||||
|
editorRoot = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { TableHeaderPin } from './extension';
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Pin-offset measurement and watcher used by the table header-pin controller.
|
||||||
|
|
||||||
|
// Fallback app-bar height (px) when no fixed surface is mounted; matches global-app-shell.tsx.
|
||||||
|
const APP_BAR_FALLBACK_HEIGHT = 45;
|
||||||
|
|
||||||
|
export const EDITOR_PIN_OFFSET_VAR = '--editor-pin-offset';
|
||||||
|
|
||||||
|
// Selectors for fixed surfaces between viewport top and editor content. Use data attributes —
|
||||||
|
// CSS module classes are build-time hashed and won't match.
|
||||||
|
const PIN_ANCHOR_SELECTORS = [
|
||||||
|
'[data-page-header]',
|
||||||
|
'[data-fixed-toolbar]',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function computePinTop(): number {
|
||||||
|
let bottom = APP_BAR_FALLBACK_HEIGHT;
|
||||||
|
for (const sel of PIN_ANCHOR_SELECTORS) {
|
||||||
|
const el = document.querySelector(sel) as HTMLElement | null;
|
||||||
|
if (!el) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.height > 0 && rect.bottom > bottom) bottom = rect.bottom;
|
||||||
|
}
|
||||||
|
return bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference-counted watcher that publishes the editor's top offset to a CSS custom property.
|
||||||
|
export const pinOffsetWatcher = {
|
||||||
|
refs: 0,
|
||||||
|
resizeObserver: null as ResizeObserver | null,
|
||||||
|
rafPending: false,
|
||||||
|
lastValue: -1,
|
||||||
|
|
||||||
|
acquire() {
|
||||||
|
if (this.refs++ > 0) return;
|
||||||
|
this.publish();
|
||||||
|
const schedule = () => {
|
||||||
|
if (this.rafPending) return;
|
||||||
|
this.rafPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.rafPending = false;
|
||||||
|
this.publish();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.resizeObserver = new ResizeObserver(schedule);
|
||||||
|
this.resizeObserver.observe(document.body);
|
||||||
|
},
|
||||||
|
|
||||||
|
release() {
|
||||||
|
if (--this.refs > 0) return;
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
|
document.documentElement.style.removeProperty(EDITOR_PIN_OFFSET_VAR);
|
||||||
|
this.lastValue = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
publish() {
|
||||||
|
const top = computePinTop();
|
||||||
|
if (top === this.lastValue) return;
|
||||||
|
this.lastValue = top;
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
EDITOR_PIN_OFFSET_VAR,
|
||||||
|
`${top}px`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,4 +2,14 @@ export * from "./row";
|
|||||||
export * from "./cell";
|
export * from "./cell";
|
||||||
export * from "./header";
|
export * from "./header";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
export * from "./dnd";
|
export * from "./dnd";
|
||||||
|
export * from "./table-view";
|
||||||
|
export * from "./header-pin";
|
||||||
|
export * from "./table-readonly-sort";
|
||||||
|
export { moveColumn } from "./utils/move-column";
|
||||||
|
export type { MoveColumnParams } from "./utils/move-column";
|
||||||
|
export { moveRow } from "./utils/move-row";
|
||||||
|
export type { MoveRowParams } from "./utils/move-row";
|
||||||
|
export { convertTableNodeToArrayOfRows } from "./utils/convert-table-node-to-array-of-rows";
|
||||||
|
export { convertArrayOfRowsToTableNode } from "./utils/convert-array-of-rows-to-table-node";
|
||||||
|
export { transpose } from "./utils/transpose";
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
type SortState = {
|
||||||
|
col: number;
|
||||||
|
direction: SortDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHEVRON_CLASS = 'tableReadonlySortChevron';
|
||||||
|
|
||||||
|
const tableReadonlySortKey = new PluginKey('tableReadonlySort');
|
||||||
|
|
||||||
|
const sortStates = new WeakMap<HTMLTableElement, SortState>();
|
||||||
|
const originalOrders = new WeakMap<HTMLTableElement, HTMLTableRowElement[]>();
|
||||||
|
|
||||||
|
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
|
||||||
|
function getColumnIndex(th: HTMLTableCellElement): number {
|
||||||
|
const row = th.parentElement as HTMLTableRowElement;
|
||||||
|
if (!row) return -1;
|
||||||
|
let col = 0;
|
||||||
|
for (let i = 0; i < row.cells.length; i++) {
|
||||||
|
if (row.cells[i] === th) return col;
|
||||||
|
col += row.cells[i].colSpan ?? 1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaderTh(target: EventTarget | null): HTMLTableCellElement | null {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
const th = target.closest('th') as HTMLTableCellElement | null;
|
||||||
|
if (!th) return null;
|
||||||
|
const row = th.parentElement;
|
||||||
|
if (!row) return null;
|
||||||
|
const tbody = row.parentElement;
|
||||||
|
if (!tbody) return null;
|
||||||
|
const table = tbody.closest('table');
|
||||||
|
if (!table) return null;
|
||||||
|
|
||||||
|
// th must be in the first row of the table (could be in thead or tbody)
|
||||||
|
const firstRow = table.querySelector('tr');
|
||||||
|
if (firstRow !== row) return null;
|
||||||
|
|
||||||
|
return th;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellText(row: HTMLTableRowElement, colIndex: number): string {
|
||||||
|
let col = 0;
|
||||||
|
for (let i = 0; i < row.cells.length; i++) {
|
||||||
|
if (col === colIndex) return row.cells[i].textContent?.trim() ?? '';
|
||||||
|
col += row.cells[i].colSpan ?? 1;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrSaveOriginalOrder(
|
||||||
|
table: HTMLTableElement,
|
||||||
|
dataRows: HTMLTableRowElement[],
|
||||||
|
): HTMLTableRowElement[] {
|
||||||
|
if (!originalOrders.has(table)) {
|
||||||
|
originalOrders.set(table, [...dataRows]);
|
||||||
|
}
|
||||||
|
return originalOrders.get(table)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDataRows(
|
||||||
|
dataRows: HTMLTableRowElement[],
|
||||||
|
colIndex: number,
|
||||||
|
direction: SortDirection,
|
||||||
|
): HTMLTableRowElement[] {
|
||||||
|
return [...dataRows].sort((a, b) => {
|
||||||
|
const textA = getCellText(a, colIndex);
|
||||||
|
const textB = getCellText(b, colIndex);
|
||||||
|
const emptyA = textA === '';
|
||||||
|
const emptyB = textB === '';
|
||||||
|
if (emptyA && emptyB) return 0;
|
||||||
|
if (emptyA) return 1;
|
||||||
|
if (emptyB) return -1;
|
||||||
|
const cmp = collator.compare(textA, textB);
|
||||||
|
return direction === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySort(table: HTMLTableElement, colIndex: number): void {
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const allRows = Array.from(tbody.querySelectorAll<HTMLTableRowElement>(':scope > tr'));
|
||||||
|
if (allRows.length === 0) return;
|
||||||
|
|
||||||
|
const headerRow = allRows[0];
|
||||||
|
const dataRows = allRows.slice(1);
|
||||||
|
if (dataRows.length === 0) return;
|
||||||
|
|
||||||
|
const current = sortStates.get(table) ?? null;
|
||||||
|
const saved = getOrSaveOriginalOrder(table, dataRows);
|
||||||
|
|
||||||
|
let next: SortState | null;
|
||||||
|
if (!current || current.col !== colIndex) {
|
||||||
|
next = { col: colIndex, direction: 'asc' };
|
||||||
|
} else if (current.direction === 'asc') {
|
||||||
|
next = { col: colIndex, direction: 'desc' };
|
||||||
|
} else {
|
||||||
|
next = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === null) {
|
||||||
|
sortStates.delete(table);
|
||||||
|
tbody.append(headerRow, ...saved);
|
||||||
|
} else {
|
||||||
|
sortStates.set(table, next);
|
||||||
|
const sorted = sortDataRows(saved, next.col, next.direction);
|
||||||
|
tbody.append(headerRow, ...sorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChevrons(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHEVRON_SVG =
|
||||||
|
'<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">' +
|
||||||
|
'<path d="M2.5 4.5 L6 8 L9.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />' +
|
||||||
|
'</svg>';
|
||||||
|
|
||||||
|
function ensureChevron(th: HTMLTableCellElement): HTMLSpanElement {
|
||||||
|
let chevron = th.querySelector<HTMLSpanElement>(`.${CHEVRON_CLASS}`);
|
||||||
|
if (!chevron) {
|
||||||
|
chevron = document.createElement('span');
|
||||||
|
chevron.className = CHEVRON_CLASS;
|
||||||
|
chevron.setAttribute('aria-hidden', 'true');
|
||||||
|
chevron.innerHTML = CHEVRON_SVG;
|
||||||
|
th.appendChild(chevron);
|
||||||
|
}
|
||||||
|
return chevron;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChevrons(table: HTMLTableElement): void {
|
||||||
|
const firstRow = table.querySelector('tr');
|
||||||
|
if (!firstRow) return;
|
||||||
|
|
||||||
|
const state = sortStates.get(table) ?? null;
|
||||||
|
let col = 0;
|
||||||
|
for (let i = 0; i < firstRow.cells.length; i++) {
|
||||||
|
const cell = firstRow.cells[i];
|
||||||
|
if (cell.tagName !== 'TH') {
|
||||||
|
col += cell.colSpan ?? 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const chevron = ensureChevron(cell as HTMLTableCellElement);
|
||||||
|
let label: string;
|
||||||
|
if (state && state.col === col) {
|
||||||
|
chevron.setAttribute('data-sort', state.direction);
|
||||||
|
label = state.direction === 'asc' ? 'Sort descending' : 'Clear sort';
|
||||||
|
} else {
|
||||||
|
chevron.removeAttribute('data-sort');
|
||||||
|
label = 'Sort ascending';
|
||||||
|
}
|
||||||
|
chevron.setAttribute('data-tooltip', label);
|
||||||
|
chevron.setAttribute('aria-label', label);
|
||||||
|
chevron.title = label;
|
||||||
|
col += cell.colSpan ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChevronsToAllTables(editorRoot: HTMLElement): void {
|
||||||
|
const tables = editorRoot.querySelectorAll<HTMLTableElement>('table');
|
||||||
|
tables.forEach((table) => updateChevrons(table));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllChevrons(editorRoot: HTMLElement): void {
|
||||||
|
editorRoot
|
||||||
|
.querySelectorAll<HTMLSpanElement>(`.${CHEVRON_CLASS}`)
|
||||||
|
.forEach((el) => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableReadonlySort = Extension.create({
|
||||||
|
name: 'tableReadonlySort',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const editor = this.editor;
|
||||||
|
let editorRoot: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const onClick = (event: MouseEvent) => {
|
||||||
|
if (editor.isEditable) return;
|
||||||
|
// Only react to clicks on the chevron, not anywhere else in the header
|
||||||
|
// cell. This lets the user click into a header to select text without
|
||||||
|
// accidentally triggering a sort.
|
||||||
|
if (!(event.target instanceof Element)) return;
|
||||||
|
const chevron = event.target.closest(`.${CHEVRON_CLASS}`);
|
||||||
|
if (!chevron) return;
|
||||||
|
const th = getHeaderTh(chevron);
|
||||||
|
if (!th) return;
|
||||||
|
const table = th.closest('table') as HTMLTableElement | null;
|
||||||
|
if (!table) return;
|
||||||
|
const colIndex = getColumnIndex(th);
|
||||||
|
if (colIndex < 0) return;
|
||||||
|
applySort(table, colIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: tableReadonlySortKey,
|
||||||
|
|
||||||
|
view(editorView) {
|
||||||
|
editorRoot = editorView.dom as HTMLElement;
|
||||||
|
editorRoot.addEventListener('click', onClick);
|
||||||
|
|
||||||
|
if (!editor.isEditable) {
|
||||||
|
addChevronsToAllTables(editorRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(view) {
|
||||||
|
const root = view.dom as HTMLElement;
|
||||||
|
if (!editor.isEditable) {
|
||||||
|
addChevronsToAllTables(root);
|
||||||
|
} else {
|
||||||
|
removeAllChevrons(root);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (editorRoot) {
|
||||||
|
editorRoot.removeEventListener('click', onClick);
|
||||||
|
removeAllChevrons(editorRoot);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
|
import type { NodeView, ViewMutationRecord } from '@tiptap/pm/view';
|
||||||
|
import { getColStyleDeclaration } from './utils/col-style';
|
||||||
|
|
||||||
|
export function updateColumns(
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
colgroup: HTMLElement,
|
||||||
|
table: HTMLTableElement,
|
||||||
|
cellMinWidth: number,
|
||||||
|
overrideCol?: number,
|
||||||
|
overrideValue?: number,
|
||||||
|
) {
|
||||||
|
let totalWidth = 0;
|
||||||
|
let fixedWidth = true;
|
||||||
|
let nextDOM = colgroup.firstChild;
|
||||||
|
const row = node.firstChild;
|
||||||
|
|
||||||
|
if (row !== null) {
|
||||||
|
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||||
|
const { colspan, colwidth } = row.child(i).attrs;
|
||||||
|
|
||||||
|
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||||
|
const hasWidth =
|
||||||
|
overrideCol === col
|
||||||
|
? overrideValue
|
||||||
|
: ((colwidth && colwidth[j]) as number | undefined);
|
||||||
|
const cssWidth = hasWidth ? `${hasWidth}px` : '';
|
||||||
|
|
||||||
|
totalWidth += hasWidth || cellMinWidth;
|
||||||
|
|
||||||
|
if (!hasWidth) {
|
||||||
|
fixedWidth = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextDOM) {
|
||||||
|
const colElement = document.createElement('col');
|
||||||
|
|
||||||
|
const [propertyKey, propertyValue] = getColStyleDeclaration(
|
||||||
|
cellMinWidth,
|
||||||
|
hasWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
colElement.style.setProperty(propertyKey, propertyValue);
|
||||||
|
|
||||||
|
colgroup.appendChild(colElement);
|
||||||
|
} else {
|
||||||
|
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
|
||||||
|
const [propertyKey, propertyValue] = getColStyleDeclaration(
|
||||||
|
cellMinWidth,
|
||||||
|
hasWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
(nextDOM as HTMLTableColElement).style.setProperty(
|
||||||
|
propertyKey,
|
||||||
|
propertyValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextDOM = nextDOM.nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (nextDOM) {
|
||||||
|
const after = nextDOM.nextSibling;
|
||||||
|
|
||||||
|
nextDOM.parentNode?.removeChild(nextDOM);
|
||||||
|
nextDOM = after;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUserWidth =
|
||||||
|
node.attrs.style &&
|
||||||
|
typeof node.attrs.style === 'string' &&
|
||||||
|
/\bwidth\s*:/i.test(node.attrs.style);
|
||||||
|
|
||||||
|
if (fixedWidth && !hasUserWidth) {
|
||||||
|
table.style.width = `${totalWidth}px`;
|
||||||
|
table.style.minWidth = '';
|
||||||
|
} else {
|
||||||
|
table.style.width = '';
|
||||||
|
table.style.minWidth = `${totalWidth}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TableView implements NodeView {
|
||||||
|
node: ProseMirrorNode;
|
||||||
|
|
||||||
|
cellMinWidth: number;
|
||||||
|
|
||||||
|
dom: HTMLDivElement;
|
||||||
|
|
||||||
|
table: HTMLTableElement;
|
||||||
|
|
||||||
|
colgroup: HTMLTableColElement;
|
||||||
|
|
||||||
|
contentDOM: HTMLTableSectionElement;
|
||||||
|
|
||||||
|
constructor(node: ProseMirrorNode, cellMinWidth: number) {
|
||||||
|
this.node = node;
|
||||||
|
this.cellMinWidth = cellMinWidth;
|
||||||
|
this.dom = document.createElement('div');
|
||||||
|
this.dom.className = 'tableWrapper';
|
||||||
|
this.table = this.dom.appendChild(document.createElement('table'));
|
||||||
|
|
||||||
|
if (node.attrs.style) {
|
||||||
|
this.table.style.cssText = node.attrs.style;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
|
||||||
|
updateColumns(node, this.colgroup, this.table, cellMinWidth);
|
||||||
|
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(node: ProseMirrorNode) {
|
||||||
|
if (node.type !== this.node.type) return false;
|
||||||
|
|
||||||
|
this.node = node;
|
||||||
|
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreMutation(mutation: ViewMutationRecord) {
|
||||||
|
const target = mutation.target as Node;
|
||||||
|
const isInsideWrapper = this.dom.contains(target);
|
||||||
|
const isInsideContent = this.contentDOM.contains(target);
|
||||||
|
|
||||||
|
if (isInsideWrapper && !isInsideContent) {
|
||||||
|
if (
|
||||||
|
mutation.type === 'attributes' ||
|
||||||
|
mutation.type === 'childList' ||
|
||||||
|
mutation.type === 'characterData'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chevron span (.tableReadonlySortChevron) added/removed by sort plugin.
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
const nodes = [
|
||||||
|
...Array.from(mutation.addedNodes),
|
||||||
|
...Array.from(mutation.removedNodes),
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
nodes.some(
|
||||||
|
(n) =>
|
||||||
|
n instanceof Element &&
|
||||||
|
n.classList.contains('tableReadonlySortChevron'),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Table } from "@tiptap/extension-table";
|
import { Table } from "@tiptap/extension-table";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { DOMOutputSpec } from "@tiptap/pm/model";
|
import { DOMOutputSpec } from "@tiptap/pm/model";
|
||||||
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
|
import { cellAround } from "@tiptap/pm/tables";
|
||||||
|
|
||||||
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
|
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
|
||||||
|
|
||||||
@@ -32,9 +34,36 @@ function handleListOutdent(editor: Editor): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CustomTable = Table.extend({
|
export const CustomTable = Table.extend({
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
|
"Mod-a": () => {
|
||||||
|
const { state, view } = this.editor;
|
||||||
|
const { selection, doc } = state;
|
||||||
|
|
||||||
|
const $cellPos = cellAround(selection.$anchor);
|
||||||
|
if (!$cellPos) return false;
|
||||||
|
|
||||||
|
const cellNode = doc.nodeAt($cellPos.pos);
|
||||||
|
// Empty cells have nothing useful to scope to — let the default
|
||||||
|
// Mod-a fall through and select the whole doc.
|
||||||
|
if (!cellNode || !cellNode.textContent) return false;
|
||||||
|
|
||||||
|
const from = $cellPos.pos + 1;
|
||||||
|
const to = $cellPos.pos + cellNode.nodeSize - 1;
|
||||||
|
if (from >= to) return true;
|
||||||
|
|
||||||
|
const nextSel = TextSelection.between(
|
||||||
|
doc.resolve(from),
|
||||||
|
doc.resolve(to),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (!nextSel || selection.eq(nextSel)) return true;
|
||||||
|
|
||||||
|
view.dispatch(state.tr.setSelection(nextSel));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
Tab: () => {
|
Tab: () => {
|
||||||
// If we're in a list within a table, handle list indentation
|
// If we're in a list within a table, handle list indentation
|
||||||
if (isInList(this.editor) && this.editor.isActive("table")) {
|
if (isInList(this.editor) && this.editor.isActive("table")) {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
|
||||||
|
if (width) {
|
||||||
|
return ['width', `${Math.max(width, minWidth)}px`]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['min-width', `${minWidth}px`]
|
||||||
|
}
|
||||||
Generated
+33
@@ -250,6 +250,12 @@ importers:
|
|||||||
|
|
||||||
apps/client:
|
apps/client:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@atlaskit/pragmatic-drag-and-drop':
|
||||||
|
specifier: ^1.8.1
|
||||||
|
version: 1.8.1
|
||||||
|
'@atlaskit/pragmatic-drag-and-drop-auto-scroll':
|
||||||
|
specifier: ^2.1.5
|
||||||
|
version: 2.1.5
|
||||||
'@casl/react':
|
'@casl/react':
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
||||||
@@ -909,6 +915,12 @@ packages:
|
|||||||
'@asamuzakjp/css-color@2.8.3':
|
'@asamuzakjp/css-color@2.8.3':
|
||||||
resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==}
|
resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==}
|
||||||
|
|
||||||
|
'@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5':
|
||||||
|
resolution: {integrity: sha512-InLvVhZAHPBfv3CxuG4AfOQuhNJjaFy69YBfodPMWtRFQNQAKa9Yb3vL9Ho6qsD9qKUBuJa4A5k7QddaXQ4Eyw==}
|
||||||
|
|
||||||
|
'@atlaskit/pragmatic-drag-and-drop@1.8.1':
|
||||||
|
resolution: {integrity: sha512-uXWNPpL8n4OmTVbduH7nq8pk8htqGo/prR5cYEE8sVCPJGAUMWn6lzvWTfI+4VCeQvHiDRODVz4YzH06OVAxhw==}
|
||||||
|
|
||||||
'@aws-crypto/crc32@5.2.0':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -5540,6 +5552,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
bind-event-listener@3.0.0:
|
||||||
|
resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==}
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
@@ -8938,6 +8953,9 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
raf-schd@4.0.3:
|
||||||
|
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
|
||||||
|
|
||||||
range-parser@1.2.1:
|
range-parser@1.2.1:
|
||||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -10557,6 +10575,17 @@ snapshots:
|
|||||||
'@csstools/css-tokenizer': 3.0.3
|
'@csstools/css-tokenizer': 3.0.3
|
||||||
lru-cache: 10.4.3
|
lru-cache: 10.4.3
|
||||||
|
|
||||||
|
'@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5':
|
||||||
|
dependencies:
|
||||||
|
'@atlaskit/pragmatic-drag-and-drop': 1.8.1
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
|
||||||
|
'@atlaskit/pragmatic-drag-and-drop@1.8.1':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
bind-event-listener: 3.0.0
|
||||||
|
raf-schd: 4.0.3
|
||||||
|
|
||||||
'@aws-crypto/crc32@5.2.0':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/util': 5.2.0
|
'@aws-crypto/util': 5.2.0
|
||||||
@@ -15978,6 +16007,8 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
bind-event-listener@3.0.0: {}
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer: 5.7.1
|
buffer: 5.7.1
|
||||||
@@ -19987,6 +20018,8 @@ snapshots:
|
|||||||
'@types/react': 18.3.12
|
'@types/react': 18.3.12
|
||||||
'@types/react-dom': 18.3.1
|
'@types/react-dom': 18.3.1
|
||||||
|
|
||||||
|
raf-schd@4.0.3: {}
|
||||||
|
|
||||||
range-parser@1.2.1: {}
|
range-parser@1.2.1: {}
|
||||||
|
|
||||||
raw-body@3.0.2:
|
raw-body@3.0.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user