feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { getNotebookDisplay } from "./display";
describe("Notebook advanced disclosure", () => {
test("shows the complete toolbar and document stats regardless of compact preferences", () => {
expect(
getNotebookDisplay({ height: 100, isAdvanced: true, isEditing: true, isSaving: false, showToolbar: false }),
).toEqual({ showToolbar: true, toolbarMaxHeight: undefined, showDocumentStats: true });
});
test("does not expose editing controls outside an active editable session", () => {
expect(
getNotebookDisplay({ height: 300, isAdvanced: true, isEditing: false, isSaving: false, showToolbar: true })
.showToolbar,
).toBe(false);
});
});
+13
View File
@@ -0,0 +1,13 @@
interface NotebookDisplayInput {
height: number;
isAdvanced: boolean;
isEditing: boolean;
isSaving: boolean;
showToolbar: boolean;
}
export const getNotebookDisplay = ({ height, isAdvanced, isEditing, isSaving, showToolbar }: NotebookDisplayInput) => ({
showToolbar: isEditing && !isSaving && (isAdvanced || showToolbar),
toolbarMaxHeight: isAdvanced ? undefined : height < 180 ? "4.5rem" : "10rem",
showDocumentStats: isAdvanced || height >= 180,
});
+65 -3
View File
@@ -1,5 +1,6 @@
.tiptap hr {
border-top-style: double;
border-top-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.tiptap ul[data-type="taskList"] {
@@ -20,6 +21,10 @@
outline: 3px solid rgba(0, 65, 198, 0.8);
}
.tiptap table :where(tr) {
background-color: transparent;
}
.tiptap table {
border-collapse: collapse;
margin: 0;
@@ -28,8 +33,8 @@
width: 100%;
}
.tiptap table td {
border-color: var(--mantine-color-gray-5) !important;
.tiptap table :where(th, td) {
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)) !important;
border-width: 1px !important;
border-style: solid !important;
box-sizing: border-box;
@@ -39,7 +44,7 @@
vertical-align: top;
}
.tiptap table td > * {
.tiptap table :where(th, td) > * {
margin-bottom: 0;
}
@@ -66,7 +71,64 @@
.tiptap[contenteditable="false"].resize-cursor {
pointer-events: none;
}
.homarr-notebook-toolbar-group {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
}
.homarr-notebook .tiptap :where(pre, blockquote) {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
}
.homarr-notebook .tiptap :where(code, kbd) {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.homarr-notebook .tiptap :where(pre) :where(code) {
background-color: transparent;
}
.homarr-notebook[data-notebook-compact="true"] .tiptap :where(h1, h2, h3):not([style*="font-size"]) {
font-size: 1.125rem;
}
.homarr-notebook[data-notebook-compact="true"] .tiptap :where(h1, h2, h3):not([style*="line-height"]) {
line-height: 1.15;
}
.ProseMirror {
height: 100%;
}
.homarr-notebook-action {
opacity: 0;
transform: translateX(2px);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.homarr-notebook:hover .homarr-notebook-action,
.homarr-notebook:focus-within .homarr-notebook-action,
.homarr-notebook-action[data-visible="true"] {
opacity: 1;
transform: none;
}
@media (any-pointer: coarse) {
.homarr-notebook-action {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.homarr-notebook-action {
transition: none;
}
}
.homarr-notebook[data-notebook-compact="true"] .tiptap img {
max-height: 2rem;
object-fit: contain;
}
+332 -347
View File
@@ -1,7 +1,7 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
ActionIcon,
Box,
@@ -9,11 +9,12 @@ import {
ColorPicker,
ColorSwatch,
Group,
NumberInput,
Popover,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
@@ -22,9 +23,6 @@ import { Link, RichTextEditor, useRichTextEditorContext } from "@mantine/tiptap"
import {
IconCheck,
IconCircleOff,
IconColumnInsertLeft,
IconColumnInsertRight,
IconColumnRemove,
IconDeviceFloppy,
IconEdit,
IconHighlight,
@@ -34,16 +32,12 @@ import {
IconLetterA,
IconListCheck,
IconPhoto,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconTableOff,
IconTablePlus,
IconTextDirectionLtr,
IconTextDirectionRtl,
IconX,
} from "@tabler/icons-react";
import { Color } from "@tiptap/extension-color";
import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details";
import { Highlight } from "@tiptap/extension-highlight";
import { Image } from "@tiptap/extension-image";
import { Placeholder } from "@tiptap/extension-placeholder";
@@ -51,23 +45,23 @@ 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 { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { InlineConfirmActionIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
import { getNotebookDisplay } from "./display";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css";
@@ -77,7 +71,6 @@ 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,
@@ -89,9 +82,20 @@ const controlIconProps = {
stroke: 1.5,
};
export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
export function Notebook({
options,
setOptions,
isEditMode,
boardId,
itemId,
width,
height,
displayScale = 1,
displayMode = "compact",
}: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(content);
const previousContentRef = useRef(options.content);
const [saveError, setSaveError] = useState<string | null>(null);
const board = useRequiredBoard();
const { data: session } = useSession();
@@ -99,22 +103,66 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
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"}` },
});
const { mutateAsync } = clientApi.widget.notebook.updateContent.useMutation();
useEffect(() => {
canChangeRef.current = canChange && !isSaving;
}, [canChange, isSaving]);
const tControls = useScopedI18n("widget.notebook.controls");
const t = useI18n();
const tControls = useI18n("widget.notebook.controls");
const t = useI18n("widget.notebook");
const tCommon = useI18n("common");
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 {
setSaveError(t("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")}…`,
placeholder: `${t("placeholder")}…`,
}),
Color,
Details.configure({
renderToggleButton: ({ element, isOpen }) => {
element.setAttribute("aria-label", tControls(isOpen ? "collapseDetails" : "expandDetails"));
},
}),
DetailsSummary,
DetailsContent,
Highlight.configure({ multicolor: true }),
Image.extend({
addAttributes() {
@@ -159,17 +207,20 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
}),
TableHeader,
TableRow,
TaskItem.configure({
ReadOnlyTaskItem.configure({
nested: true,
onReadOnlyChecked: (node, checked) => {
if (!options.allowReadOnlyCheck) return false;
if (!canChange) return false;
const event = new CustomEvent("onReadOnlyCheck", {
detail: { node, checked },
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);
return true;
},
}),
TaskList.configure({ itemTypeName: "taskItem" }),
@@ -184,62 +235,46 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setContent(editor.getHTML());
},
onCreate: ({ editor }) => {
editor.setEditable(false);
editor.setEditable(isEditing);
},
},
[],
);
const handleOnReadOnlyCheck = (event: CustomEventInit<{ node: Node; checked: boolean }>) => {
if (!options.allowReadOnlyCheck) return;
if (!editor) return;
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.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(transaction);
const nextContent = editor.getHTML();
setContent(nextContent);
void handleContentUpdate(nextContent).then((wasSaved) => {
if (wasSaved) return;
setContent(previousContentRef.current);
editor.commands.setContent(previousContentRef.current);
});
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],
[editor, handleContentUpdate],
);
const handleEditToggleCallback = useCallback(
(previous: boolean) => {
const current = !previous;
if (!editor) return current;
editor.setEditable(current);
useEffect(() => {
addEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
return () => removeEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
}, [handleOnReadOnlyCheck, readOnlyCheckEventName]);
if (previous) {
handleContentUpdate(content);
}
return current;
},
[content, editor, handleContentUpdate],
);
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);
@@ -249,62 +284,120 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
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]);
if (savingRef.current) return;
setIsEditing(handleEditCancelCallback);
}, [setIsEditing, handleEditCancelCallback]);
const handleEditToggle = useCallback(() => {
setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]);
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<HTMLDivElement>) => {
if (!canChange || isEditing) return;
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;
}
setIsEditing(handleEditToggleCallback);
setSaveError(null);
editor?.setEditable(true);
setIsEditing(true);
},
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
[canChange, editor, isEditing],
);
const documentText = editor?.getText().trim() ?? "";
const documentStats = {
characters: documentText.length,
words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length,
};
let layoutScale = 1;
if (displayMode !== "advanced" && Number.isFinite(displayScale) && displayScale > 0) {
layoutScale = displayScale;
}
const renderedWidth = width * layoutScale;
const renderedHeight = height * layoutScale;
const compactSurface = renderedWidth < 280 || renderedHeight < 240;
const display = getNotebookDisplay({
height: renderedHeight,
isAdvanced: displayMode === "advanced",
isEditing,
isSaving,
showToolbar: options.showToolbar,
});
const showDocumentStats = display.showDocumentStats && (displayMode === "advanced" || renderedWidth >= 280);
return (
<Box h="100%" onDoubleClick={handleDoubleClick}>
<Box
className="homarr-notebook"
data-notebook-compact={(displayMode === "compact" && compactSurface) || undefined}
h="100%"
onDoubleClick={handleDoubleClick}
style={{ display: "flex", flexDirection: "column", minHeight: 0, position: "relative" }}
>
<RichTextEditor
p={0}
mt={0}
h="100%"
onKeyDown={isEditing ? getHotkeyHandler([[hotkeys.saveNotebook, handleEditToggle]]) : undefined}
h="auto"
onKeyDown={
isEditing && !isSaving ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined
}
editor={editor}
styles={(theme) => ({
labels={{
detailsControlLabel: tControls("details"),
tableInsertControlLabel: tControls("insertTable"),
tableInsertLabel: (columns, rows) =>
tControls("insertTableSize", { columns: columns.toString(), rows: rows.toString() }),
tableDeleteControlLabel: tControls("deleteTable"),
tableColumnBeforeControlLabel: tControls("addColumnLeft"),
tableColumnAfterControlLabel: tControls("addColumnRight"),
tableColumnDeleteControlLabel: tControls("deleteColumn"),
tableRowBeforeControlLabel: tControls("addRowTop"),
tableRowAfterControlLabel: tControls("addRowBelow"),
tableRowDeleteControlLabel: tControls("deleteRow"),
tableToggleHeaderRowControlLabel: tControls("toggleHeaderRow"),
tableToggleHeaderColumnControlLabel: tControls("toggleHeaderColumn"),
tableMergeCellsControlLabel: tControls("mergeCells"),
tableSplitCellControlLabel: tControls("splitCell"),
}}
styles={() => ({
root: {
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : "white",
backgroundColor: "transparent",
border: "none",
borderRadius: "0.5rem",
display: "flex",
flexDirection: "column",
height: "100%",
flex: "1 1 auto",
height: "auto",
minHeight: 0,
},
toolbar: {
backgroundColor: "transparent",
borderColor: "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))",
padding: "0.5rem",
},
content: {
backgroundColor: "transparent",
padding: "0.5rem",
fontSize: "var(--mantine-font-size-md)",
padding: renderedHeight < 120 ? "0.25rem" : "0.5rem",
height: "100%",
},
typographyStylesProvider: {
@@ -314,10 +407,12 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
>
<RichTextEditor.Toolbar
style={{
display: isEditing && options.showToolbar === true ? "flex" : "none",
display: display.showToolbar ? "flex" : "none",
maxHeight: display.toolbarMaxHeight,
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
}}
>
<RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Bold title={tControls("bold")} />
<RichTextEditor.Italic title={tControls("italic")} />
<RichTextEditor.Strikethrough title={tControls("strikethrough")} />
@@ -328,27 +423,27 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
<RichTextEditor.ClearFormatting title={tControls("clear")} />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.H1 title={tControls("heading", { level: "1" })} />
<RichTextEditor.H2 title={tControls("heading", { level: "2" })} />
<RichTextEditor.H3 title={tControls("heading", { level: "3" })} />
<RichTextEditor.H4 title={tControls("heading", { level: "4" })} />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.AlignLeft
title={tControls("align", {
position: t("widget.notebook.align.left"),
position: t("align.left"),
})}
/>
<RichTextEditor.AlignCenter
title={tControls("align", {
position: t("widget.notebook.align.center"),
position: t("align.center"),
})}
/>
<RichTextEditor.AlignRight
title={tControls("align", {
position: t("widget.notebook.align.right"),
position: t("align.right"),
})}
/>
<RichTextEditor.Control
@@ -369,12 +464,13 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
</RichTextEditor.Control>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Blockquote title={tControls("blockquote")} />
<RichTextEditor.Hr title={tControls("horizontalLine")} />
<RichTextEditor.Details title={tControls("details")} />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.BulletList title={tControls("bulletList")} />
<RichTextEditor.OrderedList title={tControls("orderedList")} />
<TaskListToggle />
@@ -388,34 +484,39 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
)}
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Link title={tControls("link")} />
<RichTextEditor.Unlink title={tControls("unlink")} />
<EmbedImage />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<TableToggle />
{editor?.isActive("table") && (
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
{editor?.isActive("table") ? (
<>
<RichTextEditor.TableDelete title={tControls("deleteTable")} />
<ColorCellControl />
<TableToggleMerge />
<TableAddColumnBefore />
<TableAddColumnAfter />
<TableRemoveColumn />
<TableAddRowBefore />
<TableAddRowAfter />
<TableRemoveRow />
<RichTextEditor.TableColumnBefore title={tControls("addColumnLeft")} />
<RichTextEditor.TableColumnAfter title={tControls("addColumnRight")} />
<RichTextEditor.TableColumnDelete title={tControls("deleteColumn")} />
<RichTextEditor.TableRowBefore title={tControls("addRowTop")} />
<RichTextEditor.TableRowAfter title={tControls("addRowBelow")} />
<RichTextEditor.TableRowDelete title={tControls("deleteRow")} />
<RichTextEditor.TableToggleHeaderRow title={tControls("toggleHeaderRow")} />
<RichTextEditor.TableToggleHeaderColumn title={tControls("toggleHeaderColumn")} />
<RichTextEditor.TableMergeCells title={tControls("mergeCells")} />
<RichTextEditor.TableSplitCell title={tControls("splitCell")} />
</>
) : (
<RichTextEditor.TableInsert title={tControls("insertTable")} withHeaderRow={false} />
)}
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Undo />
<RichTextEditor.Redo />
</RichTextEditor.ControlsGroup>
</RichTextEditor.Toolbar>
{editor && (
{editor && isEditing && !isSaving && (
<BubbleMenu editor={editor}>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold title={tControls("bold")} />
@@ -426,64 +527,95 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
)}
<ScrollArea
mih="4rem"
offsetScrollbars
pl={12}
pt={12}
pl={compactSurface ? 4 : 12}
pt={compactSurface ? 4 : 12}
styles={{
root: {
flex: "1 1 auto",
height: "100%",
minHeight: 0,
},
content: {
height: "100%",
minHeight: 0,
},
}}
>
<RichTextEditor.Content />
</ScrollArea>
</RichTextEditor>
{(saveError || showDocumentStats) && (
<Group
w="100%"
justify="flex-end"
gap="xs"
px={8}
pb={4}
style={{ flex: "0 0 auto", pointerEvents: saveError ? undefined : "none" }}
>
{saveError && (
<Tooltip label={saveError} multiline>
<Text
size="xs"
c="red"
lineClamp={1}
maw={320}
tabIndex={0}
aria-label={`${t("saveFailed")}. ${saveError}`}
>
{t("saveFailed")}
</Text>
</Tooltip>
)}
{showDocumentStats && (
<Text size="xs" c="dimmed">
{t("documentStats", documentStats)}
</Text>
)}
</Group>
)}
{canChange && (
<>
<Stack pos="absolute" top={7} right={7} gap={7} style={{ zIndex: 1 }}>
<ActionIcon
title={isEditing ? t("common.action.save") : t("common.action.edit")}
style={{
zIndex: 1,
}}
top={7}
right={7}
pos="absolute"
className={`homarr-notebook-action ${actionTargetClasses.root}`}
data-visible={isEditing || undefined}
title={isEditing ? tCommon("action.save") : tCommon("action.edit")}
aria-label={isEditing ? tCommon("action.save") : tCommon("action.edit")}
color={primaryColor}
variant="light"
size={30}
onClick={handleEditToggle}
loading={isSaving}
disabled={isSaving}
onClick={() => void handleEditToggle()}
>
{isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />}
</ActionIcon>
{isEditing && (
<ActionIcon
title={t("common.action.cancel")}
style={{
zIndex: 1,
}}
top={44}
right={7}
pos="absolute"
<InlineConfirmActionIcon
className={`homarr-notebook-action ${actionTargetClasses.root}`}
data-visible
title={tCommon("action.cancel")}
aria-label={tCommon("action.cancel")}
confirmLabel={t("dismiss.message")}
confirmationAriaLabel={t("dismiss.action.discard")}
confirmationChildren={<IconCheck {...iconProps} />}
onConfirm={handleEditCancel}
color={primaryColor}
variant="light"
size={30}
onClick={handleEditCancel}
disabled={isSaving}
>
<IconX {...iconProps} />
</ActionIcon>
</InlineConfirmActionIcon>
)}
</>
</Stack>
)}
</Box>
);
}
function TextHighlightControl() {
const tControls = useScopedI18n("widget.notebook.controls");
const tControls = useI18n("widget.notebook.controls");
const { editor } = useRichTextEditorContext();
const defaultColor = "transparent";
@@ -514,7 +646,7 @@ function TextHighlightControl() {
}
function TextColorControl() {
const tControls = useScopedI18n("widget.notebook.controls");
const tControls = useI18n("widget.notebook.controls");
const { editor } = useRichTextEditorContext();
const { black, colors } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
@@ -547,7 +679,7 @@ function TextColorControl() {
}
function ColorCellControl() {
const tControls = useScopedI18n("widget.notebook.controls");
const tControls = useI18n("widget.notebook.controls");
const { editor } = useRichTextEditorContext();
const getCurrent = useCallback(() => {
@@ -586,7 +718,8 @@ const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel
const { colors, white } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, { close, toggle }] = useDisclosure(false);
const t = useI18n();
const tCommon = useI18n("common");
const tNotebook = useI18n("widget.notebook");
const palette = [
"#000000",
@@ -654,13 +787,31 @@ const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel
<Stack gap={8}>
<ColorPicker value={color} onChange={setColor} format="hexa" swatches={palette} swatchesPerRow={6} />
<Group justify="right" gap={8}>
<ActionIcon title={t("common.action.cancel")} variant="default" onClick={close}>
<ActionIcon
className={actionTargetClasses.root}
title={tCommon("action.cancel")}
aria-label={tCommon("action.cancel")}
variant="default"
onClick={close}
>
<IconX stroke={1.5} size="1rem" />
</ActionIcon>
<ActionIcon title={t("common.action.apply")} variant="default" onClick={handleApplyColor}>
<ActionIcon
className={actionTargetClasses.root}
title={tCommon("action.apply")}
aria-label={tCommon("action.apply")}
variant="default"
onClick={handleApplyColor}
>
<IconCheck stroke={1.5} size="1rem" />
</ActionIcon>
<ActionIcon title={t("widget.notebook.popover.clearColor")} variant="default" onClick={handleClearColor}>
<ActionIcon
className={actionTargetClasses.root}
title={tNotebook("popover.clearColor")}
aria-label={tNotebook("popover.clearColor")}
variant="default"
onClick={handleClearColor}
>
<IconCircleOff stroke={1.5} size="1rem" />
</ActionIcon>
</Group>
@@ -671,8 +822,9 @@ const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel
};
function EmbedImage() {
const tControls = useScopedI18n("widget.notebook.controls");
const t = useI18n();
const tControls = useI18n("widget.notebook.controls");
const t = useI18n("widget.notebook");
const tCommon = useI18n("common");
const { editor } = useRichTextEditorContext();
const { colors, white } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
@@ -726,18 +878,14 @@ function EmbedImage() {
<Popover.Dropdown>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap={5}>
<TextInput label={t("popover.source")} placeholder="https://example.com/" {...form.getInputProps("src")} />
<TextInput
label={t("widget.notebook.popover.source")}
placeholder="https://example.com/"
{...form.getInputProps("src")}
/>
<TextInput
label={t("widget.notebook.popover.width")}
placeholder={t("widget.notebook.popover.widthPlaceholder")}
label={t("popover.width")}
placeholder={t("popover.widthPlaceholder")}
{...form.getInputProps("width")}
/>
<Button type="submit" variant="default" mt={10} mb={5}>
{t("common.action.save")}
{tCommon("action.save")}
</Button>
</Stack>
</form>
@@ -748,7 +896,7 @@ function EmbedImage() {
function TaskListToggle() {
const { editor } = useRichTextEditorContext();
const tControls = useScopedI18n("widget.notebook.controls");
const tControls = useI18n("widget.notebook.controls");
const handleToggleTaskList = useCallback(() => {
editor?.chain().focus().toggleTaskList().run();
}, [editor]);
@@ -764,18 +912,30 @@ function TaskListToggle() {
);
}
function ListIndentIncrease() {
function useActiveListItemType() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
const tControls = useScopedI18n("widget.notebook.controls");
useEffect(() => {
if (!editor) return;
const updateItemType = () => setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
updateItemType();
editor.on("selectionUpdate", updateItemType);
return () => {
editor.off("selectionUpdate", updateItemType);
};
}, [editor]);
return { editor, itemType };
}
function ListIndentIncrease() {
const { editor, itemType } = useActiveListItemType();
const tControls = useI18n("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 (
<RichTextEditor.Control
title={tControls("increaseIndent")}
@@ -788,18 +948,13 @@ function ListIndentIncrease() {
}
function ListIndentDecrease() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
const tControls = useScopedI18n("widget.notebook.controls");
const { editor, itemType } = useActiveListItemType();
const tControls = useI18n("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 (
<RichTextEditor.Control
title={tControls("decreaseIndent")}
@@ -810,173 +965,3 @@ function ListIndentDecrease() {
</RichTextEditor.Control>
);
}
const handleAddColumnBefore = (editor: Editor) => {
editor.commands.addColumnBefore();
};
const TableAddColumnBefore = () => (
<TableControl title="addColumnLeft" onClick={handleAddColumnBefore} icon={IconColumnInsertLeft} />
);
const handleAddColumnAfter = (editor: Editor) => {
editor.commands.addColumnAfter();
};
const TableAddColumnAfter = () => (
<TableControl title="addColumnRight" onClick={handleAddColumnAfter} icon={IconColumnInsertRight} />
);
const handleRemoveColumn = (editor: Editor) => {
editor.commands.deleteColumn();
};
const TableRemoveColumn = () => (
<TableControl title="deleteColumn" onClick={handleRemoveColumn} icon={IconColumnRemove} />
);
const handleAddRowBefore = (editor: Editor) => {
editor.commands.addRowBefore();
};
const TableAddRowBefore = () => <TableControl title="addRowTop" onClick={handleAddRowBefore} icon={IconRowInsertTop} />;
const handleAddRowAfter = (editor: Editor) => {
editor.commands.addRowAfter();
};
const TableAddRowAfter = () => (
<TableControl title="addRowBelow" onClick={handleAddRowAfter} icon={IconRowInsertBottom} />
);
const handleRemoveRow = (editor: Editor) => {
editor.commands.deleteRow();
};
const TableRemoveRow = () => <TableControl title="deleteRow" onClick={handleRemoveRow} icon={IconRowRemove} />;
interface TableControlProps {
title: Exclude<keyof TranslationObject["widget"]["notebook"]["controls"], "align" | "heading">;
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 (
<RichTextEditor.Control title={tControls(title)} onClick={handleControlClick}>
<Icon {...controlIconProps} />
</RichTextEditor.Control>
);
};
function TableToggleMerge() {
const { editor } = useRichTextEditorContext();
const tControls = useScopedI18n("widget.notebook.controls");
const handleToggleMerge = useCallback(() => {
editor?.commands.mergeOrSplit();
}, [editor]);
return (
<RichTextEditor.Control
title={tControls("mergeCell")}
onClick={handleToggleMerge}
active={editor?.getAttributes("tableCell").colspan > 1}
>
<svg
height="1.25rem"
width="1.25rem"
strokeWidth="0.1"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{/* No existing icon from tabler, taken from https://icon-sets.iconify.design/fluent/table-cells-merge-24-regular/ */}
<path
fill="currentColor"
d="M15.58 11.25H8.42l.89-1.002a.75.75 0 0 0-1.12-.996l-2 2.25a.75.75 0 0 0 0 .996l2 2.25a.75.75 0 1 0 1.12-.996l-.89-1.002h7.16l-.89 1.002a.75.75 0 0 0 1.12.996l2-2.25l.011-.012a.746.746 0 0 0-.013-.987l-1.997-2.247a.75.75 0 0 0-1.121.996l.89 1.002ZM6.25 3A3.25 3.25 0 0 0 3 6.25v11.5A3.25 3.25 0 0 0 6.25 21h11.5A3.25 3.25 0 0 0 21 17.75V6.25A3.25 3.25 0 0 0 17.75 3H6.25ZM4.5 6.25c0-.966.784-1.75 1.75-1.75h11.5c.966 0 1.75.784 1.75 1.75v.25h-15v-.25ZM4.5 8h15v8h-15V8Zm15 9.5v.25a1.75 1.75 0 0 1-1.75 1.75H6.25a1.75 1.75 0 0 1-1.75-1.75v-.25h15Z"
/>
</svg>
</RichTextEditor.Control>
);
}
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 (
<Popover
opened={opened}
onOpen={handleOpen}
onClose={close}
styles={{
dropdown: {
backgroundColor: colorScheme === "dark" ? colors.dark[7] : white,
},
}}
trapFocus
>
<Popover.Target>
<RichTextEditor.Control
title={tControls(isActive ? "deleteTable" : "addTable")}
active={isActive}
onClick={handleControlClick}
>
{isActive ? <IconTableOff stroke={1.5} size="1rem" /> : <IconTablePlus stroke={1.5} size="1rem" />}
</RichTextEditor.Control>
</Popover.Target>
<Popover.Dropdown>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap={5}>
<NumberInput label={t("widget.notebook.popover.columns")} min={1} {...form.getInputProps("cols")} />
<NumberInput label={t("widget.notebook.popover.rows")} min={1} {...form.getInputProps("rows")} />
<Button type="submit" variant="default" mt={10} mb={5}>
{t("common.action.insert")}
</Button>
</Stack>
</form>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,96 @@
import { Editor } from "@tiptap/core";
import { TaskList } from "@tiptap/extension-task-list";
import { Schema } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
const schema = new Schema({
nodes: {
doc: { content: "taskList" },
paragraph: { content: "text*" },
text: { inline: true },
taskList: { content: "taskItem+" },
taskItem: {
attrs: { checked: { default: false } },
content: "paragraph",
},
},
});
const createTaskItem = () =>
schema.node("taskItem", { checked: false }, [schema.node("paragraph", null, [schema.text("Duplicate task")])]);
describe("read-only task item updates", () => {
it("reports the exact position of the clicked duplicate task", () => {
const element = document.createElement("div");
let clickedPosition: number | undefined;
const editor = new Editor({
element,
editable: false,
extensions: [
StarterKit,
TaskList.configure({ itemTypeName: "taskItem" }),
ReadOnlyTaskItem.configure({
nested: true,
onReadOnlyChecked: () => true,
onReadOnlyCheckedAtPosition: (position) => {
clickedPosition = position;
},
}),
],
content: {
type: "doc",
content: [
{
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
{ type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
],
},
],
},
});
try {
const positions: number[] = [];
editor.state.doc.descendants((node, position) => {
if (node.type.name === "taskItem") positions.push(position);
});
const secondPosition = positions[1];
const secondCheckbox = element.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').item(1);
if (secondPosition === undefined || !secondCheckbox) throw new Error("Expected a second task item checkbox");
secondCheckbox.checked = true;
secondCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
expect(clickedPosition).toBe(secondPosition);
} finally {
editor.destroy();
}
});
it("updates the selected position when task items are structurally identical", () => {
const doc = schema.node("doc", null, [schema.node("taskList", null, [createTaskItem(), createTaskItem()])]);
const taskItemPositions: number[] = [];
doc.descendants((node, position) => {
if (node.type.name === "taskItem") taskItemPositions.push(position);
});
const secondTaskItemPosition = taskItemPositions[1];
if (secondTaskItemPosition === undefined) throw new Error("Expected a second task item");
const state = EditorState.create({ doc });
const transaction = createReadOnlyTaskItemTransaction(state, secondTaskItemPosition, true);
if (!transaction) throw new Error("Expected a task-item transaction");
const nextState = state.apply(transaction);
const checkedStates: boolean[] = [];
nextState.doc.descendants((node) => {
if (node.type.name === "taskItem") checkedStates.push(Boolean(node.attrs.checked));
});
expect(checkedStates).toEqual([false, true]);
});
});
@@ -0,0 +1,66 @@
import type { NodeViewRenderer } from "@tiptap/core";
import { TaskItem } from "@tiptap/extension-task-item";
import type { TaskItemOptions } from "@tiptap/extension-task-item";
import type { EditorState, Transaction } from "@tiptap/pm/state";
interface ReadOnlyTaskItemOptions extends TaskItemOptions {
onReadOnlyCheckedAtPosition?: (position: number, checked: boolean) => void;
}
export const ReadOnlyTaskItem = TaskItem.extend<ReadOnlyTaskItemOptions>({
addOptions() {
const parentOptions = this.parent?.();
return {
onReadOnlyChecked: parentOptions?.onReadOnlyChecked,
nested: parentOptions?.nested ?? false,
HTMLAttributes: parentOptions?.HTMLAttributes ?? {},
taskListTypeName: parentOptions?.taskListTypeName ?? "taskList",
a11y: parentOptions?.a11y,
onReadOnlyCheckedAtPosition: undefined,
};
},
addNodeView() {
const parentNodeView = this.parent?.() as NodeViewRenderer | undefined;
if (!parentNodeView) return null;
return (props) => {
const nodeView = parentNodeView(props);
const checkbox =
nodeView.dom instanceof HTMLElement
? nodeView.dom.querySelector<HTMLInputElement>('input[type="checkbox"]')
: null;
if (!checkbox) return nodeView;
const handleChange = () => {
if (props.editor.isEditable) return;
const position = props.getPos();
if (typeof position !== "number") return;
this.options.onReadOnlyCheckedAtPosition?.(position, checkbox.checked);
};
checkbox.addEventListener("change", handleChange);
const destroyParent = nodeView.destroy?.bind(nodeView);
return {
...nodeView,
destroy() {
checkbox.removeEventListener("change", handleChange);
destroyParent?.();
},
};
};
},
});
export const createReadOnlyTaskItemTransaction = (
state: EditorState,
position: number,
checked: boolean,
): Transaction | null => {
const taskItem = state.doc.nodeAt(position);
if (taskItem?.type.name !== "taskItem") return null;
return state.tr.setNodeMarkup(position, undefined, {
...taskItem.attrs,
checked,
});
};