mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Add plain tables to editor (#180)
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
<Document node={page} canEdit={canEdit} autoFocus="start" />
|
||||
</ScrollArea>
|
||||
<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" />
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
<RecordProvider record={record} role={role}>
|
||||
<RecordAttributes />
|
||||
</RecordProvider>
|
||||
<Separator className="my-4 w-full" />
|
||||
<Document node={record} canEdit={canEdit} autoFocus={false} />
|
||||
</ScrollArea>
|
||||
<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} />
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
</div>
|
||||
</RecordDatabase>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
packages/ui/src/editor/commands/table.tsx
Normal file
20
packages/ui/src/editor/commands/table.tsx
Normal 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();
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
packages/ui/src/editor/extensions/table-cell.tsx
Normal file
44
packages/ui/src/editor/extensions/table-cell.tsx
Normal 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'),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
44
packages/ui/src/editor/extensions/table-header.tsx
Normal file
44
packages/ui/src/editor/extensions/table-header.tsx
Normal 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'),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
9
packages/ui/src/editor/extensions/table-row.tsx
Normal file
9
packages/ui/src/editor/extensions/table-row.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
15
packages/ui/src/editor/extensions/table.tsx
Normal file
15
packages/ui/src/editor/extensions/table.tsx
Normal 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',
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
pmNode = node;
|
||||
domNode = nodeDOMElement;
|
||||
nodePos = currentPos;
|
||||
}
|
||||
|
||||
currentPos--;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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 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>{color.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
88
packages/ui/src/editor/menus/highlight-button.tsx
Normal file
88
packages/ui/src/editor/menus/highlight-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './toolbar-menu';
|
||||
export * from './action-menu';
|
||||
export * from './table-cell-context-menu';
|
||||
export * from './table-cell-dropdown-menu';
|
||||
|
||||
@@ -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"
|
||||
|
||||
173
packages/ui/src/editor/menus/table-cell-context-menu.tsx
Normal file
173
packages/ui/src/editor/menus/table-cell-context-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
177
packages/ui/src/editor/menus/table-cell-dropdown-menu.tsx
Normal file
177
packages/ui/src/editor/menus/table-cell-dropdown-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
38
packages/ui/src/editor/renderers/table-cell.tsx
Normal file
38
packages/ui/src/editor/renderers/table-cell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
packages/ui/src/editor/renderers/table-header.tsx
Normal file
38
packages/ui/src/editor/renderers/table-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
packages/ui/src/editor/renderers/table-row.tsx
Normal file
21
packages/ui/src/editor/renderers/table-row.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
packages/ui/src/editor/renderers/table.tsx
Normal file
17
packages/ui/src/editor/renderers/table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
92
packages/ui/src/editor/views/table-cell.tsx
Normal file
92
packages/ui/src/editor/views/table-cell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
92
packages/ui/src/editor/views/table-header.tsx
Normal file
92
packages/ui/src/editor/views/table-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
162
packages/ui/src/editor/views/table.tsx
Normal file
162
packages/ui/src/editor/views/table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
packages/ui/src/lib/editor.ts
Normal file
73
packages/ui/src/lib/editor.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user