"use client";
import { useCallback, useEffect, useState } from "react";
import {
ActionIcon,
Button,
ColorPicker,
ColorSwatch,
Group,
NumberInput,
Popover,
ScrollArea,
Stack,
TextInput,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Link, RichTextEditor, useRichTextEditorContext } from "@mantine/tiptap";
import {
IconCheck,
IconCircleOff,
IconColumnInsertLeft,
IconColumnInsertRight,
IconColumnRemove,
IconDeviceFloppy,
IconEdit,
IconHighlight,
IconIndentDecrease,
IconIndentIncrease,
IconLayoutGrid,
IconLetterA,
IconListCheck,
IconPhoto,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconTableOff,
IconTablePlus,
IconX,
} from "@tabler/icons-react";
import { Color } from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import Image from "@tiptap/extension-image";
import Table from "@tiptap/extension-table";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextAlign from "@tiptap/extension-text-align";
import TextStyle from "@tiptap/extension-text-style";
import Underline from "@tiptap/extension-underline";
import type { Editor } from "@tiptap/react";
import { BubbleMenu, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import type { Node } from "prosemirror-model";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
const iconProps = {
size: "1.25rem",
stroke: 1.5,
};
const controlIconProps = {
size: "1rem",
stroke: 1.5,
};
export function Notebook({ options, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
const [toSaveContent, setToSaveContent] = useState(content);
// TODO: Add check for user permissions
const enabled = !isEditMode;
const [isEditing, setIsEditing] = useState(false);
const { primaryColor } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { mutateAsync } = clientApi.widget.notebook.updateContent.useMutation();
const tControls = useScopedI18n("widget.notebook.controls");
const t = useI18n();
const editor = useEditor(
{
extensions: [
Color,
Highlight.configure({ multicolor: true }),
Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: { default: null },
};
},
}).configure({ inline: true }),
Link.configure({
openOnClick: true,
validate(url) {
return /^https?:\/\//.test(url);
},
}).extend({
addAttributes() {
return {
...this.parent?.(),
target: { default: null },
};
},
}),
StarterKit,
Table.configure({
resizable: true,
lastColumnResizable: false,
}),
TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: undefined,
renderHTML: (attributes) => ({
style: attributes.backgroundColor ? `background-color: ${attributes.backgroundColor}` : undefined,
}),
parseHTML: (element) => element.style.backgroundColor || undefined,
},
};
},
}),
TableHeader,
TableRow,
TaskItem.configure({
nested: true,
onReadOnlyChecked: (node, checked) => {
if (options.allowReadOnlyCheck && enabled) {
const event = new CustomEvent("onReadOnlyCheck", {
detail: { node, checked },
});
dispatchEvent(event);
return true;
}
return false;
},
}),
TaskList.configure({ itemTypeName: "taskItem" }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
TextStyle,
Underline,
],
content,
onUpdate: ({ editor }) => {
setContent(editor.getHTML());
},
onCreate: ({ editor }) => {
editor.setEditable(false);
},
},
[toSaveContent],
);
const handleOnReadOnlyCheck = (event: CustomEventInit<{ node: Node; checked: boolean }>) => {
if (!options.allowReadOnlyCheck) return;
if (!editor) return;
editor.state.doc.descendants((subnode, pos) => {
if (!event.detail) return;
if (!subnode.eq(event.detail.node)) return;
if (subnode.eq(event.detail.node)) {
const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, {
...event.detail.node.attrs,
checked: event.detail.checked,
});
editor.view.dispatch(tr);
setContent(editor.getHTML());
handleContentUpdate(editor.getHTML());
}
});
};
addEventListener("onReadOnlyCheck", handleOnReadOnlyCheck);
const handleEditToggleCallback = (previous: boolean) => {
const current = !previous;
if (!editor) return current;
editor.setEditable(current);
handleContentUpdate(content);
return current;
};
const handleEditCancelCallback = () => {
if (!editor) return false;
editor.setEditable(false);
setContent(toSaveContent);
editor.commands.setContent(toSaveContent);
return false;
};
const handleEditCancel = useCallback(() => {
setIsEditing(handleEditCancelCallback);
}, [setIsEditing, handleEditCancelCallback]);
const handleContentUpdate = (contentUpdate: string) => {
setToSaveContent(contentUpdate);
// This is not available in preview mode
if (boardId && itemId) {
void mutateAsync({ boardId, itemId, content: contentUpdate });
}
};
const handleEditToggle = useCallback(() => {
setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]);
return (
<>
({
root: {
"& .ProseMirror": {
padding: "0 !important",
},
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : "white",
border: "none",
borderRadius: "0.5rem",
display: "flex",
flexDirection: "column",
},
toolbar: {
backgroundColor: "transparent",
padding: "0.5rem",
},
content: {
backgroundColor: "transparent",
padding: "0.5rem",
},
})}
>
{(editor?.isActive("taskList") || editor?.isActive("bulletList") || editor?.isActive("orderedList")) && (
<>
>
)}
{editor?.isActive("table") && (
<>
>
)}
{editor && (
)}
{enabled && (
<>
{isEditing ? : }
{isEditing && (
)}
>
)}
>
);
}
function TextHighlightControl() {
const tControls = useScopedI18n("widget.notebook.controls");
const { editor } = useRichTextEditorContext();
const defaultColor = "transparent";
const getCurrent = useCallback(() => {
return editor?.getAttributes("highlight").color as string | undefined;
}, [editor]);
const update = useCallback(
(value: string) => {
if (value === defaultColor) {
editor?.chain().focus().unsetHighlight().run();
return;
}
editor?.chain().focus().setHighlight({ color: value }).run();
},
[editor, defaultColor],
);
return (
);
}
function TextColorControl() {
const tControls = useScopedI18n("widget.notebook.controls");
const { editor } = useRichTextEditorContext();
const { black, colors } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const defaultColor = colorScheme === "dark" ? colors.dark[0] : black;
const getCurrent = useCallback(() => {
return editor?.getAttributes("textStyle").color as string | undefined;
}, [editor]);
const update = useCallback(
(value: string) => {
if (value === defaultColor) {
editor?.chain().focus().unsetColor().run();
return;
}
editor?.chain().focus().setColor(value).run();
},
[editor, defaultColor],
);
return (
);
}
function ColorCellControl() {
const tControls = useScopedI18n("widget.notebook.controls");
const { editor } = useRichTextEditorContext();
const getCurrent = useCallback(() => {
return editor?.getAttributes("tableCell").backgroundColor as string | undefined;
}, [editor]);
const update = useCallback(
(value: string) => {
editor?.chain().focus().setCellAttribute("backgroundColor", value).run();
},
[editor],
);
return (
);
}
interface ColorControlProps {
defaultColor: string;
getCurrent: () => string | undefined;
update: (value: string) => void;
icon: TablerIcon;
ariaLabel: string;
}
const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel }: ColorControlProps) => {
const { editor } = useRichTextEditorContext();
const [color, setColor] = useState(defaultColor);
const { colors, white } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, { close, toggle }] = useDisclosure(false);
const t = useI18n();
const palette = [
"#000000",
colors.dark[9],
colors.dark[6],
colors.dark[3],
colors.dark[0],
"#FFFFFF",
colors.red[9],
colors.pink[7],
colors.grape[8],
colors.violet[9],
colors.indigo[9],
colors.blue[5],
colors.green[6],
"#09D630",
colors.lime[5],
colors.yellow[5],
"#EB8415",
colors.orange[9],
];
const onSelection = useCallback(() => {
setColor(getCurrent() ?? defaultColor);
}, [getCurrent, defaultColor, setColor]);
useEffect(() => {
editor?.on("selectionUpdate", onSelection);
return () => {
editor?.off("selectionUpdate", onSelection);
};
});
const handleApplyColor = useCallback(() => {
update(color);
close();
}, [color, update, close]);
const handleClearColor = useCallback(() => {
update(defaultColor);
setColor(defaultColor);
close();
}, [update, setColor, close, defaultColor]);
return (
);
};
function EmbedImage() {
const tControls = useScopedI18n("widget.notebook.controls");
const t = useI18n();
const { editor } = useRichTextEditorContext();
const { colors, white } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, { open, close, toggle }] = useDisclosure(false);
const form = useForm({
initialValues: {
src: (editor?.getAttributes("image").src as string | undefined) ?? "",
width: (editor?.getAttributes("image").width as string | undefined) ?? "",
},
});
const handleOpen = useCallback(() => {
form.reset();
open();
}, [form, open]);
const handleSubmit = useCallback(
(values: { src: string; width: string }) => {
editor?.commands.insertContent({
type: "paragraph",
content: [
{
type: "image",
attrs: values,
},
],
});
close();
},
[editor, close],
);
return (
);
}
function TaskListToggle() {
const { editor } = useRichTextEditorContext();
const tControls = useScopedI18n("widget.notebook.controls");
const handleToggleTaskList = useCallback(() => {
editor?.chain().focus().toggleTaskList().run();
}, [editor]);
return (
);
}
function ListIndentIncrease() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
const tControls = useScopedI18n("widget.notebook.controls");
const handleIncreaseIndent = useCallback(() => {
editor?.chain().focus().sinkListItem(itemType).run();
}, [editor, itemType]);
editor?.on("selectionUpdate", ({ editor }) => {
setItemType(editor?.isActive("taskItem") ? "taskItem" : "listItem");
});
return (
);
}
function ListIndentDecrease() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
const tControls = useScopedI18n("widget.notebook.controls");
const handleDecreaseIndent = useCallback(() => {
editor?.chain().focus().liftListItem(itemType).run();
}, [editor, itemType]);
editor?.on("selectionUpdate", ({ editor }) => {
setItemType(editor?.isActive("taskItem") ? "taskItem" : "listItem");
});
return (
);
}
const handleAddColumnBefore = (editor: Editor) => {
editor.commands.addColumnBefore();
};
const TableAddColumnBefore = () => (
);
const handleAddColumnAfter = (editor: Editor) => {
editor.commands.addColumnAfter();
};
const TableAddColumnAfter = () => (
);
const handleRemoveColumn = (editor: Editor) => {
editor.commands.deleteColumn();
};
const TableRemoveColumn = () => (
);
const handleAddRowBefore = (editor: Editor) => {
editor.commands.addRowBefore();
};
const TableAddRowBefore = () => ;
const handleAddRowAfter = (editor: Editor) => {
editor.commands.addRowAfter();
};
const TableAddRowAfter = () => (
);
const handleRemoveRow = (editor: Editor) => {
editor.commands.deleteRow();
};
const TableRemoveRow = () => ;
interface TableControlProps {
title: Exclude;
onClick: (editor: Editor) => void;
icon: TablerIcon;
}
const TableControl = ({ title, onClick, icon: Icon }: TableControlProps) => {
const { editor } = useRichTextEditorContext();
const tControls = useScopedI18n("widget.notebook.controls");
const handleControlClick = useCallback(() => {
if (!editor) return;
onClick(editor);
}, [editor, onClick]);
return (
);
};
function TableToggleMerge() {
const { editor } = useRichTextEditorContext();
const tControls = useScopedI18n("widget.notebook.controls");
const handleToggleMerge = useCallback(() => {
editor?.commands.mergeOrSplit();
}, [editor]);
return (
1}
>
);
}
function TableToggle() {
const { editor } = useRichTextEditorContext();
const isActive = editor?.isActive("table");
const { colors, white } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, { open, close, toggle }] = useDisclosure(false);
const t = useI18n();
const tControls = useScopedI18n("widget.notebook.controls");
const form = useForm({
initialValues: {
cols: 3,
rows: 3,
},
});
const handleOpen = useCallback(() => {
form.reset();
open();
}, [form, open]);
const handleSubmit = useCallback(
(values: { rows: number; cols: number }) => {
editor?.commands.insertTable({ ...values, withHeaderRow: false });
close();
},
[editor, close],
);
const handleControlClick = useCallback(() => {
if (isActive) {
editor?.commands.deleteTable();
} else {
toggle();
}
}, [isActive, editor, toggle]);
return (
{isActive ? : }
);
}