Add plain tables to editor (#180)

This commit is contained in:
Hakan Shehu
2025-08-08 11:19:19 +02:00
committed by GitHub
parent fd675d7d9b
commit 766952b31e
36 changed files with 1430 additions and 169 deletions

15
package-lock.json generated
View File

@@ -9561,6 +9561,20 @@
"@tiptap/core": "^3.0.9"
}
},
"node_modules/@tiptap/extension-table": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.0.9.tgz",
"integrity": "sha512-jygDvj9MIwMlzs2c+4MZwXCXI6sc7LcKgPFoJ93qiCn6CZrDwaX3XzxXi0VAg7MexsUi1nVaGZQk/gv+Pf3rKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.9",
"@tiptap/pm": "^3.0.9"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.0.9.tgz",
@@ -24893,6 +24907,7 @@
"@tiptap/extension-list": "^3.0.9",
"@tiptap/extension-paragraph": "^3.0.9",
"@tiptap/extension-strike": "^3.0.9",
"@tiptap/extension-table": "^3.0.9",
"@tiptap/extension-text": "^3.0.9",
"@tiptap/extension-underline": "^3.0.9",
"@tiptap/extensions": "^3.0.9",

View File

@@ -1,6 +1,8 @@
import { Editor, JSONContent } from '@tiptap/core';
import { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model';
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
import { TableMap } from '@tiptap/pm/tables';
import { EditorView } from '@tiptap/pm/view';
import {
Block,
@@ -13,6 +15,14 @@ import {
RichTextContent,
} from '@colanode/core';
interface TableCellAttrs {
colspan: number;
rowspan: number;
colwidth: number[] | null;
align?: 'left' | 'center' | 'right';
backgroundColor?: string;
}
const leafBlockTypes = new Set([
EditorNodeTypes.Paragraph,
EditorNodeTypes.Heading1,
@@ -430,3 +440,84 @@ export const restoreRelativeSelection = (
view.dispatch(tr);
}
};
export const updateColumnWidth = (
view: EditorView,
cell: number,
width: number
): void => {
const $cell = view.state.doc.resolve(cell);
const table = $cell.node(-1);
const map = TableMap.get(table);
const start = $cell.start(-1);
const col =
map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan - 1;
const tr = view.state.tr;
for (let row = 0; row < map.height; row++) {
const mapIndex = row * map.width + col;
// Rowspanning cell that has already been handled
if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) {
continue;
}
const pos = map.map[mapIndex];
if (!pos) {
continue;
}
const attrs = table.nodeAt(pos)!.attrs as TableCellAttrs;
const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos);
if (attrs.colwidth && attrs.colwidth[index] == width) {
continue;
}
const colwidth = attrs.colwidth
? attrs.colwidth.slice()
: Array(attrs.colspan).fill(0);
colwidth[index] = width;
tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth });
}
if (tr.docChanged) {
view.dispatch(tr);
}
};
export const isDescendantNode = (
ancestor: ProseMirrorNode,
candidate: ProseMirrorNode
): boolean => {
if (ancestor === candidate) return false;
let found = false;
ancestor.descendants((node) => {
if (node === candidate) {
found = true;
return false; // break out early
}
return !found;
});
return found;
};
export const findClosestNodeAtPos = (
doc: ProseMirrorNode,
pos: number
): ProseMirrorNode | null => {
let currentPos = pos;
while (currentPos >= 0) {
const node = doc.nodeAt(currentPos);
if (node) {
return node;
}
currentPos--;
}
return null;
};

View File

@@ -15,6 +15,10 @@ export const EditorNodeTypes = {
File: 'file',
Folder: 'folder',
TempFile: 'tempFile',
Table: 'table',
TableHeader: 'tableHeader',
TableCell: 'tableCell',
TableRow: 'tableRow',
};
export type SortDirection = 'asc' | 'desc';

View File

@@ -59,6 +59,7 @@
"@tiptap/extension-list": "^3.0.9",
"@tiptap/extension-paragraph": "^3.0.9",
"@tiptap/extension-strike": "^3.0.9",
"@tiptap/extension-table": "^3.0.9",
"@tiptap/extension-text": "^3.0.9",
"@tiptap/extension-underline": "^3.0.9",
"@tiptap/extensions": "^3.0.9",

View File

