"use client"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { ActionIcon, Box, Button, ColorPicker, ColorSwatch, Group, NumberInput, Popover, ScrollArea, Stack, Text, TextInput, Tooltip, 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 { 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 { 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 { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item"; 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, displayMode, height, }: WidgetComponentProps<"notebook">) { const [content, setContent] = useState(options.content); const previousContentRef = useRef(options.content); const [saveError, setSaveError] = useState(null); const board = useRequiredBoard(); const { data: session } = useSession(); const { hasChangeAccess } = constructBoardPermissions(board, session); const canChange = !isEditMode && hasChangeAccess; const [isEditing, setIsEditing] = useState(false); const canChangeRef = useRef(canChange); const allowReadOnlyCheckRef = useRef(options.allowReadOnlyCheck); const savingRef = useRef(false); const readOnlyCheckEventName = `homarr:notebook-read-only-check:${useId()}`; useEffect(() => { allowReadOnlyCheckRef.current = options.allowReadOnlyCheck; }, [options.allowReadOnlyCheck]); const { primaryColor } = useMantineTheme(); const { colorScheme } = useMantineColorScheme(); const { mutateAsync, isPending: isSaving } = clientApi.widget.notebook.updateContent.useMutation({ scope: { id: `notebook-content:${boardId ?? "preview"}:${itemId ?? "preview"}` }, }); useEffect(() => { canChangeRef.current = canChange && !isSaving; }, [canChange, isSaving]); const tControls = useScopedI18n("widget.notebook.controls"); const t = useI18n(); const handleContentUpdate = useCallback( async (contentUpdate: string) => { savingRef.current = true; canChangeRef.current = false; setSaveError(null); try { if (boardId && itemId) { await mutateAsync({ boardId, itemId, content: contentUpdate }); } previousContentRef.current = contentUpdate; setOptions({ newOptions: { content: contentUpdate } }); return true; } catch (error) { setSaveError(error instanceof Error ? error.message : t("widget.notebook.saveFailed")); return false; } finally { savingRef.current = false; canChangeRef.current = canChange; } }, [boardId, canChange, itemId, mutateAsync, setOptions, t], ); 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, ReadOnlyTaskItem.configure({ nested: true, onReadOnlyChecked: () => { if (!allowReadOnlyCheckRef.current) return false; if (!canChangeRef.current) return false; return true; }, onReadOnlyCheckedAtPosition: (position, checked) => { if (!allowReadOnlyCheckRef.current) return; if (!canChangeRef.current) return; const event = new CustomEvent(readOnlyCheckEventName, { detail: { position, checked }, }); dispatchEvent(event); }, }), 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(isEditing); }, }, [], ); const handleOnReadOnlyCheck = useCallback( (event: Event) => { if (!allowReadOnlyCheckRef.current) return; if (!editor) return; const { detail } = event as CustomEvent<{ position: number; checked: boolean }>; const transaction = createReadOnlyTaskItemTransaction(editor.state, detail.position, detail.checked); if (!transaction) return; editor.view.dispatch(transaction); const nextContent = editor.getHTML(); setContent(nextContent); void handleContentUpdate(nextContent).then((wasSaved) => { if (wasSaved) return; setContent(previousContentRef.current); editor.commands.setContent(previousContentRef.current); }); }, [editor, handleContentUpdate], ); useEffect(() => { addEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck); return () => removeEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck); }, [handleOnReadOnlyCheck, readOnlyCheckEventName]); useEffect(() => { if (isEditing || !editor || options.content === previousContentRef.current) return; previousContentRef.current = options.content; setContent(options.content); editor.commands.setContent(options.content); }, [editor, isEditing, options.content]); const handleEditCancelCallback = useCallback(() => { if (savingRef.current) return true; if (!editor) return false; editor.setEditable(false); setContent(previousContentRef.current); editor.commands.setContent(previousContentRef.current); return false; }, [editor]); const { openConfirmModal } = useConfirmModal(); const handleEditCancel = useCallback(() => { if (savingRef.current) return; 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(async () => { if (!editor || savingRef.current) return; if (!isEditing) { setSaveError(null); editor.setEditable(true); setIsEditing(true); return; } editor.setEditable(false); const wasSaved = await handleContentUpdate(content); if (!wasSaved) { editor.setEditable(true); return; } editor.setEditable(false); setIsEditing(false); }, [content, editor, handleContentUpdate, isEditing]); const handleDoubleClick = useCallback( (event: ReactMouseEvent) => { if (!canChange || isEditing || savingRef.current) 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; } setSaveError(null); editor?.setEditable(true); setIsEditing(true); }, [canChange, editor, isEditing], ); const documentText = editor?.getText().trim() ?? ""; const documentStats = { characters: documentText.length, words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length, }; return ( void handleEditToggle()]]) : undefined } editor={editor} styles={(theme) => ({ 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: height < 120 && displayMode !== "advanced" ? "0.25rem" : "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 && isEditing && !isSaving && ( )} {displayMode === "advanced" && ( {saveError && ( {t("widget.notebook.saveFailed")} )} {t("widget.notebook.documentStats", documentStats)} )} {canChange && ( <> void handleEditToggle()} > {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 ? : }
); }