"use client"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActionIcon, Box, Button, ColorPicker, ColorSwatch, Group, NumberInput, Popover, ScrollArea, Stack, TextInput, useMantineColorScheme, useMantineTheme, } from "@mantine/core"; import { getHotkeyHandler, 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, IconTextDirectionLtr, IconTextDirectionRtl, IconX, } from "@tabler/icons-react"; import { Color } from "@tiptap/extension-color"; import { Highlight } from "@tiptap/extension-highlight"; import { Image } from "@tiptap/extension-image"; import { Placeholder } from "@tiptap/extension-placeholder"; 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 type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; 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"; import { NotebookTextDirection, setTextDirection } from "./text-direction"; import "@mantine/tiptap/styles.css"; import "./notebook.css"; import { useSession } from "@homarr/auth/client"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { useRequiredBoard } from "@homarr/boards/context"; import { hotkeys } from "@homarr/definitions"; import { useConfirmModal } from "@homarr/modals"; const iconProps = { size: 30, stroke: 1.5, }; const controlIconProps = { size: 20, stroke: 1.5, }; export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) { const [content, setContent] = useState(options.content); const previousContentRef = useRef(content); const board = useRequiredBoard(); const { data: session } = useSession(); const { hasChangeAccess } = constructBoardPermissions(board, session); const canChange = !isEditMode && hasChangeAccess; 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: [ Placeholder.configure({ placeholder: `${t("widget.notebook.placeholder")}…`, }), 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 }, }; }, }), // we use a custom link implementation from mantine StarterKit.configure({ link: false }), 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) return false; if (!canChange) return false; const event = new CustomEvent("onReadOnlyCheck", { detail: { node, checked }, }); dispatchEvent(event); return true; }, }), TaskList.configure({ itemTypeName: "taskItem" }), TextAlign.configure({ types: ["heading", "paragraph"] }), NotebookTextDirection, TextStyle, ], shouldRerenderOnTransaction: true, immediatelyRender: false, content, onUpdate: ({ editor }) => { setContent(editor.getHTML()); }, onCreate: ({ editor }) => { editor.setEditable(false); }, }, [], ); 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; 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 handleContentUpdate = useCallback( (contentUpdate: string) => { previousContentRef.current = contentUpdate; setOptions({ newOptions: { content: contentUpdate } }); // This is not available in preview mode if (boardId && itemId) { void mutateAsync({ boardId, itemId, content: contentUpdate }); } }, [boardId, itemId, mutateAsync, setOptions], ); const handleEditToggleCallback = useCallback( (previous: boolean) => { const current = !previous; if (!editor) return current; editor.setEditable(current); if (previous) { handleContentUpdate(content); } return current; }, [content, editor, handleContentUpdate], ); const handleEditCancelCallback = useCallback(() => { if (!editor) return false; editor.setEditable(false); setContent(previousContentRef.current); editor.commands.setContent(previousContentRef.current); return false; }, [editor]); const { openConfirmModal } = useConfirmModal(); const handleEditCancel = useCallback(() => { openConfirmModal({ title: t("widget.notebook.dismiss.title"), children: t("widget.notebook.dismiss.message"), labels: { confirm: t("widget.notebook.dismiss.action.discard"), cancel: t("widget.notebook.dismiss.action.keepEditing"), }, onConfirm: () => { setIsEditing(handleEditCancelCallback); }, }); }, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]); const handleEditToggle = useCallback(() => { setIsEditing(handleEditToggleCallback); }, [setIsEditing, handleEditToggleCallback]); const handleDoubleClick = useCallback( (event: ReactMouseEvent) => { if (!canChange || isEditing) return; // Ignore double-clicks bubbling up from interactive controls (e.g. the // edit/save ActionIcon), which would otherwise toggle edit mode twice. if (event.target instanceof Element && event.target.closest("button, a")) { return; } setIsEditing(handleEditToggleCallback); }, [canChange, isEditing, setIsEditing, handleEditToggleCallback], ); return ( ({ root: { backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : "white", border: "none", borderRadius: "0.5rem", display: "flex", flexDirection: "column", height: "100%", }, toolbar: { backgroundColor: "transparent", padding: "0.5rem", }, content: { backgroundColor: "transparent", padding: "0.5rem", height: "100%", }, typographyStylesProvider: { height: "100%", }, })} > editor && setTextDirection(editor, "ltr").run()} > editor && setTextDirection(editor, "rtl").run()} > {(Boolean(editor?.isActive("taskList")) || Boolean(editor?.isActive("bulletList")) || Boolean(editor?.isActive("orderedList"))) && ( <> )} {editor?.isActive("table") && ( <> )} {editor && ( )} {canChange && ( <> {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 ? : }
); }