@@ -37,6 +37,7 @@ import {
OrderedListCommand,
PageCommand,
ParagraphCommand,
TableCommand,
TodoCommand,
DatabaseCommand,
DatabaseInlineCommand,
@@ -70,6 +71,10 @@ import {
PlaceholderExtension,
StrikethroughMark,
TabKeymapExtension,
TableNode,
TableRowNode,
TableHeaderNode,
TableCellNode,
TaskItemNode,
TaskListNode,
TextNode,
@@ -196,6 +201,10 @@ export const DocumentEditor = ({
}),
TaskListNode,
TaskItemNode,
TableNode,
TableRowNode,
TableCellNode,
TableHeaderNode,
DividerNode,
TrailingNode,
LinkMark,
@@ -214,6 +223,7 @@ export const DocumentEditor = ({
BulletListCommand,
CodeBlockCommand,
OrderedListCommand,
TableCommand,
DatabaseInlineCommand,
DatabaseCommand,
DividerCommand,
@@ -388,7 +398,7 @@ export const DocumentEditor = ({
}, [node.id, editor]);
return (
<div className="min-h-[500px]">
<>
{editor && canEdit && (
<Fragment>
<ToolbarMenu editor={editor} />
@@ -396,6 +406,6 @@ export const DocumentEditor = ({
</Fragment>
)}
<EditorContent editor={editor} />
</div>
</>
);
};

View File

@@ -1,7 +1,9 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { LocalPageNode } from '@colanode/client/types';
import { NodeRole, hasNodeRole } from '@colanode/core';
import { Document } from '@colanode/ui/components/documents/document';
import { ScrollArea } from '@colanode/ui/components/ui/scroll-area';
import { ScrollBar } from '@colanode/ui/components/ui/scroll-area';
interface PageBodyProps {
page: LocalPageNode;
@@ -12,8 +14,15 @@ export const PageBody = ({ page, role }: PageBodyProps) => {
const canEdit = hasNodeRole(role, 'editor');
return (
<ScrollArea className="h-full max-h-full w-full overflow-y-auto">
<div className="h-full w-full overflow-y-auto">
<ScrollAreaPrimitive.Root className="relative overflow-hidden h-full">
<ScrollAreaPrimitive.Viewport className="h-full max-h-[calc(100vh-100px)] w-full overflow-y-auto rounded-[inherit]">
<Document node={page} canEdit={canEdit} autoFocus="start" />
</ScrollArea>
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</div>
);
};

View File

@@ -1,10 +1,12 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { LocalRecordNode } from '@colanode/client/types';
import { NodeRole, hasNodeRole } from '@colanode/core';
import { Document } from '@colanode/ui/components/documents/document';
import { RecordAttributes } from '@colanode/ui/components/records/record-attributes';
import { RecordDatabase } from '@colanode/ui/components/records/record-database';
import { RecordProvider } from '@colanode/ui/components/records/record-provider';
import { ScrollArea } from '@colanode/ui/components/ui/scroll-area';
import { ScrollBar } from '@colanode/ui/components/ui/scroll-area';
import { Separator } from '@colanode/ui/components/ui/separator';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
@@ -20,13 +22,20 @@ export const RecordBody = ({ record, role }: RecordBodyProps) => {
record.createdBy === workspace.userId || hasNodeRole(role, 'editor');
return (
<RecordDatabase id={record.attributes.databaseId} role={role}>
<ScrollArea className="h-full max-h-full w-full overflow-y-auto">
<div className="h-full w-full overflow-y-auto">
<ScrollAreaPrimitive.Root className="relative overflow-hidden h-full">
<ScrollAreaPrimitive.Viewport className="h-full max-h-[calc(100vh-100px)] w-full overflow-y-auto rounded-[inherit]">
<RecordProvider record={record} role={role}>
<RecordAttributes />
</RecordProvider>
<Separator className="my-4 w-full" />
<Document node={record} canEdit={canEdit} autoFocus={false} />
</ScrollArea>
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</div>
</RecordDatabase>
);
};

View File

@@ -15,13 +15,15 @@ export const defaultClasses = {
taskList: 'not-prose pl-2',
taskItem: 'flex items-start my-1',
link: 'font-medium underline underline-offset-4 cursor-pointer',
table: 'border-collapse border border-gray-300 w-full',
tableRow: 'min-w-full',
tableHeader: 'border p-1 px-2 text-left font-semibold bg-gray-50',
tableCell: 'border p-1 px-2',
gif: 'max-h-72 my-1',
emoji: 'max-h-5 max-w-5 h-5 w-5 px-0.5 mb-1 inline-block',
dropcursor: 'text-primary-foreground bg-blue-500',
mention:
'inline-flex flex-row items-center gap-1 rounded-md bg-blue-50 px-0.5 py-0',
table: 'w-full',
tableRow: 'min-w-full',
tableCellWrapper: 'border',
tableCell: 'flex p-1 px-2 items-center',
tableHeaderWrapper: 'border',
tableHeader: 'flex p-1 px-2 items-center font-semibold bg-gray-50',
};

View File

@@ -13,6 +13,7 @@ import { Heading3Command } from '@colanode/ui/editor/commands/heading3';
import { OrderedListCommand } from '@colanode/ui/editor/commands/ordered-list';
import { PageCommand } from '@colanode/ui/editor/commands/page';
import { ParagraphCommand } from '@colanode/ui/editor/commands/paragraph';
import { TableCommand } from '@colanode/ui/editor/commands/table';
import { TodoCommand } from '@colanode/ui/editor/commands/todo';
export type { EditorCommand, EditorCommandProps };
@@ -30,6 +31,7 @@ export {
OrderedListCommand,
PageCommand,
ParagraphCommand,
TableCommand,
TodoCommand,
DatabaseCommand,
DatabaseInlineCommand,

View File

@@ -0,0 +1,20 @@
import { Table } from 'lucide-react';
import { EditorCommand } from '@colanode/client/types';
export const TableCommand: EditorCommand = {
key: 'table',
name: 'Table',
description: 'Insert a table',
keywords: ['table', 'grid', 'rows', 'columns'],
icon: Table,
disabled: false,
async handler({ editor, range }) {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: false })
.run();
},
};

View File

@@ -1,5 +1,7 @@
import { Mark } from '@tiptap/core';
import { editorColors } from '@colanode/ui/lib/editor';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
color: {
@@ -36,9 +38,14 @@ export const ColorMark = Mark.create({
return {};
}
const value = attributes.color;
const color = editorColors.find((editorColor) => {
return editorColor.color === value;
});
return {
'data-color': attributes.color,
class: `text-${attributes.color}-600`,
class: color?.textClass,
};
},
},

View File

@@ -1,5 +1,7 @@
import { Mark } from '@tiptap/core';
import { editorColors } from '@colanode/ui/lib/editor';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
highlight: {
@@ -36,9 +38,14 @@ export const HighlightMark = Mark.create({
return {};
}
const value = attributes.highlight;
const color = editorColors.find((editorColor) => {
return editorColor.color === value;
});
return {
'data-highlight': attributes.highlight,
class: `bg-${attributes.highlight}-200`,
class: color?.bgClass,
};
},
},

View File

@@ -16,6 +16,10 @@ const types = [
EditorNodeTypes.TaskItem,
EditorNodeTypes.CodeBlock,
EditorNodeTypes.HorizontalRule,
EditorNodeTypes.Table,
EditorNodeTypes.TableHeader,
EditorNodeTypes.TableCell,
EditorNodeTypes.TableRow,
];
export const IdExtension = Extension.create({

View File

@@ -34,6 +34,10 @@ import { PageNode } from '@colanode/ui/editor/extensions/page';
import { ParagraphNode } from '@colanode/ui/editor/extensions/paragraph';
import { PlaceholderExtension } from '@colanode/ui/editor/extensions/placeholder';
import { TabKeymapExtension } from '@colanode/ui/editor/extensions/tab-keymap';
import { TableNode } from '@colanode/ui/editor/extensions/table';
import { TableCellNode } from '@colanode/ui/editor/extensions/table-cell';
import { TableHeaderNode } from '@colanode/ui/editor/extensions/table-header';
import { TableRowNode } from '@colanode/ui/editor/extensions/table-row';
import { TaskItemNode } from '@colanode/ui/editor/extensions/task-item';
import { TaskListNode } from '@colanode/ui/editor/extensions/task-list';
import { TempFileNode } from '@colanode/ui/editor/extensions/temp-file';
@@ -70,6 +74,10 @@ export {
PlaceholderExtension,
StrikethroughMark,
TabKeymapExtension,
TableNode,
TableRowNode,
TableHeaderNode,
TableCellNode,
TaskItemNode,
TaskListNode,
TextNode,

View File

@@ -0,0 +1,44 @@
import { TableCell } from '@tiptap/extension-table/cell';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { TableCellNodeView } from '@colanode/ui/editor/views';
export const TableCellNode = TableCell.extend({
addNodeView() {
return ReactNodeViewRenderer(TableCellNodeView, {
as: 'td',
className: defaultClasses.tableCellWrapper,
});
},
addAttributes() {
return {
colspan: {
default: 1,
},
rowspan: {
default: 1,
},
colwidth: {
default: null,
parseHTML: (element: HTMLElement) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth
? colwidth.split(',').map((width: string) => parseInt(width, 10))
: null;
return value;
},
},
align: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-align'),
},
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-background-color'),
},
};
},
});

View File

@@ -0,0 +1,44 @@
import { TableHeader } from '@tiptap/extension-table/header';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { TableHeaderNodeView } from '@colanode/ui/editor/views';
export const TableHeaderNode = TableHeader.extend({
addNodeView() {
return ReactNodeViewRenderer(TableHeaderNodeView, {
as: 'th',
className: defaultClasses.tableHeaderWrapper,
});
},
addAttributes() {
return {
colspan: {
default: 1,
},
rowspan: {
default: 1,
},
colwidth: {
default: null,
parseHTML: (element: HTMLElement) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth
? colwidth.split(',').map((width: string) => parseInt(width, 10))
: null;
return value;
},
},
align: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-align'),
},
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-background-color'),
},
};
},
});

View File

@@ -0,0 +1,9 @@
import { TableRow } from '@tiptap/extension-table/row';
import { defaultClasses } from '@colanode/ui/editor/classes';
export const TableRowNode = TableRow.configure({
HTMLAttributes: {
class: defaultClasses.tableRow,
},
});

View File

@@ -0,0 +1,15 @@
import { Table } from '@tiptap/extension-table/table';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { TableNodeView } from '@colanode/ui/editor/views/table';
export const TableNode = Table.configure({
allowTableNodeSelection: true,
cellMinWidth: 100,
}).extend({
addNodeView() {
return ReactNodeViewRenderer(TableNodeView, {
contentDOMElementTag: 'tbody',
});
},
});

View File

@@ -5,6 +5,8 @@ import { Editor } from '@tiptap/react';
import { GripVertical, Plus } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { findClosestNodeAtPos, isDescendantNode } from '@colanode/client/lib';
interface ActionMenuProps {
editor: Editor | null;
}
@@ -72,8 +74,15 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
return;
}
// Find the nearest block parent at the current horizontal position
let currentPos = pos.pos;
const nodeAtPos = findClosestNodeAtPos(view.current.state.doc, pos.pos);
if (!nodeAtPos) {
setMenuState({
show: false,
});
return;
}
let currentPos = pos.pos - 1;
let pmNode = null;
let domNode = null;
let nodePos = -1;
@@ -81,13 +90,12 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
while (currentPos >= 0) {
const node = view.current.state.doc.nodeAt(currentPos);
if (
!node ||
!node.isBlock ||
node.type.name === 'bulletList' ||
node.type.name === 'orderedList' ||
node.type.name === 'taskList'
) {
if (!node || !node.isBlock) {
currentPos--;
continue;
}
if (!isDescendantNode(node, nodeAtPos)) {
currentPos--;
continue;
}
@@ -99,20 +107,11 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
: ((nodeDOM as Node)?.parentElement as HTMLElement);
if (nodeDOMElement) {
const nodeRect = nodeDOMElement.getBoundingClientRect();
// Are we on the same horizontal axis (vertical range) as the mouse over?
const verticallyAligned =
event.clientY >= nodeRect.top && event.clientY <= nodeRect.bottom;
if (verticallyAligned) {
pmNode = node;
domNode = nodeDOMElement;
nodePos = currentPos;
} else {
break;
}
}
currentPos--;
}

View File

@@ -1,4 +1,5 @@
import { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { Baseline } from 'lucide-react';
import {
@@ -6,72 +7,9 @@ import {
PopoverContent,
PopoverTrigger,
} from '@colanode/ui/components/ui/popover';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
interface ColorItem {
color: string;
textClass: string;
bgClass: string;
name: string;
}
const colors: ColorItem[] = [
{
name: 'Default',
color: 'default',
textClass: 'text-black-600',
bgClass: '',
},
{
name: 'Gray',
color: 'gray',
textClass: 'text-gray-600',
bgClass: 'bg-gray-100',
},
{
name: 'Orange',
color: 'orange',
textClass: 'text-orange-600',
bgClass: 'bg-orange-200',
},
{
name: 'Yellow',
color: 'yellow',
textClass: 'text-yellow-600',
bgClass: 'bg-yello-200',
},
{
name: 'Green',
color: 'green',
textClass: 'text-green-600',
bgClass: 'bg-green-200',
},
{
name: 'Blue',
color: 'blue',
textClass: 'text-blue-600',
bgClass: 'bg-blue-200',
},
{
name: 'Purple',
color: 'purple',
textClass: 'text-purple-600',
bgClass: 'bg-purple-200',
},
{
name: 'Pink',
color: 'pink',
textClass: 'text-pink-600',
bgClass: 'bg-pink-200',
},
{
name: 'Red',
color: 'red',
textClass: 'text-red-600',
bgClass: 'bg-red-200',
},
];
export const ColorButton = ({
editor,
isOpen,
@@ -81,75 +19,55 @@ export const ColorButton = ({
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}) => {
const activeColor = colors.find((color) =>
editor.isActive('color', { color: color.color })
);
const state = useEditorState({
editor,
selector: ({ editor }) => {
if (!editor) {
return null;
}
const activeHighlight = colors.find((color) =>
editor.isActive('highlight', { highlight: color.color })
);
return {
isEditable: editor.isEditable,
activeColor: editorColors.find((editorColor) =>
editor.isActive('color', { color: editorColor.color })
),
};
},
});
const activeColor = state?.activeColor ?? editorColors[0]!;
return (
<Popover open={isOpen} onOpenChange={setIsOpen} modal={true}>
<PopoverTrigger>
<span
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100',
activeHighlight?.bgClass ?? 'bg-white'
)}
>
<Baseline className={cn('size-4', activeColor?.textClass ?? '')} />
<span className="flex size-8 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100">
<Baseline className={cn('size-4', activeColor.textClass)} />
</span>
</PopoverTrigger>
<PopoverContent
align="start"
className="z-50 max-h-96 min-w-0 overflow-y-auto"
className="overflow-x-hidden overflow-y-auto rounded-md p-1"
>
<p className="text-sm text-muted-foreground">Color</p>
<div className="mt-1 flex flex-col gap-1">
{colors.map((color) => (
<div className="px-2 py-1.5 text-sm font-medium">Color</div>
<div>
{editorColors.map((color) => (
<button
type="button"
key={`text-color-${color.color}`}
onClick={() =>
color.color === 'default'
? editor.commands.unsetColor()
: editor.chain().focus().setColor(color.color).run()
onClick={() => {
if (color.color === 'default') {
editor.commands.unsetColor();
} else {
editor.chain().focus().setColor(color.color).run();
}
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-gray-100 cursor-pointer"
>
<div className="flex cursor-pointer flex-row items-center gap-2 p-1 pl-0 hover:bg-gray-100">
<div className="relative inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded bg-gray-50 shadow">
<div className="relative inline-flex size-6 items-center justify-center overflow-hidden rounded bg-gray-50 shadow">
<span className={cn('font-medium', color.textClass)}>A</span>
</div>
<span className="text-sm">{color.name}</span>
</div>
</button>
))}
</div>
<p className="mt-4 text-sm text-muted-foreground">Highlight</p>
<div className="mt-1 flex flex-col gap-1">
{colors.map((color) => (
<button
type="button"
key={`text-color-${color.color}`}
onClick={() =>
color.color === 'default'
? editor.commands.unsetHighlight()
: editor.commands.setHighlight(color.color)
}
>
<div className="flex cursor-pointer flex-row items-center gap-2 p-1 pl-0 hover:bg-gray-100">
<div
className={cn(
'relative inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded shadow',
color.bgClass
)}
>
<span className="font-medium">A</span>
</div>
<span className="text-sm">{color.name}</span>
</div>
<span>{color.name}</span>
</button>
))}
</div>

View File

@@ -0,0 +1,88 @@
import { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { Highlighter } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@colanode/ui/components/ui/popover';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
export const HighlightButton = ({
editor,
isOpen,
setIsOpen,
}: {
editor: Editor;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}) => {
const state = useEditorState({
editor,
selector: ({ editor }) => {
if (!editor) {
return null;
}
return {
isEditable: editor.isEditable,
activeHighlight: editorColors.find((editorColor) =>
editor.isActive('highlight', { highlight: editorColor.color })
),
};
},
});
const activeHighlight = state?.activeHighlight ?? editorColors[0]!;
return (
<Popover open={isOpen} onOpenChange={setIsOpen} modal={true}>
<PopoverTrigger>
<span
className={cn(
'flex size-8 items-center justify-center rounded-md cursor-pointer',
activeHighlight.bgClass,
activeHighlight.bgHoverClass
)}
>
<Highlighter className="size-4" />
</span>
</PopoverTrigger>
<PopoverContent
align="start"
className="overflow-x-hidden overflow-y-auto rounded-md p-1"
>
<div className="px-2 py-1.5 text-sm font-medium">Highlight</div>
<div>
{editorColors.map((editorColor) => (
<button
key={`highlight-color-${editorColor.color}`}
onClick={() => {
if (editorColor.color === 'default') {
editor.commands.unsetHighlight();
} else {
editor.commands.setHighlight(editorColor.color);
}
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-gray-100 cursor-pointer"
>
<div
className={cn(
'relative inline-flex size-6 items-center justify-center overflow-hidden rounded bg-gray-50 shadow',
editorColor.bgClass
)}
>
<span className="font-medium">A</span>
</div>
<span>{editorColor.name}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -1,2 +1,4 @@
export * from './toolbar-menu';
export * from './action-menu';
export * from './table-cell-context-menu';
export * from './table-cell-dropdown-menu';

View File

@@ -1,4 +1,5 @@
import { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { Check, Link, Trash2 } from 'lucide-react';
import { isValidUrl } from '@colanode/core';
@@ -30,13 +31,28 @@ interface LinkButtonProps {
}
export const LinkButton = ({ editor, isOpen, setIsOpen }: LinkButtonProps) => {
const state = useEditorState({
editor,
selector: ({ editor }) => {
if (!editor) {
return null;
}
return {
isEditable: editor.isEditable,
isActive: editor.isActive('link'),
attributes: editor.getAttributes('link'),
};
},
});
return (
<Popover open={isOpen} onOpenChange={setIsOpen} modal={true}>
<PopoverTrigger>
<span
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100',
editor.isActive('link') ? 'bg-gray-100' : 'bg-white'
state?.isActive ? 'bg-gray-100' : 'bg-white'
)}
>
<Link className="size-4" />
@@ -60,9 +76,9 @@ export const LinkButton = ({ editor, isOpen, setIsOpen }: LinkButtonProps) => {
<Input
placeholder="Write or paste link"
className="border-0"
defaultValue={editor.getAttributes('link').href || ''}
defaultValue={state?.attributes.href || ''}
/>
{editor.getAttributes('link').href ? (
{state?.attributes.href ? (
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100"

View File

@@ -0,0 +1,173 @@
import { type NodeViewProps } from '@tiptap/core';
import {
Trash,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
AlignLeft,
AlignCenter,
AlignRight,
Highlighter,
AlignJustify,
Check,
} from 'lucide-react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@colanode/ui/components/ui/context-menu';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
interface TableCellContextMenuProps extends NodeViewProps {
children: React.ReactNode;
}
export const TableCellContextMenu = ({
editor,
node,
updateAttributes,
children,
}: TableCellContextMenuProps) => {
const textAlign = node.attrs.align ?? 'left';
const backgroundColor = node.attrs.backgroundColor ?? 'default';
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel>Cell Actions</ContextMenuLabel>
<ContextMenuSub>
<ContextMenuSubTrigger className="flex gap-2">
<AlignJustify className="size-4 text-muted-foreground" />
Alignment
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuLabel>Alignment</ContextMenuLabel>
<ContextMenuItem
onSelect={() => updateAttributes({ align: 'left' })}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<AlignLeft className="size-4" />
Left
</div>
{textAlign === 'left' && <Check className="size-4" />}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => updateAttributes({ align: 'center' })}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<AlignCenter className="size-4" />
Center
</div>
{textAlign === 'center' && <Check className="size-4" />}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => updateAttributes({ align: 'right' })}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<AlignRight className="size-4" />
Right
</div>
{textAlign === 'right' && <Check className="size-4" />}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger className="flex gap-2">
<Highlighter className="size-4 text-muted-foreground" />
Background Color
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuLabel>Background Color</ContextMenuLabel>
{editorColors.map((color) => (
<ContextMenuItem
key={color.color}
onSelect={() =>
updateAttributes({ backgroundColor: color.color })
}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div
className={cn(
'w-4 h-4 rounded border border-gray-300',
color.bgClass
)}
/>
{color.name}
</div>
{backgroundColor === color.color && (
<Check className="size-4" />
)}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuLabel>Column Actions</ContextMenuLabel>
<ContextMenuItem
onSelect={() => {
editor.chain().addColumnBefore().focus().run();
}}
>
<ArrowLeft className="size-4" />
Insert column left
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
editor.chain().addColumnAfter().focus().run();
}}
>
<ArrowRight className="size-4" />
Insert column right
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
editor.chain().focus().deleteColumn().run();
}}
>
<Trash className="size-4" />
Delete column
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuLabel>Row Actions</ContextMenuLabel>
<ContextMenuItem
onSelect={() => {
editor.chain().addRowBefore().focus().run();
}}
>
<ArrowUp className="size-4" />
Insert row above
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
editor.chain().addRowAfter().focus().run();
}}
>
<ArrowDown className="size-4" />
Insert row below
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
editor.chain().focus().deleteRow().run();
}}
>
<Trash className="size-4" />
Delete row
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -0,0 +1,177 @@
import { NodeViewProps } from '@tiptap/core';
import {
Trash,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
EllipsisVertical,
AlignLeft,
AlignCenter,
AlignRight,
Highlighter,
AlignJustify,
Check,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@colanode/ui/components/ui/dropdown-menu';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
export const TableCellDropdownMenu = ({
editor,
node,
updateAttributes,
}: NodeViewProps) => {
const textAlign = node.attrs.textAlign ?? 'left';
const backgroundColor = node.attrs.backgroundColor ?? 'default';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'absolute top-1/2 -right-2 transform -translate-y-1/2 bg-white hover:bg-gray-100 py-1 cursor-pointer border border-gray-200 rounded z-10'
)}
>
<EllipsisVertical className="size-3 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" className="w-52">
<DropdownMenuLabel>Cell Actions</DropdownMenuLabel>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex gap-2">
<AlignJustify className="size-4 text-muted-foreground" />
Alignment
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
<DropdownMenuLabel>Alignment</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => updateAttributes({ align: 'left' })}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<AlignLeft className="size-4" />
Left
</div>
{textAlign === 'left' && <Check className="size-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateAttributes({ align: 'center' })}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<AlignCenter className="size-4" />
Center
</div>
{textAlign === 'center' && <Check className="size-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateAttributes({ align: 'right' })}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<AlignRight className="size-4" />
Right
</div>
{textAlign === 'right' && <Check className="size-4" />}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex gap-2">
<Highlighter className="size-4 text-muted-foreground" />
Background Color
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
<DropdownMenuLabel>Background Color</DropdownMenuLabel>
{editorColors.map((color) => (
<DropdownMenuItem
key={color.color}
onClick={() =>
updateAttributes({ backgroundColor: color.color })
}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div
className={cn(
'w-4 h-4 rounded border border-gray-300',
color.bgClass
)}
/>
{color.name}
</div>
{backgroundColor === color.color && (
<Check className="size-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuLabel>Column Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
editor.chain().addColumnBefore().focus().run();
}}
>
<ArrowLeft className="size-4" />
Insert column left
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
editor.chain().addColumnAfter().focus().run();
}}
>
<ArrowRight className="size-4" />
Insert column right
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
editor.chain().focus().deleteColumn().run();
}}
>
<Trash className="size-4" />
Delete column
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Row Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
editor.chain().addRowBefore().focus().run();
}}
>
<ArrowUp className="size-4" />
Insert row above
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
editor.chain().addRowAfter().focus().run();
}}
>
<ArrowDown className="size-4" />
Insert row below
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
editor.chain().focus().deleteRow().run();
}}
>
<Trash className="size-4" />
Delete row
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,9 +1,10 @@
import { Editor, isNodeSelection } from '@tiptap/react';
import { Editor, isNodeSelection, useEditorState } from '@tiptap/react';
import { BubbleMenu, type BubbleMenuProps } from '@tiptap/react/menus';
import { Bold, Code, Italic, Strikethrough, Underline } from 'lucide-react';
import { useState } from 'react';
import { ColorButton } from '@colanode/ui/editor/menus/color-button';
import { HighlightButton } from '@colanode/ui/editor/menus/highlight-button';
import { LinkButton } from '@colanode/ui/editor/menus/link-button';
import { MarkButton } from '@colanode/ui/editor/menus/mark-button';
@@ -14,6 +15,25 @@ interface ToolbarMenuProps extends Omit<BubbleMenuProps, 'children'> {
export const ToolbarMenu = (props: ToolbarMenuProps) => {
const [isColorButtonOpen, setIsColorButtonOpen] = useState(false);
const [isLinkButtonOpen, setIsLinkButtonOpen] = useState(false);
const [isHighlightButtonOpen, setIsHighlightButtonOpen] = useState(false);
const state = useEditorState({
editor: props.editor,
selector: ({ editor }) => {
if (!editor) {
return null;
}
return {
isEditable: editor.isEditable,
isBoldActive: editor.isActive('bold'),
isItalicActive: editor.isActive('italic'),
isUnderlineActive: editor.isActive('underline'),
isStrikeActive: editor.isActive('strike'),
isCodeActive: editor.isActive('code'),
};
},
});
const bubbleMenuProps: ToolbarMenuProps = {
...props,
@@ -48,6 +68,7 @@ export const ToolbarMenu = (props: ToolbarMenuProps) => {
onHide: () => {
setIsColorButtonOpen(false);
setIsLinkButtonOpen(false);
setIsHighlightButtonOpen(false);
},
},
};
@@ -66,31 +87,32 @@ export const ToolbarMenu = (props: ToolbarMenuProps) => {
isOpen={isLinkButtonOpen}
setIsOpen={(isOpen) => {
setIsColorButtonOpen(false);
setIsHighlightButtonOpen(false);
setIsLinkButtonOpen(isOpen);
}}
/>
<MarkButton
isActive={props.editor?.isActive('bold') === true}
isActive={state?.isBoldActive ?? false}
onClick={() => props.editor?.chain().focus().toggleBold().run()}
icon={Bold}
/>
<MarkButton
isActive={props.editor?.isActive('italic') === true}
isActive={state?.isItalicActive ?? false}
onClick={() => props.editor?.chain().focus().toggleItalic().run()}
icon={Italic}
/>
<MarkButton
isActive={props.editor?.isActive('underline') === true}
isActive={state?.isUnderlineActive ?? false}
onClick={() => props.editor?.chain().focus().toggleUnderline().run()}
icon={Underline}
/>
<MarkButton
isActive={props.editor?.isActive('strike') === true}
isActive={state?.isStrikeActive ?? false}
onClick={() => props.editor?.chain().focus().toggleStrike().run()}
icon={Strikethrough}
/>
<MarkButton
isActive={props.editor?.isActive('code') === true}
isActive={state?.isCodeActive ?? false}
onClick={() => props.editor?.chain().focus().toggleCode().run()}
icon={Code}
/>
@@ -100,6 +122,16 @@ export const ToolbarMenu = (props: ToolbarMenuProps) => {
setIsOpen={(isOpen) => {
setIsColorButtonOpen(isOpen);
setIsLinkButtonOpen(false);
setIsHighlightButtonOpen(false);
}}
/>
<HighlightButton
editor={props.editor}
isOpen={isHighlightButtonOpen}
setIsOpen={(isOpen) => {
setIsHighlightButtonOpen(isOpen);
setIsColorButtonOpen(false);
setIsLinkButtonOpen(false);
}}
/>
</BubbleMenu>

View File

@@ -17,6 +17,10 @@ import { MentionRenderer } from '@colanode/ui/editor/renderers/mention';
import { MessageRenderer } from '@colanode/ui/editor/renderers/message';
import { OrderedListRenderer } from '@colanode/ui/editor/renderers/ordered-list';
import { ParagraphRenderer } from '@colanode/ui/editor/renderers/paragraph';
import { TableRenderer } from '@colanode/ui/editor/renderers/table';
import { TableCellRenderer } from '@colanode/ui/editor/renderers/table-cell';
import { TableHeaderRenderer } from '@colanode/ui/editor/renderers/table-header';
import { TableRowRenderer } from '@colanode/ui/editor/renderers/table-row';
import { TaskItemRenderer } from '@colanode/ui/editor/renderers/task-item';
import { TaskListRenderer } from '@colanode/ui/editor/renderers/task-list';
import { TextRenderer } from '@colanode/ui/editor/renderers/text';
@@ -80,6 +84,18 @@ export const NodeRenderer = ({
.with('hardBreak', () => (
<HardBreakRenderer node={node} keyPrefix={keyPrefix} />
))
.with('table', () => (
<TableRenderer node={node} keyPrefix={keyPrefix} />
))
.with('tableRow', () => (
<TableRowRenderer node={node} keyPrefix={keyPrefix} />
))
.with('tableCell', () => (
<TableCellRenderer node={node} keyPrefix={keyPrefix} />
))
.with('tableHeader', () => (
<TableHeaderRenderer node={node} keyPrefix={keyPrefix} />
))
.otherwise(() => null)}
</MarkRenderer>
);

View File

@@ -0,0 +1,38 @@
import { JSONContent } from '@tiptap/core';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { NodeChildrenRenderer } from '@colanode/ui/editor/renderers/node-children';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
interface TableCellRendererProps {
node: JSONContent;
keyPrefix: string | null;
}
export const TableCellRenderer = ({
node,
keyPrefix,
}: TableCellRendererProps) => {
const align = node.attrs?.align ?? 'left';
const backgroundColorAttr = node.attrs?.backgroundColor ?? null;
const backgroundColor = backgroundColorAttr
? editorColors.find((color) => color.color === backgroundColorAttr)
: null;
return (
<td className={defaultClasses.tableCellWrapper}>
<div
className={cn(
defaultClasses.tableCell,
backgroundColor?.bgClass,
align === 'left' && 'justify-start',
align === 'center' && 'justify-center',
align === 'right' && 'justify-end'
)}
>
<NodeChildrenRenderer node={node} keyPrefix={keyPrefix} />
</div>
</td>
);
};

View File

@@ -0,0 +1,38 @@
import { JSONContent } from '@tiptap/core';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { NodeChildrenRenderer } from '@colanode/ui/editor/renderers/node-children';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
interface TableHeaderRendererProps {
node: JSONContent;
keyPrefix: string | null;
}
export const TableHeaderRenderer = ({
node,
keyPrefix,
}: TableHeaderRendererProps) => {
const align = node.attrs?.align ?? 'left';
const backgroundColorAttr = node.attrs?.backgroundColor ?? null;
const backgroundColor = backgroundColorAttr
? editorColors.find((color) => color.color === backgroundColorAttr)
: null;
return (
<th className={defaultClasses.tableHeaderWrapper}>
<div
className={cn(
defaultClasses.tableHeader,
backgroundColor?.bgClass,
align === 'left' && 'justify-start',
align === 'center' && 'justify-center',
align === 'right' && 'justify-end'
)}
>
<NodeChildrenRenderer node={node} keyPrefix={keyPrefix} />
</div>
</th>
);
};

View File

@@ -0,0 +1,21 @@
import { JSONContent } from '@tiptap/core';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { NodeChildrenRenderer } from '@colanode/ui/editor/renderers/node-children';
import { cn } from '@colanode/ui/lib/utils';
interface TableRowRendererProps {
node: JSONContent;
keyPrefix: string | null;
}
export const TableRowRenderer = ({
node,
keyPrefix,
}: TableRowRendererProps) => {
return (
<tr className={cn(defaultClasses.tableRow)}>
<NodeChildrenRenderer node={node} keyPrefix={keyPrefix} />
</tr>
);
};

View File

@@ -0,0 +1,17 @@
import { JSONContent } from '@tiptap/core';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { NodeChildrenRenderer } from '@colanode/ui/editor/renderers/node-children';
interface TableRendererProps {
node: JSONContent;
keyPrefix: string | null;
}
export const TableRenderer = ({ node, keyPrefix }: TableRendererProps) => {
return (
<table className={defaultClasses.table}>
<NodeChildrenRenderer node={node} keyPrefix={keyPrefix} />
</table>
);
};

View File

@@ -4,6 +4,9 @@ import { FileNodeView } from '@colanode/ui/editor/views/file';
import { FolderNodeView } from '@colanode/ui/editor/views/folder';
import { MentionNodeView } from '@colanode/ui/editor/views/mention';
import { PageNodeView } from '@colanode/ui/editor/views/page';
import { TableNodeView } from '@colanode/ui/editor/views/table';
import { TableCellNodeView } from '@colanode/ui/editor/views/table-cell';
import { TableHeaderNodeView } from '@colanode/ui/editor/views/table-header';
import { TempFileNodeView } from '@colanode/ui/editor/views/temp-file';
export {
@@ -14,4 +17,7 @@ export {
FolderNodeView,
MentionNodeView,
PageNodeView,
TableCellNodeView,
TableNodeView,
TableHeaderNodeView,
};

View File

@@ -0,0 +1,92 @@
import { type NodeViewProps } from '@tiptap/core';
import {
NodeViewContent,
NodeViewWrapper,
useEditorState,
} from '@tiptap/react';
import { Resizable } from 're-resizable';
import { updateColumnWidth } from '@colanode/client/lib';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { TableCellContextMenu } from '@colanode/ui/editor/menus/table-cell-context-menu';
import { TableCellDropdownMenu } from '@colanode/ui/editor/menus/table-cell-dropdown-menu';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
export const TableCellNodeView = (props: NodeViewProps) => {
const state = useEditorState({
editor: props.editor,
selector(context) {
return {
isActive: context.editor.isActive(
props.node.type.name,
props.node.attrs
),
};
},
});
const isActive = state.isActive;
const colWidth = props.node.attrs.colwidth ?? 100;
const align = props.node.attrs.align;
const backgroundColor = editorColors.find(
(color) => color.color === props.node.attrs.backgroundColor
);
return (
<NodeViewWrapper>
<TableCellContextMenu {...props}>
<Resizable
className={cn(
defaultClasses.tableCell,
'relative',
isActive && 'outline outline-gray-400',
backgroundColor?.bgClass,
align === 'left' && 'justify-start',
align === 'center' && 'justify-center',
align === 'right' && 'justify-end'
)}
defaultSize={{
width: `${colWidth}px`,
}}
minWidth={100}
maxWidth={500}
size={{
width: `${colWidth}px`,
}}
enable={{
bottom: false,
bottomLeft: false,
bottomRight: false,
left: false,
right: !isActive,
top: false,
topLeft: false,
topRight: false,
}}
handleClasses={{
right: 'opacity-0 hover:opacity-100 bg-blue-300',
}}
handleStyles={{
right: {
width: '3px',
right: '-3px',
},
}}
onResizeStop={(_e, _direction, ref) => {
const newWidth = ref.offsetWidth;
const pos = props.getPos();
if (!pos) {
return;
}
updateColumnWidth(props.editor.view, pos, newWidth);
}}
>
{isActive && <TableCellDropdownMenu {...props} />}
<NodeViewContent className="z-0 w-full h-full" />
</Resizable>
</TableCellContextMenu>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,92 @@
import { type NodeViewProps } from '@tiptap/core';
import {
NodeViewContent,
NodeViewWrapper,
useEditorState,
} from '@tiptap/react';
import { Resizable } from 're-resizable';
import { updateColumnWidth } from '@colanode/client/lib';
import { defaultClasses } from '@colanode/ui/editor/classes';
import { TableCellContextMenu } from '@colanode/ui/editor/menus/table-cell-context-menu';
import { TableCellDropdownMenu } from '@colanode/ui/editor/menus/table-cell-dropdown-menu';
import { editorColors } from '@colanode/ui/lib/editor';
import { cn } from '@colanode/ui/lib/utils';
export const TableHeaderNodeView = (props: NodeViewProps) => {
const state = useEditorState({
editor: props.editor,
selector(context) {
return {
isActive: context.editor.isActive(
props.node.type.name,
props.node.attrs
),
};
},
});
const isActive = state.isActive;
const colWidth = props.node.attrs.colwidth ?? 100;
const align = props.node.attrs.align;
const backgroundColor = editorColors.find(
(color) => color.color === props.node.attrs.backgroundColor
);
return (
<NodeViewWrapper>
<TableCellContextMenu {...props}>
<Resizable
className={cn(
defaultClasses.tableHeader,
'relative',
isActive && 'outline outline-gray-400',
backgroundColor?.bgClass,
align === 'left' && 'justify-start',
align === 'center' && 'justify-center',
align === 'right' && 'justify-end'
)}
defaultSize={{
width: `${colWidth}px`,
}}
minWidth={100}
maxWidth={500}
size={{
width: `${colWidth}px`,
}}
enable={{
bottom: false,
bottomLeft: false,
bottomRight: false,
left: false,
right: !isActive,
top: false,
topLeft: false,
topRight: false,
}}
handleClasses={{
right: 'opacity-0 hover:opacity-100 bg-blue-300',
}}
handleStyles={{
right: {
width: '3px',
right: '-3px',
},
}}
onResizeStop={(_e, _direction, ref) => {
const newWidth = ref.offsetWidth;
const pos = props.getPos();
if (!pos) {
return;
}
updateColumnWidth(props.editor.view, pos, newWidth);
}}
>
{isActive && <TableCellDropdownMenu {...props} />}
<NodeViewContent className="z-0 w-full h-full" />
</Resizable>
</TableCellContextMenu>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,162 @@
import { type NodeViewProps } from '@tiptap/core';
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react';
import { useState, useRef } from 'react';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@colanode/ui/components/ui/tooltip';
import { defaultClasses } from '@colanode/ui/editor/classes';
export const TableNodeView = ({ editor, getPos }: NodeViewProps) => {
const [isSideHovered, setIsSideHovered] = useState(false);
const [isBottomHovered, setIsBottomHovered] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const focusTable = () => {
if (!getPos || typeof getPos !== 'function') {
return false;
}
const pos = getPos();
if (pos === undefined) {
return false;
}
try {
const resolvedPos = editor.state.doc.resolve(pos + 1);
const table = resolvedPos.node(1);
if (table && table.type.name === 'table') {
let cellPos = pos + 1;
table.descendants((node, nodePos) => {
if (
node.type.name === 'tableCell' ||
node.type.name === 'tableHeader'
) {
cellPos = pos + 1 + nodePos + 1;
return false;
}
return true;
});
editor.chain().focus().setTextSelection(cellPos).run();
return true;
}
} catch (error) {
console.warn('Failed to focus table:', error);
}
return false;
};
const handleMouseEnter = (event: React.MouseEvent) => {
handleMouseMove(event);
};
const handleMouseMove = (event: React.MouseEvent) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Define edge threshold (how close to the edge to trigger hover)
const edgeThreshold = 50; // pixels
// Check if mouse is close to right edge or bottom edge
const isNearRightEdge = x >= rect.width - edgeThreshold;
const isNearBottomEdge = y >= rect.height - edgeThreshold;
setIsSideHovered(isNearRightEdge);
setIsBottomHovered(isNearBottomEdge);
};
const handleMouseLeave = (event: React.MouseEvent) => {
const relatedTarget = event.relatedTarget;
const currentTarget = event.currentTarget;
// Check if both targets are actually Node instances
if (
!relatedTarget ||
!currentTarget ||
!(relatedTarget instanceof Node) ||
!(currentTarget instanceof Node)
) {
setIsSideHovered(false);
setIsBottomHovered(false);
return;
}
if (relatedTarget && currentTarget?.contains(relatedTarget)) {
return;
}
if (
wrapperRef.current &&
relatedTarget &&
wrapperRef.current.contains(relatedTarget)
) {
return;
}
setIsSideHovered(false);
setIsBottomHovered(false);
};
return (
<NodeViewWrapper
ref={wrapperRef}
className="pr-4 pb-4 pt-4 w-fit"
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<div className="relative">
<NodeViewContent<'table'> as="table" className={defaultClasses.table} />
{isSideHovered && (
<div className="absolute -right-6 top-0 h-full flex items-center">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
className="w-4 h-full hover:bg-gray-50 cursor-pointer flex items-center justify-center rounded-sm transition-colors text-muted-foreground text-xs"
onClick={() => {
if (focusTable()) {
editor.chain().addColumnAfter().run();
} else {
editor.chain().focus().addColumnAfter().run();
}
}}
>
+
</button>
</TooltipTrigger>
<TooltipContent side="right">Add column</TooltipContent>
</Tooltip>
</div>
)}
{isBottomHovered && (
<div className="absolute -bottom-6 left-0 w-full flex justify-center">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
className="h-4 w-full hover:bg-gray-50 cursor-pointer flex items-center justify-center rounded-sm transition-colors text-muted-foreground text-xs"
onClick={() => {
if (focusTable()) {
editor.chain().addRowAfter().run();
} else {
editor.chain().focus().addRowAfter().run();
}
}}
>
+
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Add row</TooltipContent>
</Tooltip>
</div>
)}
</div>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,73 @@
export interface EditorColorOption {
color: string;
name: string;
textClass: string;
bgClass: string;
bgHoverClass: string;
}
export const editorColors: EditorColorOption[] = [
{
name: 'Default',
color: 'default',
textClass: 'text-black-600',
bgClass: '',
bgHoverClass: 'hover:bg-gray-100',
},
{
name: 'Gray',
color: 'gray',
textClass: 'text-gray-600',
bgClass: 'bg-gray-100',
bgHoverClass: 'hover:bg-gray-200',
},
{
name: 'Orange',
color: 'orange',
textClass: 'text-orange-600',
bgClass: 'bg-orange-200',
bgHoverClass: 'hover:bg-orange-300',
},
{
name: 'Yellow',
color: 'yellow',
textClass: 'text-yellow-600',
bgClass: 'bg-yellow-200',
bgHoverClass: 'hover:bg-yellow-300',
},
{
name: 'Green',
color: 'green',
textClass: 'text-green-600',
bgClass: 'bg-green-200',
bgHoverClass: 'hover:bg-green-300',
},
{
name: 'Blue',
color: 'blue',
textClass: 'text-blue-600',
bgClass: 'bg-blue-200',
bgHoverClass: 'hover:bg-blue-300',
},
{
name: 'Purple',
color: 'purple',
textClass: 'text-purple-600',
bgClass: 'bg-purple-200',
bgHoverClass: 'hover:bg-purple-300',
},
{
name: 'Pink',
color: 'pink',
textClass: 'text-pink-600',
bgClass: 'bg-pink-200',
bgHoverClass: 'hover:bg-pink-300',
},
{
name: 'Red',
color: 'red',
textClass: 'text-red-600',
bgClass: 'bg-red-200',
bgHoverClass: 'hover:bg-red-300',
},
];