fix: address widget review feedback
This commit is contained in:
1 parent
b1b17ad69f
commit
a38e731209
79 files changed
+2138
-244
No files matched your search
@@ -53,7 +53,6 @@ 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";
|
||||
@@ -61,7 +60,6 @@ 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";
|
||||
@@ -70,6 +68,7 @@ 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";
|
||||
@@ -208,17 +207,20 @@ export function Notebook({
|
||||
}),
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TaskItem.configure({
|
||||
ReadOnlyTaskItem.configure({
|
||||
nested: true,
|
||||
onReadOnlyChecked: (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: { node, checked },
|
||||
detail: { position, checked },
|
||||
});
|
||||
dispatchEvent(event);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
TaskList.configure({ itemTypeName: "taskItem" }),
|
||||
@@ -243,25 +245,17 @@ export function Notebook({
|
||||
(event: Event) => {
|
||||
if (!allowReadOnlyCheckRef.current) return;
|
||||
if (!editor) return;
|
||||
const { detail } = event as CustomEvent<{ node: Node; checked: boolean }>;
|
||||
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 (!detail) return;
|
||||
if (!subnode.eq(detail.node)) return;
|
||||
|
||||
const { tr } = editor.state;
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...detail.node.attrs,
|
||||
checked: detail.checked,
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
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(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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user