"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} > {/* No existing icon from tabler, taken from https://icon-sets.iconify.design/fluent/table-cells-merge-24-regular/ */} ); } 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 ? : }
); }