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"
|
"@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": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "3.0.9",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.0.9.tgz",
|
"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-list": "^3.0.9",
|
||||||
"@tiptap/extension-paragraph": "^3.0.9",
|
"@tiptap/extension-paragraph": "^3.0.9",
|
||||||
"@tiptap/extension-strike": "^3.0.9",
|
"@tiptap/extension-strike": "^3.0.9",
|
||||||
|
"@tiptap/extension-table": "^3.0.9",
|
||||||
"@tiptap/extension-text": "^3.0.9",
|
"@tiptap/extension-text": "^3.0.9",
|
||||||
"@tiptap/extension-underline": "^3.0.9",
|
"@tiptap/extension-underline": "^3.0.9",
|
||||||
"@tiptap/extensions": "^3.0.9",
|
"@tiptap/extensions": "^3.0.9",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Editor, JSONContent } from '@tiptap/core';
|
import { Editor, JSONContent } from '@tiptap/core';
|
||||||
import { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model';
|
import { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model';
|
||||||
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
|
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
|
||||||
|
import { TableMap } from '@tiptap/pm/tables';
|
||||||
|
import { EditorView } from '@tiptap/pm/view';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Block,
|
Block,
|
||||||
@@ -13,6 +15,14 @@ import {
|
|||||||
RichTextContent,
|
RichTextContent,
|
||||||
} from '@colanode/core';
|
} from '@colanode/core';
|
||||||
|
|
||||||
|
interface TableCellAttrs {
|
||||||
|
colspan: number;
|
||||||
|
rowspan: number;
|
||||||
|
colwidth: number[] | null;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
backgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const leafBlockTypes = new Set([
|
const leafBlockTypes = new Set([
|
||||||
EditorNodeTypes.Paragraph,
|
EditorNodeTypes.Paragraph,
|
||||||
EditorNodeTypes.Heading1,
|
EditorNodeTypes.Heading1,
|
||||||
@@ -430,3 +440,84 @@ export const restoreRelativeSelection = (
|
|||||||
view.dispatch(tr);
|
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',
|
File: 'file',
|
||||||
Folder: 'folder',
|
Folder: 'folder',
|
||||||
TempFile: 'tempFile',
|
TempFile: 'tempFile',
|
||||||
|
Table: 'table',
|
||||||
|
TableHeader: 'tableHeader',
|
||||||
|
TableCell: 'tableCell',
|
||||||
|
TableRow: 'tableRow',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SortDirection = 'asc' | 'desc';
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"@tiptap/extension-list": "^3.0.9",
|
"@tiptap/extension-list": "^3.0.9",
|
||||||
"@tiptap/extension-paragraph": "^3.0.9",
|
"@tiptap/extension-paragraph": "^3.0.9",
|
||||||
"@tiptap/extension-strike": "^3.0.9",
|
"@tiptap/extension-strike": "^3.0.9",
|
||||||
|
"@tiptap/extension-table": "^3.0.9",
|
||||||
"@tiptap/extension-text": "^3.0.9",
|
"@tiptap/extension-text": "^3.0.9",
|
||||||
"@tiptap/extension-underline": "^3.0.9",
|
"@tiptap/extension-underline": "^3.0.9",
|
||||||
"@tiptap/extensions": "^3.0.9",
|
"@tiptap/extensions": "^3.0.9",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
OrderedListCommand,
|
OrderedListCommand,
|
||||||
PageCommand,
|
PageCommand,
|
||||||
ParagraphCommand,
|
ParagraphCommand,
|
||||||
|
TableCommand,
|
||||||
TodoCommand,
|
TodoCommand,
|
||||||
DatabaseCommand,
|
DatabaseCommand,
|
||||||
DatabaseInlineCommand,
|
DatabaseInlineCommand,
|
||||||
@@ -70,6 +71,10 @@ import {
|
|||||||
PlaceholderExtension,
|
PlaceholderExtension,
|
||||||
StrikethroughMark,
|
StrikethroughMark,
|
||||||
TabKeymapExtension,
|
TabKeymapExtension,
|
||||||
|
TableNode,
|
||||||
|
TableRowNode,
|
||||||
|
TableHeaderNode,
|
||||||
|
TableCellNode,
|
||||||
TaskItemNode,
|
TaskItemNode,
|
||||||
TaskListNode,
|
TaskListNode,
|
||||||
TextNode,
|
TextNode,
|
||||||
@@ -196,6 +201,10 @@ export const DocumentEditor = ({
|
|||||||
}),
|
}),
|
||||||
TaskListNode,
|
TaskListNode,
|
||||||
TaskItemNode,
|
TaskItemNode,
|
||||||
|
TableNode,
|
||||||
|
TableRowNode,
|
||||||
|
TableCellNode,
|
||||||
|
TableHeaderNode,
|
||||||
DividerNode,
|
DividerNode,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
LinkMark,
|
LinkMark,
|
||||||
@@ -214,6 +223,7 @@ export const DocumentEditor = ({
|
|||||||
BulletListCommand,
|
BulletListCommand,
|
||||||
CodeBlockCommand,
|
CodeBlockCommand,
|
||||||
OrderedListCommand,
|
OrderedListCommand,
|
||||||
|
TableCommand,
|
||||||
DatabaseInlineCommand,
|
DatabaseInlineCommand,
|
||||||
DatabaseCommand,
|
DatabaseCommand,
|
||||||
DividerCommand,
|
DividerCommand,
|
||||||
@@ -388,7 +398,7 @@ export const DocumentEditor = ({
|
|||||||
}, [node.id, editor]);
|
}, [node.id, editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[500px]">
|
<>
|
||||||
{editor && canEdit && (
|
{editor && canEdit && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<ToolbarMenu editor={editor} />
|
<ToolbarMenu editor={editor} />
|
||||||
@@ -396,6 +406,6 @@ export const DocumentEditor = ({
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
|
||||||
import { LocalPageNode } from '@colanode/client/types';
|
import { LocalPageNode } from '@colanode/client/types';
|
||||||
import { NodeRole, hasNodeRole } from '@colanode/core';
|
import { NodeRole, hasNodeRole } from '@colanode/core';
|
||||||
import { Document } from '@colanode/ui/components/documents/document';
|
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 {
|
interface PageBodyProps {
|
||||||
page: LocalPageNode;
|
page: LocalPageNode;
|
||||||
@@ -12,8 +14,15 @@ export const PageBody = ({ page, role }: PageBodyProps) => {
|
|||||||
const canEdit = hasNodeRole(role, 'editor');
|
const canEdit = hasNodeRole(role, 'editor');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full max-h-full w-full overflow-y-auto">
|
<div className="h-full w-full overflow-y-auto">
|
||||||
<Document node={page} canEdit={canEdit} autoFocus="start" />
|
<ScrollAreaPrimitive.Root className="relative overflow-hidden h-full">
|
||||||
</ScrollArea>
|
<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 { LocalRecordNode } from '@colanode/client/types';
|
||||||
import { NodeRole, hasNodeRole } from '@colanode/core';
|
import { NodeRole, hasNodeRole } from '@colanode/core';
|
||||||
import { Document } from '@colanode/ui/components/documents/document';
|
import { Document } from '@colanode/ui/components/documents/document';
|
||||||
import { RecordAttributes } from '@colanode/ui/components/records/record-attributes';
|
import { RecordAttributes } from '@colanode/ui/components/records/record-attributes';
|
||||||
import { RecordDatabase } from '@colanode/ui/components/records/record-database';
|
import { RecordDatabase } from '@colanode/ui/components/records/record-database';
|
||||||
import { RecordProvider } from '@colanode/ui/components/records/record-provider';
|
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 { Separator } from '@colanode/ui/components/ui/separator';
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
|
||||||
@@ -20,13 +22,20 @@ export const RecordBody = ({ record, role }: RecordBodyProps) => {
|
|||||||
record.createdBy === workspace.userId || hasNodeRole(role, 'editor');
|
record.createdBy === workspace.userId || hasNodeRole(role, 'editor');
|
||||||
return (
|
return (
|
||||||
<RecordDatabase id={record.attributes.databaseId} role={role}>
|
<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">
|
||||||
<RecordProvider record={record} role={role}>
|
<ScrollAreaPrimitive.Root className="relative overflow-hidden h-full">
|
||||||
<RecordAttributes />
|
<ScrollAreaPrimitive.Viewport className="h-full max-h-[calc(100vh-100px)] w-full overflow-y-auto rounded-[inherit]">
|
||||||
</RecordProvider>
|
<RecordProvider record={record} role={role}>
|
||||||
<Separator className="my-4 w-full" />
|
<RecordAttributes />
|
||||||
<Document node={record} canEdit={canEdit} autoFocus={false} />
|
</RecordProvider>
|
||||||
</ScrollArea>
|
<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>
|
</RecordDatabase>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ export const defaultClasses = {
|
|||||||
taskList: 'not-prose pl-2',
|
taskList: 'not-prose pl-2',
|
||||||
taskItem: 'flex items-start my-1',
|
taskItem: 'flex items-start my-1',
|
||||||
link: 'font-medium underline underline-offset-4 cursor-pointer',
|
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',
|
gif: 'max-h-72 my-1',
|
||||||
emoji: 'max-h-5 max-w-5 h-5 w-5 px-0.5 mb-1 inline-block',
|
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',
|
dropcursor: 'text-primary-foreground bg-blue-500',
|
||||||
mention:
|
mention:
|
||||||
'inline-flex flex-row items-center gap-1 rounded-md bg-blue-50 px-0.5 py-0',
|
'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 { OrderedListCommand } from '@colanode/ui/editor/commands/ordered-list';
|
||||||
import { PageCommand } from '@colanode/ui/editor/commands/page';
|
import { PageCommand } from '@colanode/ui/editor/commands/page';
|
||||||
import { ParagraphCommand } from '@colanode/ui/editor/commands/paragraph';
|
import { ParagraphCommand } from '@colanode/ui/editor/commands/paragraph';
|
||||||
|
import { TableCommand } from '@colanode/ui/editor/commands/table';
|
||||||
import { TodoCommand } from '@colanode/ui/editor/commands/todo';
|
import { TodoCommand } from '@colanode/ui/editor/commands/todo';
|
||||||
|
|
||||||
export type { EditorCommand, EditorCommandProps };
|
export type { EditorCommand, EditorCommandProps };
|
||||||
@@ -30,6 +31,7 @@ export {
|
|||||||
OrderedListCommand,
|
OrderedListCommand,
|
||||||
PageCommand,
|
PageCommand,
|
||||||
ParagraphCommand,
|
ParagraphCommand,
|
||||||
|
TableCommand,
|
||||||
TodoCommand,
|
TodoCommand,
|
||||||
DatabaseCommand,
|
DatabaseCommand,
|
||||||
DatabaseInlineCommand,
|
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 { Mark } from '@tiptap/core';
|
||||||
|
|
||||||
|
import { editorColors } from '@colanode/ui/lib/editor';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
color: {
|
color: {
|
||||||
@@ -36,9 +38,14 @@ export const ColorMark = Mark.create({
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const value = attributes.color;
|
||||||
|
const color = editorColors.find((editorColor) => {
|
||||||
|
return editorColor.color === value;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'data-color': attributes.color,
|
'data-color': attributes.color,
|
||||||
class: `text-${attributes.color}-600`,
|
class: color?.textClass,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Mark } from '@tiptap/core';
|
import { Mark } from '@tiptap/core';
|
||||||
|
|
||||||
|
import { editorColors } from '@colanode/ui/lib/editor';
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
highlight: {
|
highlight: {
|
||||||
@@ -36,9 +38,14 @@ export const HighlightMark = Mark.create({
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const value = attributes.highlight;
|
||||||
|
const color = editorColors.find((editorColor) => {
|
||||||
|
return editorColor.color === value;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'data-highlight': attributes.highlight,
|
'data-highlight': attributes.highlight,
|
||||||
class: `bg-${attributes.highlight}-200`,
|
class: color?.bgClass,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const types = [
|
|||||||
EditorNodeTypes.TaskItem,
|
EditorNodeTypes.TaskItem,
|
||||||
EditorNodeTypes.CodeBlock,
|
EditorNodeTypes.CodeBlock,
|
||||||
EditorNodeTypes.HorizontalRule,
|
EditorNodeTypes.HorizontalRule,
|
||||||
|
EditorNodeTypes.Table,
|
||||||
|
EditorNodeTypes.TableHeader,
|
||||||
|
EditorNodeTypes.TableCell,
|
||||||
|
EditorNodeTypes.TableRow,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const IdExtension = Extension.create({
|
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 { ParagraphNode } from '@colanode/ui/editor/extensions/paragraph';
|
||||||
import { PlaceholderExtension } from '@colanode/ui/editor/extensions/placeholder';
|
import { PlaceholderExtension } from '@colanode/ui/editor/extensions/placeholder';
|
||||||
import { TabKeymapExtension } from '@colanode/ui/editor/extensions/tab-keymap';
|
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 { TaskItemNode } from '@colanode/ui/editor/extensions/task-item';
|
||||||
import { TaskListNode } from '@colanode/ui/editor/extensions/task-list';
|
import { TaskListNode } from '@colanode/ui/editor/extensions/task-list';
|
||||||
import { TempFileNode } from '@colanode/ui/editor/extensions/temp-file';
|
import { TempFileNode } from '@colanode/ui/editor/extensions/temp-file';
|
||||||
@@ -70,6 +74,10 @@ export {
|
|||||||
PlaceholderExtension,
|
PlaceholderExtension,
|
||||||
StrikethroughMark,
|
StrikethroughMark,
|
||||||
TabKeymapExtension,
|
TabKeymapExtension,
|
||||||
|
TableNode,
|
||||||
|
TableRowNode,
|
||||||
|
TableHeaderNode,
|
||||||
|
TableCellNode,
|
||||||
TaskItemNode,
|
TaskItemNode,
|
||||||
TaskListNode,
|
TaskListNode,
|
||||||
TextNode,
|
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 { GripVertical, Plus } from 'lucide-react';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { findClosestNodeAtPos, isDescendantNode } from '@colanode/client/lib';
|
||||||
|
|
||||||
interface ActionMenuProps {
|
interface ActionMenuProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
}
|
}
|
||||||
@@ -72,8 +74,15 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the nearest block parent at the current horizontal position
|
const nodeAtPos = findClosestNodeAtPos(view.current.state.doc, pos.pos);
|
||||||
let currentPos = pos.pos;
|
if (!nodeAtPos) {
|
||||||
|
setMenuState({
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPos = pos.pos - 1;
|
||||||
let pmNode = null;
|
let pmNode = null;
|
||||||
let domNode = null;
|
let domNode = null;
|
||||||
let nodePos = -1;
|
let nodePos = -1;
|
||||||
@@ -81,13 +90,12 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
|
|||||||
while (currentPos >= 0) {
|
while (currentPos >= 0) {
|
||||||
const node = view.current.state.doc.nodeAt(currentPos);
|
const node = view.current.state.doc.nodeAt(currentPos);
|
||||||
|
|
||||||
if (
|
if (!node || !node.isBlock) {
|
||||||
!node ||
|
currentPos--;
|
||||||
!node.isBlock ||
|
continue;
|
||||||
node.type.name === 'bulletList' ||
|
}
|
||||||
node.type.name === 'orderedList' ||
|
|
||||||
node.type.name === 'taskList'
|
if (!isDescendantNode(node, nodeAtPos)) {
|
||||||
) {
|
|
||||||
currentPos--;
|
currentPos--;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -99,20 +107,11 @@ export const ActionMenu = ({ editor }: ActionMenuProps) => {
|
|||||||
: ((nodeDOM as Node)?.parentElement as HTMLElement);
|
: ((nodeDOM as Node)?.parentElement as HTMLElement);
|
||||||
|
|
||||||
if (nodeDOMElement) {
|
if (nodeDOMElement) {
|
||||||
const nodeRect = nodeDOMElement.getBoundingClientRect();
|
pmNode = node;
|
||||||
|
domNode = nodeDOMElement;
|
||||||
// Are we on the same horizontal axis (vertical range) as the mouse over?
|
nodePos = currentPos;
|
||||||
const verticallyAligned =
|
|
||||||
event.clientY >= nodeRect.top && event.clientY <= nodeRect.bottom;
|
|
||||||
|
|
||||||
if (verticallyAligned) {
|
|
||||||
pmNode = node;
|
|
||||||
domNode = nodeDOMElement;
|
|
||||||
nodePos = currentPos;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPos--;
|
currentPos--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Editor } from '@tiptap/core';
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { useEditorState } from '@tiptap/react';
|
||||||
import { Baseline } from 'lucide-react';
|
import { Baseline } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -6,72 +7,9 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@colanode/ui/components/ui/popover';
|
} from '@colanode/ui/components/ui/popover';
|
||||||
|
import { editorColors } from '@colanode/ui/lib/editor';
|
||||||
import { cn } from '@colanode/ui/lib/utils';
|
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 = ({
|
export const ColorButton = ({
|
||||||
editor,
|
editor,
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -81,75 +19,55 @@ export const ColorButton = ({
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const activeColor = colors.find((color) =>
|
const state = useEditorState({
|
||||||
editor.isActive('color', { color: color.color })
|
editor,
|
||||||
);
|
selector: ({ editor }) => {
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const activeHighlight = colors.find((color) =>
|
return {
|
||||||
editor.isActive('highlight', { highlight: color.color })
|
isEditable: editor.isEditable,
|
||||||
);
|
activeColor: editorColors.find((editorColor) =>
|
||||||
|
editor.isActive('color', { color: editorColor.color })
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeColor = state?.activeColor ?? editorColors[0]!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen} modal={true}>
|
<Popover open={isOpen} onOpenChange={setIsOpen} modal={true}>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<span
|
<span className="flex size-8 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100">
|
||||||
className={cn(
|
<Baseline className={cn('size-4', activeColor.textClass)} />
|
||||||
'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>
|
</span>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="start"
|
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="px-2 py-1.5 text-sm font-medium">Color</div>
|
||||||
<div className="mt-1 flex flex-col gap-1">
|
<div>
|
||||||
{colors.map((color) => (
|
{editorColors.map((color) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
key={`text-color-${color.color}`}
|
key={`text-color-${color.color}`}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
color.color === 'default'
|
if (color.color === 'default') {
|
||||||
? editor.commands.unsetColor()
|
editor.commands.unsetColor();
|
||||||
: editor.chain().focus().setColor(color.color).run()
|
} 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 size-6 items-center justify-center overflow-hidden rounded bg-gray-50 shadow">
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
|
<span>{color.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 './toolbar-menu';
|
||||||
export * from './action-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 { Editor } from '@tiptap/core';
|
||||||
|
import { useEditorState } from '@tiptap/react';
|
||||||
import { Check, Link, Trash2 } from 'lucide-react';
|
import { Check, Link, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { isValidUrl } from '@colanode/core';
|
import { isValidUrl } from '@colanode/core';
|
||||||
@@ -30,13 +31,28 @@ interface LinkButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LinkButton = ({ editor, isOpen, setIsOpen }: 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 (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen} modal={true}>
|
<Popover open={isOpen} onOpenChange={setIsOpen} modal={true}>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-8 w-8 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100',
|
'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" />
|
<Link className="size-4" />
|
||||||
@@ -60,9 +76,9 @@ export const LinkButton = ({ editor, isOpen, setIsOpen }: LinkButtonProps) => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Write or paste link"
|
placeholder="Write or paste link"
|
||||||
className="border-0"
|
className="border-0"
|
||||||
defaultValue={editor.getAttributes('link').href || ''}
|
defaultValue={state?.attributes.href || ''}
|
||||||
/>
|
/>
|
||||||
{editor.getAttributes('link').href ? (
|
{state?.attributes.href ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md cursor-pointer hover:bg-gray-100"
|
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 { BubbleMenu, type BubbleMenuProps } from '@tiptap/react/menus';
|
||||||
import { Bold, Code, Italic, Strikethrough, Underline } from 'lucide-react';
|
import { Bold, Code, Italic, Strikethrough, Underline } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { ColorButton } from '@colanode/ui/editor/menus/color-button';
|
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 { LinkButton } from '@colanode/ui/editor/menus/link-button';
|
||||||
import { MarkButton } from '@colanode/ui/editor/menus/mark-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) => {
|
export const ToolbarMenu = (props: ToolbarMenuProps) => {
|
||||||
const [isColorButtonOpen, setIsColorButtonOpen] = useState(false);
|
const [isColorButtonOpen, setIsColorButtonOpen] = useState(false);
|
||||||
const [isLinkButtonOpen, setIsLinkButtonOpen] = 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 = {
|
const bubbleMenuProps: ToolbarMenuProps = {
|
||||||
...props,
|
...props,
|
||||||
@@ -48,6 +68,7 @@ export const ToolbarMenu = (props: ToolbarMenuProps) => {
|
|||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsColorButtonOpen(false);
|
setIsColorButtonOpen(false);
|
||||||
setIsLinkButtonOpen(false);
|
setIsLinkButtonOpen(false);
|
||||||
|
setIsHighlightButtonOpen(false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -66,31 +87,32 @@ export const ToolbarMenu = (props: ToolbarMenuProps) => {
|
|||||||
isOpen={isLinkButtonOpen}
|
isOpen={isLinkButtonOpen}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
setIsColorButtonOpen(false);
|
setIsColorButtonOpen(false);
|
||||||
|
setIsHighlightButtonOpen(false);
|
||||||
setIsLinkButtonOpen(isOpen);
|
setIsLinkButtonOpen(isOpen);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
isActive={props.editor?.isActive('bold') === true}
|
isActive={state?.isBoldActive ?? false}
|
||||||
onClick={() => props.editor?.chain().focus().toggleBold().run()}
|
onClick={() => props.editor?.chain().focus().toggleBold().run()}
|
||||||
icon={Bold}
|
icon={Bold}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
isActive={props.editor?.isActive('italic') === true}
|
isActive={state?.isItalicActive ?? false}
|
||||||
onClick={() => props.editor?.chain().focus().toggleItalic().run()}
|
onClick={() => props.editor?.chain().focus().toggleItalic().run()}
|
||||||
icon={Italic}
|
icon={Italic}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
isActive={props.editor?.isActive('underline') === true}
|
isActive={state?.isUnderlineActive ?? false}
|
||||||
onClick={() => props.editor?.chain().focus().toggleUnderline().run()}
|
onClick={() => props.editor?.chain().focus().toggleUnderline().run()}
|
||||||
icon={Underline}
|
icon={Underline}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
isActive={props.editor?.isActive('strike') === true}
|
isActive={state?.isStrikeActive ?? false}
|
||||||
onClick={() => props.editor?.chain().focus().toggleStrike().run()}
|
onClick={() => props.editor?.chain().focus().toggleStrike().run()}
|
||||||
icon={Strikethrough}
|
icon={Strikethrough}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
isActive={props.editor?.isActive('code') === true}
|
isActive={state?.isCodeActive ?? false}
|
||||||
onClick={() => props.editor?.chain().focus().toggleCode().run()}
|
onClick={() => props.editor?.chain().focus().toggleCode().run()}
|
||||||
icon={Code}
|
icon={Code}
|
||||||
/>
|
/>
|
||||||
@@ -100,6 +122,16 @@ export const ToolbarMenu = (props: ToolbarMenuProps) => {
|
|||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
setIsColorButtonOpen(isOpen);
|
setIsColorButtonOpen(isOpen);
|
||||||
setIsLinkButtonOpen(false);
|
setIsLinkButtonOpen(false);
|
||||||
|
setIsHighlightButtonOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HighlightButton
|
||||||
|
editor={props.editor}
|
||||||
|
isOpen={isHighlightButtonOpen}
|
||||||
|
setIsOpen={(isOpen) => {
|
||||||
|
setIsHighlightButtonOpen(isOpen);
|
||||||
|
setIsColorButtonOpen(false);
|
||||||
|
setIsLinkButtonOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { MentionRenderer } from '@colanode/ui/editor/renderers/mention';
|
|||||||
import { MessageRenderer } from '@colanode/ui/editor/renderers/message';
|
import { MessageRenderer } from '@colanode/ui/editor/renderers/message';
|
||||||
import { OrderedListRenderer } from '@colanode/ui/editor/renderers/ordered-list';
|
import { OrderedListRenderer } from '@colanode/ui/editor/renderers/ordered-list';
|
||||||
import { ParagraphRenderer } from '@colanode/ui/editor/renderers/paragraph';
|
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 { TaskItemRenderer } from '@colanode/ui/editor/renderers/task-item';
|
||||||
import { TaskListRenderer } from '@colanode/ui/editor/renderers/task-list';
|
import { TaskListRenderer } from '@colanode/ui/editor/renderers/task-list';
|
||||||
import { TextRenderer } from '@colanode/ui/editor/renderers/text';
|
import { TextRenderer } from '@colanode/ui/editor/renderers/text';
|
||||||
@@ -80,6 +84,18 @@ export const NodeRenderer = ({
|
|||||||
.with('hardBreak', () => (
|
.with('hardBreak', () => (
|
||||||
<HardBreakRenderer node={node} keyPrefix={keyPrefix} />
|
<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)}
|
.otherwise(() => null)}
|
||||||
</MarkRenderer>
|
</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 { FolderNodeView } from '@colanode/ui/editor/views/folder';
|
||||||
import { MentionNodeView } from '@colanode/ui/editor/views/mention';
|
import { MentionNodeView } from '@colanode/ui/editor/views/mention';
|
||||||
import { PageNodeView } from '@colanode/ui/editor/views/page';
|
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';
|
import { TempFileNodeView } from '@colanode/ui/editor/views/temp-file';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -14,4 +17,7 @@ export {
|
|||||||
FolderNodeView,
|
FolderNodeView,
|
||||||
MentionNodeView,
|
MentionNodeView,
|
||||||
PageNodeView,
|
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