diff --git a/package.json b/package.json index fd5a3c0ba..d16b71fac 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,22 @@ "@tanstack/react-query": "^4.2.1", "@tanstack/react-query-devtools": "^4.24.4", "@tanstack/react-query-persist-client": "^4.28.0", - "@tiptap/extension-link": "^2.0.4", - "@tiptap/pm": "^2.0.4", - "@tiptap/react": "^2.0.4", - "@tiptap/starter-kit": "^2.0.4", + "@tiptap/extension-color": "^2.1.12", + "@tiptap/extension-highlight": "^2.1.12", + "@tiptap/extension-image": "^2.1.12", + "@tiptap/extension-link": "^2.1.12", + "@tiptap/extension-table": "^2.1.12", + "@tiptap/extension-table-cell": "^2.1.12", + "@tiptap/extension-table-header": "^2.1.12", + "@tiptap/extension-table-row": "^2.1.12", + "@tiptap/extension-task-item": "^2.1.12", + "@tiptap/extension-task-list": "^2.1.12", + "@tiptap/extension-text-align": "^2.1.12", + "@tiptap/extension-text-style": "^2.1.12", + "@tiptap/extension-underline": "^2.1.12", + "@tiptap/pm": "^2.1.12", + "@tiptap/react": "^2.1.12", + "@tiptap/starter-kit": "^2.1.12", "@trpc/client": "^10.37.1", "@trpc/next": "^10.37.1", "@trpc/react-query": "^10.37.1", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index a28e7cf51..31f4af7b1 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1,5 +1,7 @@ { "save": "Save", + "apply": "Apply", + "insert": "Insert", "about": "About", "cancel": "Cancel", "close": "Close", @@ -40,5 +42,14 @@ "medium": "medium", "large": "large" }, - "seeMore": "See more..." + "seeMore": "See more...", + "position": { + "left": "Left", + "center": "Center", + "right": "Right" + }, + "attributes": { + "width": "Width", + "height": "Height" + } } \ No newline at end of file diff --git a/public/locales/en/modules/notebook.json b/public/locales/en/modules/notebook.json index 516ae4ae0..165aec21f 100644 --- a/public/locales/en/modules/notebook.json +++ b/public/locales/en/modules/notebook.json @@ -7,9 +7,53 @@ "showToolbar": { "label": "Show the toolbar to help you write markdown" }, + "allowReadOnlyCheck": { + "label": "Allow check in read only mode" + }, "content": { "label": "The content of the notebook" } } + }, + "card": { + "controls": { + "bold": "Bold", + "italic": "Italic", + "strikethrough": "Strikethrough", + "underline": "Underline", + "colorText": "Color text", + "colorHighlight": "Colored highlight text", + "code": "Code", + "clear": "Clear formatting", + "heading": "Heading {{level}}", + "align": "Align text: {{position}}", + "blockquote": "Blockquote", + "horizontalLine": "Horizontal line", + "bulletList": "Bullet list", + "orderedList": "Ordered list", + "checkList": "Check list", + "increaseIndent": "Increase Indent", + "decreaseIndent": "Decrease Indent", + "link": "Link", + "unlink": "Remove link", + "image": "Embed Image", + "addTable": "Add table", + "deleteTable": "Delete Table", + "colorCell": "Color Cell", + "mergeCell": "Toggle cell merging", + "addColumnLeft": "Add column before", + "addColumnRight": "Add column after", + "deleteColumn": "Delete column", + "addRowTop": "Add row before", + "addRowBelow": "Add row after", + "deleteRow": "Delete row" + }, + "modals": { + "clearColor": "Clear color", + "source": "Source", + "widthPlaceholder": "Value in % or pixels", + "columns": "Columns", + "rows": "Rows" + } } } \ No newline at end of file diff --git a/src/components/Dashboard/Modals/ChangePosition/ChangePositionModal.tsx b/src/components/Dashboard/Modals/ChangePosition/ChangePositionModal.tsx index 853870fc4..3a4464d23 100644 --- a/src/components/Dashboard/Modals/ChangePosition/ChangePositionModal.tsx +++ b/src/components/Dashboard/Modals/ChangePosition/ChangePositionModal.tsx @@ -89,7 +89,7 @@ export const ChangePositionModal = ({ data={widthData} max={24} min={1} - label={t('layout/modals/change-position:width')} + label={t('common:attributes.width')} description={t('layout/modals/change-position:betweenXandY', { min: widthData.at(0)?.label, max: widthData.at(-1)?.label, @@ -104,7 +104,7 @@ export const ChangePositionModal = ({ data={heightData} max={24} min={1} - label={t('layout/modals/change-position:height')} + label={t('common:attributes.height')} description={t('layout/modals/change-position:betweenXandY', { min: heightData.at(0)?.label, max: heightData.at(-1)?.label, diff --git a/src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts b/src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts index d135dc862..5d9c863f2 100644 --- a/src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts +++ b/src/components/Dashboard/Wrappers/gridstack/init-gridstack.ts @@ -50,6 +50,7 @@ export const initializeGridstack = ( // Add listener for moving items around in a wrapper grid.on('change', (_, el) => { const nodes = el as GridStackNode[]; + if (!nodes) return; const firstNode = nodes.at(0); if (!firstNode) return; events.onChange(firstNode); @@ -58,6 +59,7 @@ export const initializeGridstack = ( // Add listener for moving items in config from one wrapper to another grid.on('added', (_, el) => { const nodes = el as GridStackNode[]; + if (!nodes) return; const firstNode = nodes.at(0); if (!firstNode) return; events.onAdd(firstNode); diff --git a/src/styles/global.scss b/src/styles/global.scss index 99d51cf6d..46153f4b5 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -104,4 +104,78 @@ transparent 6px ); background-size: 60px 60px; +} + +.tiptap { + hr { + border-top-style: double; + } + + ul[data-type="taskList"] { + padding-left: 17px; + li { + list-style-type: none; + display: flex; + gap: 8px; + } + } + + img { + max-width: 100%; + &.ProseMirror-selectednode { + outline: 3px solid rgba(0, 65, 198, 0.8); + } + } + + table { + border-collapse: collapse; + margin: 0; + overflow: hidden; + table-layout: fixed; + width: 100%; + + td { + border-color: var(--mantine-color-gray-5) !important; + border-width: 1px !important; + border-style: solid !important; + box-sizing: border-box; + min-width: 1em; + padding: 3px 5px; + position: relative; + vertical-align: top; + + > * { + margin-bottom: 0; + } + } + + .selectedCell:after { + background: rgba(200, 200, 200, 0.4); + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + pointer-events: none; + position: absolute; + z-index: 2; + } + + p { + margin: 0; + } + } + + &[contenteditable="true"].resize-cursor { + cursor: ew-resize; + } + + &[contenteditable="false"].resize-cursor { + pointer-events: none; + } +} + +.tableWrapper { + padding: 1rem 0; + overflow-x: auto; } \ No newline at end of file diff --git a/src/widgets/dnshole/DnsHoleControls.tsx b/src/widgets/dnshole/DnsHoleControls.tsx index 01967a9cf..a695455a3 100644 --- a/src/widgets/dnshole/DnsHoleControls.tsx +++ b/src/widgets/dnshole/DnsHoleControls.tsx @@ -99,8 +99,6 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) { ); } - console.log(data); - type getDnsStatusAcc = { enabled: string[]; disabled: string[]; diff --git a/src/widgets/notebook/NotebookEditor.tsx b/src/widgets/notebook/NotebookEditor.tsx index 4ec8ccb69..b52c4899c 100644 --- a/src/widgets/notebook/NotebookEditor.tsx +++ b/src/widgets/notebook/NotebookEditor.tsx @@ -1,63 +1,193 @@ -import { ActionIcon, ScrollArea } from '@mantine/core'; -import { useDebouncedValue } from '@mantine/hooks'; -import { Link, RichTextEditor } from '@mantine/tiptap'; -import { IconEdit, IconEditOff } from '@tabler/icons-react'; +import { + ActionIcon, + Button, + ColorPicker, + ColorSwatch, + Group, + NumberInput, + Popover, + ScrollArea, + Stack, + TextInput, + useMantineTheme, +} from '@mantine/core'; +import { useDisclosure, useInputState } 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 { BubbleMenu, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; -import { useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { Dispatch, SetStateAction, useState } from 'react'; import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore'; import { useConfigContext } from '~/config/provider'; import { useConfigStore } from '~/config/store'; -import { useColorTheme } from '~/tools/color'; import { api } from '~/utils/api'; import { WidgetLoading } from '../loading'; import { INotebookWidget } from './NotebookWidgetTile'; -Link.configure({ - openOnClick: true, -}); - export function Editor({ widget }: { widget: INotebookWidget }) { const [content, setContent] = useState(widget.properties.content); + const [toSaveContent, setToSaveContent] = useState(content); const { enabled } = useEditModeStore(); const [isEditing, setIsEditing] = useState(false); const { config, name: configName } = useConfigContext(); const updateConfig = useConfigStore((x) => x.updateConfig); - const { primaryColor } = useColorTheme(); + const { primaryColor } = useMantineTheme(); const { mutateAsync } = api.notebook.update.useMutation(); - const [debouncedContent] = useDebouncedValue(content, 500); + const { t } = useTranslation(['modules/notebook', 'common']); - const editor = useEditor({ - extensions: [ - StarterKit, - Link.configure({ - validate(url) { - return /^https?:\/\//.test(url); - }, - }), - ], - content, - editable: false, - onUpdate: (e) => { - setContent(e.editor.getHTML()); + 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); + }, + }), + 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 (widget.properties.allowReadOnlyCheck) { + const event = new CustomEvent('onReadOnlyCheck', { detail: { node, checked } }); + dispatchEvent(event); + } + return widget.properties.allowReadOnlyCheck; + }, + }), + TaskList.configure({ itemTypeName: 'taskItem' }), + TextAlign.configure({ types: ['heading', 'paragraph'] }), + TextStyle, + Underline, + ], + content, + onUpdate: (e) => { + setContent(e.editor.getHTML()); + }, + onCreate: (e) => { + e.editor.setEditable(false); + }, }, - }); + [toSaveContent] + ); + + const handleOnReadOnlyCheck = (event: CustomEventInit) => { + if (widget.properties.allowReadOnlyCheck && !!editor) { + editor.state.doc.descendants((subnode, pos) => { + 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()); + handleConfigUpdate(editor.getHTML()); + } + }); + } + }; + + addEventListener('onReadOnlyCheck', handleOnReadOnlyCheck); const handleEditToggle = (previous: boolean) => { const current = !previous; if (!editor) return current; editor.setEditable(current); + handleConfigUpdate(content); + + return current; + }; + + const handleEditCancel = () => { + if (!editor) return false; + editor.setEditable(false); + + setContent(toSaveContent); + editor.commands.setContent(toSaveContent); + + return false; + }; + + const handleConfigUpdate = (contentUpdate: string) => { + setToSaveContent(contentUpdate); updateConfig( configName!, (previous) => { const currentWidget = previous.widgets.find((x) => x.id === widget.id); - currentWidget!.properties.content = debouncedContent; + currentWidget!.properties.content = contentUpdate; return { ...previous, @@ -72,11 +202,9 @@ export function Editor({ widget }: { widget: INotebookWidget }) { void mutateAsync({ configName: configName!, - content: debouncedContent, + content: contentUpdate, widgetId: widget.id, }); - - return current; }; if (!config || !configName) return ; @@ -115,63 +243,681 @@ export function Editor({ widget }: { widget: INotebookWidget }) { }} > - - - - - + + + + + + + + - - - - + + + + - - - - + + + - - + + + + + + + + + {(editor?.isActive('taskList') || + editor?.isActive('bulletList') || + editor?.isActive('orderedList')) && ( + <> + + + + )} + + + + + + + + + + + {editor?.isActive('table') && ( + <> + + + + + + + + + + )} {editor && ( - - - + + + )} - + {!enabled && ( - setIsEditing(handleEditToggle)} - > - {isEditing ? : } - + <> + setIsEditing(handleEditToggle)} + > + {isEditing ? : } + + {isEditing && ( + setIsEditing(handleEditCancel)} + > + + + )} + )} ); } + +function ColoredHighlight() { + const { editor } = useRichTextEditorContext(); + const defaultColor = 'transparent'; + const [color, setColor] = useState(defaultColor); + + const { t } = useTranslation(['modules/notebook']); + + return ( + } + selectionUpdate={() => { + setColor(editor.getAttributes('highlight').color ?? defaultColor); + }} + onSaveHandle={() => { + editor.chain().focus().setHighlight({ color: color }).run(); + }} + onUnsetHandle={() => { + editor.chain().focus().unsetHighlight().run(); + setColor(defaultColor); + }} + /> + ); +} + +function ColoredText() { + const { editor } = useRichTextEditorContext(); + const { black, colors, colorScheme } = useMantineTheme(); + const defaultColor = colorScheme === 'dark' ? colors.dark[0] : black; + const [color, setColor] = useState(defaultColor); + + const { t } = useTranslation(['modules/notebook']); + + return ( + } + selectionUpdate={() => { + setColor(editor.getAttributes('textStyle').color ?? defaultColor); + }} + onSaveHandle={() => { + editor.chain().focus().setColor(color).run(); + }} + onUnsetHandle={() => { + editor.chain().focus().unsetColor().run(); + setColor(defaultColor); + }} + /> + ); +} + +function ColoredCell() { + const { editor } = useRichTextEditorContext(); + const defaultColor = 'transparent'; + const [color, setColor] = useState(defaultColor); + + const { t } = useTranslation(['modules/notebook']); + + return ( + } + selectionUpdate={() => { + setColor(editor.getAttributes('tableCell').backgroundColor ?? defaultColor); + }} + onSaveHandle={() => { + editor.chain().focus().setCellAttribute('backgroundColor', color).run(); + }} + onUnsetHandle={() => { + editor.chain().focus().setCellAttribute('backgroundColor', undefined).run(); + setColor(defaultColor); + }} + /> + ); +} + +interface ColoredControlProps { + color: string; + setColor: Dispatch>; + hoverText: string; + icon: JSX.Element; + selectionUpdate: () => any; + onSaveHandle: () => any; + onUnsetHandle: () => any; +} + +function ColoredControl({ + color, + setColor, + hoverText, + icon, + selectionUpdate, + onSaveHandle, + onUnsetHandle, +}: ColoredControlProps) { + const { editor } = useRichTextEditorContext(); + const { colors, colorScheme, white } = useMantineTheme(); + const [opened, { close, toggle }] = useDisclosure(false); + + const { t } = useTranslation(['modules/notebook']); + + 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], + ]; + + editor?.on('selectionUpdate', selectionUpdate); + + return ( + + + + + {icon} + + + + + + + + + + + + { + onSaveHandle(); + close(); + }} + > + + + { + onUnsetHandle(); + close(); + }} + > + + + + + + + ); +} + +function EmbedImage() { + const { editor } = useRichTextEditorContext(); + const { colors, colorScheme, white } = useMantineTheme(); + const [opened, { open, close, toggle }] = useDisclosure(false); + const [src, setSrc] = useInputState(''); + const [width, setWidth] = useInputState(''); + + const { t } = useTranslation(['modules/notebook']); + + function setImage() { + editor.commands.insertContent({ + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { + width: width, + src: src, + }, + }, + ], + }); + close(); + } + + return ( + { + close(); + setSrc(''); + setWidth(''); + }} + onOpen={() => { + open(); + setSrc(editor == null ? '' : editor.getAttributes('image').src); + setWidth(editor == null ? '' : editor.getAttributes('image').width); + }} + position="left" + styles={{ + dropdown: { + backgroundColor: colorScheme === 'dark' ? colors.dark[7] : white, + }, + }} + trapFocus + > + + + + + + + + { + if (e.key === 'Enter') { + e.preventDefault(); + setImage(); + } + }} + placeholder="https://example.com/" + /> + { + if (e.key === 'Enter') { + e.preventDefault(); + setImage(); + } + }} + placeholder={t('card.modals.widthPlaceholder')!} + /> +