@edux-design/comments 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +20 -0
- package/dist/index.d.mts +163 -48
- package/dist/index.d.ts +163 -48
- package/dist/index.js +424 -156
- package/dist/index.mjs +425 -149
- package/docs/composer.md +65 -0
- package/docs/mention-portals.md +105 -0
- package/package.json +11 -24
- package/src/demos/Comment.stories.jsx +0 -593
- package/src/demos/CommentComposer.stories.jsx +0 -66
- package/src/elements/Comment.jsx +0 -240
- package/src/elements/Comment.test.jsx +0 -151
- package/src/elements/CommentComposer.jsx +0 -184
- package/src/elements/CommentComposer.test.jsx +0 -125
- package/src/elements/CommentMention.jsx +0 -232
- package/src/elements/CommentMention.test.jsx +0 -150
- package/src/elements/CommentMentionExtension.jsx +0 -56
- package/src/index.js +0 -3
- package/tsconfig.json +0 -10
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import React, { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
-
import { Menu, MenuList, MenuItem } from "@edux-design/menus";
|
|
3
|
-
import { Avatar } from "@edux-design/avatars";
|
|
4
|
-
import { useCommentComposer } from "./CommentComposer";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @typedef {{
|
|
8
|
-
* id: string,
|
|
9
|
-
* name: string,
|
|
10
|
-
* handle: string,
|
|
11
|
-
* avatarProps?: import("@edux-design/avatars").AvatarProps,
|
|
12
|
-
* }} CommentMentionPerson
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @typedef {{
|
|
17
|
-
* peopleList: CommentMentionPerson[],
|
|
18
|
-
* onQueryChange?: (query: string) => void,
|
|
19
|
-
* placeholder?: string,
|
|
20
|
-
* }} CommentMentionProps
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
export const CommentMention = ({
|
|
24
|
-
peopleList = [],
|
|
25
|
-
onQueryChange,
|
|
26
|
-
}) => {
|
|
27
|
-
const { editor, editorContainerRef } = useCommentComposer();
|
|
28
|
-
|
|
29
|
-
const [menuOpen, setMenuOpen] = useState(false);
|
|
30
|
-
const [query, setQuery] = useState("");
|
|
31
|
-
const [range, setRange] = useState(null);
|
|
32
|
-
const [anchorStyle, setAnchorStyle] = useState({ left: 0, top: 0 });
|
|
33
|
-
const menuListRef = useRef(null);
|
|
34
|
-
const rangeRef = useRef(null);
|
|
35
|
-
const lastQueryRef = useRef("");
|
|
36
|
-
|
|
37
|
-
const peopleByHandle = useMemo(
|
|
38
|
-
() => new Map(peopleList.map((person) => [person.handle.toLowerCase(), person])),
|
|
39
|
-
[peopleList]
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
const filteredPeople = useMemo(() => {
|
|
43
|
-
if (!query) return peopleList;
|
|
44
|
-
const lowered = query.toLowerCase();
|
|
45
|
-
return peopleList.filter((person) =>
|
|
46
|
-
person.handle.toLowerCase().startsWith(lowered)
|
|
47
|
-
);
|
|
48
|
-
}, [peopleList, query]);
|
|
49
|
-
|
|
50
|
-
const updateMenuState = useCallback(() => {
|
|
51
|
-
if (!editor) return;
|
|
52
|
-
const { from } = editor.state.selection;
|
|
53
|
-
const { parent, parentOffset, pos } = editor.state.selection.$from;
|
|
54
|
-
|
|
55
|
-
if (!parent.isTextblock) {
|
|
56
|
-
setMenuOpen(false);
|
|
57
|
-
setQuery("");
|
|
58
|
-
setRange(null);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const textBefore = parent.textBetween(0, parentOffset, "\n", "\0");
|
|
63
|
-
const match = textBefore.match(/@([\w-]*)$/);
|
|
64
|
-
|
|
65
|
-
if (match) {
|
|
66
|
-
const handleQuery = match[1] ?? "";
|
|
67
|
-
if (onQueryChange && handleQuery !== lastQueryRef.current) {
|
|
68
|
-
lastQueryRef.current = handleQuery;
|
|
69
|
-
onQueryChange(handleQuery);
|
|
70
|
-
}
|
|
71
|
-
setQuery(handleQuery);
|
|
72
|
-
setMenuOpen(true);
|
|
73
|
-
const fromPos = pos - match[0].length;
|
|
74
|
-
const nextRange = { from: fromPos, to: pos };
|
|
75
|
-
setRange(nextRange);
|
|
76
|
-
rangeRef.current = nextRange;
|
|
77
|
-
const coords = editor.view.coordsAtPos(fromPos);
|
|
78
|
-
const containerRect = editorContainerRef?.current?.getBoundingClientRect?.();
|
|
79
|
-
if (containerRect) {
|
|
80
|
-
setAnchorStyle({
|
|
81
|
-
left: coords.left - containerRect.left + 12,
|
|
82
|
-
top: coords.bottom - containerRect.top + 4,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
setMenuOpen(false);
|
|
89
|
-
setQuery("");
|
|
90
|
-
setRange(null);
|
|
91
|
-
rangeRef.current = null;
|
|
92
|
-
if (onQueryChange && lastQueryRef.current !== "") {
|
|
93
|
-
lastQueryRef.current = "";
|
|
94
|
-
onQueryChange("");
|
|
95
|
-
}
|
|
96
|
-
}, [editor, editorContainerRef, onQueryChange]);
|
|
97
|
-
|
|
98
|
-
const handleSelectPerson = useCallback(
|
|
99
|
-
(person) => {
|
|
100
|
-
if (!editor || !rangeRef.current) return;
|
|
101
|
-
const selectionRange = rangeRef.current;
|
|
102
|
-
editor.commands.focus();
|
|
103
|
-
editor.commands.setTextSelection(selectionRange);
|
|
104
|
-
editor.commands.insertContentAt(
|
|
105
|
-
selectionRange,
|
|
106
|
-
[
|
|
107
|
-
{
|
|
108
|
-
type: "commentMention",
|
|
109
|
-
attrs: {
|
|
110
|
-
id: person.id,
|
|
111
|
-
handle: person.handle,
|
|
112
|
-
label: person.name,
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
{ type: "text", text: " " },
|
|
116
|
-
],
|
|
117
|
-
{ updateSelection: false }
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
const nextPos = selectionRange.from + 2;
|
|
121
|
-
requestAnimationFrame(() => {
|
|
122
|
-
editor.commands.setTextSelection(nextPos);
|
|
123
|
-
editor.view.focus();
|
|
124
|
-
setTimeout(() => editor.view.focus(), 0);
|
|
125
|
-
});
|
|
126
|
-
setMenuOpen(false);
|
|
127
|
-
setQuery("");
|
|
128
|
-
setRange(null);
|
|
129
|
-
rangeRef.current = null;
|
|
130
|
-
if (onQueryChange && lastQueryRef.current !== "") {
|
|
131
|
-
lastQueryRef.current = "";
|
|
132
|
-
onQueryChange("");
|
|
133
|
-
}
|
|
134
|
-
},
|
|
135
|
-
[editor, onQueryChange]
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
useEffect(() => {
|
|
139
|
-
if (!editor) return;
|
|
140
|
-
updateMenuState();
|
|
141
|
-
editor.on("transaction", updateMenuState);
|
|
142
|
-
editor.on("selectionUpdate", updateMenuState);
|
|
143
|
-
return () => {
|
|
144
|
-
editor.off("transaction", updateMenuState);
|
|
145
|
-
editor.off("selectionUpdate", updateMenuState);
|
|
146
|
-
};
|
|
147
|
-
}, [editor, updateMenuState]);
|
|
148
|
-
|
|
149
|
-
useEffect(() => {
|
|
150
|
-
if (!editor) return;
|
|
151
|
-
const handleKeyDown = (event) => {
|
|
152
|
-
if (!menuOpen) return;
|
|
153
|
-
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
154
|
-
|
|
155
|
-
event.preventDefault();
|
|
156
|
-
const list = menuListRef.current;
|
|
157
|
-
const items = list?.querySelectorAll("[data-menu-item]");
|
|
158
|
-
if (!list || !items?.length) return;
|
|
159
|
-
|
|
160
|
-
list.focus();
|
|
161
|
-
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
162
|
-
const targetIndex = direction === 1 ? 0 : items.length - 1;
|
|
163
|
-
items[targetIndex]?.focus();
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const dom = editor.view?.dom;
|
|
167
|
-
dom?.addEventListener("keydown", handleKeyDown);
|
|
168
|
-
return () => dom?.removeEventListener("keydown", handleKeyDown);
|
|
169
|
-
}, [editor, menuOpen]);
|
|
170
|
-
|
|
171
|
-
const menuContent = useMemo(
|
|
172
|
-
() => (
|
|
173
|
-
<Menu
|
|
174
|
-
open={menuOpen && filteredPeople.length > 0}
|
|
175
|
-
onOpenChange={setMenuOpen}
|
|
176
|
-
button={
|
|
177
|
-
<button
|
|
178
|
-
type="button"
|
|
179
|
-
aria-hidden
|
|
180
|
-
className="absolute h-1 w-1 opacity-0"
|
|
181
|
-
style={{
|
|
182
|
-
left: `${anchorStyle.left}px`,
|
|
183
|
-
top: `${anchorStyle.top}px`,
|
|
184
|
-
pointerEvents: "none",
|
|
185
|
-
}}
|
|
186
|
-
/>
|
|
187
|
-
}
|
|
188
|
-
align="start"
|
|
189
|
-
side="bottom"
|
|
190
|
-
trapFocus={false}
|
|
191
|
-
disableOutsidePointerEvents={false}
|
|
192
|
-
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
193
|
-
contentProps={{
|
|
194
|
-
sideOffset: 4,
|
|
195
|
-
avoidCollisions: true,
|
|
196
|
-
collisionPadding: 12,
|
|
197
|
-
onCloseAutoFocus: (event) => event.preventDefault(),
|
|
198
|
-
}}
|
|
199
|
-
>
|
|
200
|
-
<MenuList ref={menuListRef} className="min-w-[220px]">
|
|
201
|
-
{filteredPeople.map((person) => (
|
|
202
|
-
<MenuItem
|
|
203
|
-
key={person.id}
|
|
204
|
-
onMouseDown={(event) => {
|
|
205
|
-
event.preventDefault();
|
|
206
|
-
event.stopPropagation();
|
|
207
|
-
handleSelectPerson(person);
|
|
208
|
-
}}
|
|
209
|
-
onClick={() => handleSelectPerson(person)}
|
|
210
|
-
onPointerDown={(event) => event.preventDefault()}
|
|
211
|
-
>
|
|
212
|
-
<span className="flex items-center gap-8">
|
|
213
|
-
<Avatar size="small" {...person.avatarProps} />
|
|
214
|
-
<span className="flex flex-col">
|
|
215
|
-
<span className="text-sm text-fg-base">{person.name}</span>
|
|
216
|
-
<span className="text-xs text-fg-subtle">
|
|
217
|
-
@{person.handle}
|
|
218
|
-
</span>
|
|
219
|
-
</span>
|
|
220
|
-
</span>
|
|
221
|
-
</MenuItem>
|
|
222
|
-
))}
|
|
223
|
-
</MenuList>
|
|
224
|
-
</Menu>
|
|
225
|
-
),
|
|
226
|
-
[filteredPeople, handleSelectPerson, menuOpen]
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
if (!editor) return null;
|
|
230
|
-
|
|
231
|
-
return menuContent;
|
|
232
|
-
};
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
3
|
-
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
4
|
-
import "@testing-library/jest-dom";
|
|
5
|
-
|
|
6
|
-
const insertContentAt = vi.fn();
|
|
7
|
-
const setTextSelection = vi.fn();
|
|
8
|
-
const focus = vi.fn();
|
|
9
|
-
const viewFocus = vi.fn();
|
|
10
|
-
|
|
11
|
-
const editorMock = {
|
|
12
|
-
state: {
|
|
13
|
-
selection: {
|
|
14
|
-
from: 10,
|
|
15
|
-
$from: {
|
|
16
|
-
parent: {
|
|
17
|
-
isTextblock: true,
|
|
18
|
-
textBetween: () => "Hello @al",
|
|
19
|
-
},
|
|
20
|
-
parentOffset: 9,
|
|
21
|
-
pos: 10,
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
|
-
view: {
|
|
26
|
-
dom: document.createElement("div"),
|
|
27
|
-
coordsAtPos: () => ({ left: 100, bottom: 120 }),
|
|
28
|
-
focus: viewFocus,
|
|
29
|
-
},
|
|
30
|
-
commands: {
|
|
31
|
-
focus,
|
|
32
|
-
setTextSelection,
|
|
33
|
-
insertContentAt,
|
|
34
|
-
},
|
|
35
|
-
on: vi.fn(),
|
|
36
|
-
off: vi.fn(),
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const containerRef = {
|
|
40
|
-
current: {
|
|
41
|
-
getBoundingClientRect: () => ({ left: 50, top: 50 }),
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
vi.mock("@edux-design/menus", () => ({
|
|
46
|
-
Menu: ({ children, button }) => (
|
|
47
|
-
<div>
|
|
48
|
-
{button}
|
|
49
|
-
{children}
|
|
50
|
-
</div>
|
|
51
|
-
),
|
|
52
|
-
MenuList: React.forwardRef(({ children }, ref) => (
|
|
53
|
-
<div ref={ref} tabIndex={0}>
|
|
54
|
-
{children}
|
|
55
|
-
</div>
|
|
56
|
-
)),
|
|
57
|
-
MenuItem: ({ children, onMouseDown, onClick }) => (
|
|
58
|
-
<div
|
|
59
|
-
data-testid="menu-item"
|
|
60
|
-
data-menu-item
|
|
61
|
-
tabIndex={-1}
|
|
62
|
-
onMouseDown={onMouseDown}
|
|
63
|
-
onClick={onClick}
|
|
64
|
-
>
|
|
65
|
-
{children}
|
|
66
|
-
</div>
|
|
67
|
-
),
|
|
68
|
-
}));
|
|
69
|
-
|
|
70
|
-
vi.mock("@edux-design/avatars", () => ({
|
|
71
|
-
Avatar: () => <div data-testid="avatar" />,
|
|
72
|
-
}));
|
|
73
|
-
|
|
74
|
-
vi.mock("@edux-design/mentions", () => ({
|
|
75
|
-
Mention: ({ children }) => <span>{children}</span>,
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
vi.mock("./CommentComposer", () => ({
|
|
79
|
-
useCommentComposer: () => ({
|
|
80
|
-
editor: editorMock,
|
|
81
|
-
editorContainerRef: containerRef,
|
|
82
|
-
}),
|
|
83
|
-
}));
|
|
84
|
-
|
|
85
|
-
import { CommentMention } from "./CommentMention";
|
|
86
|
-
|
|
87
|
-
afterEach(() => {
|
|
88
|
-
cleanup();
|
|
89
|
-
vi.clearAllMocks();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe("CommentMention", () => {
|
|
93
|
-
it("inserts a mention and focuses the editor on selection", () => {
|
|
94
|
-
render(
|
|
95
|
-
<CommentMention
|
|
96
|
-
peopleList={[{ id: "1", name: "Alex Johnson", handle: "alex" }]}
|
|
97
|
-
/>
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
const item = screen.getByTestId("menu-item");
|
|
101
|
-
fireEvent.mouseDown(item);
|
|
102
|
-
|
|
103
|
-
expect(focus).toHaveBeenCalled();
|
|
104
|
-
expect(setTextSelection).toHaveBeenCalled();
|
|
105
|
-
expect(insertContentAt).toHaveBeenCalled();
|
|
106
|
-
expect(viewFocus).toHaveBeenCalled();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("positions the menu trigger relative to the cursor", () => {
|
|
110
|
-
render(
|
|
111
|
-
<CommentMention
|
|
112
|
-
peopleList={[{ id: "1", name: "Alex Johnson", handle: "alex" }]}
|
|
113
|
-
/>
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
const trigger = screen.getByRole("button", { hidden: true });
|
|
117
|
-
expect(trigger.style.left).toBe("62px");
|
|
118
|
-
expect(trigger.style.top).toBe("74px");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("moves focus to menu items on arrow key", () => {
|
|
122
|
-
render(
|
|
123
|
-
<CommentMention
|
|
124
|
-
peopleList={[
|
|
125
|
-
{ id: "1", name: "Alex Johnson", handle: "alex" },
|
|
126
|
-
{ id: "2", name: "Priya Singh", handle: "priya" },
|
|
127
|
-
]}
|
|
128
|
-
/>
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const items = screen.getAllByTestId("menu-item");
|
|
132
|
-
const focusSpy = vi.spyOn(items[0], "focus");
|
|
133
|
-
|
|
134
|
-
fireEvent.keyDown(editorMock.view.dom, { key: "ArrowDown" });
|
|
135
|
-
|
|
136
|
-
expect(focusSpy).toHaveBeenCalled();
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("emits mention queries when typing", () => {
|
|
140
|
-
const handleQueryChange = vi.fn();
|
|
141
|
-
render(
|
|
142
|
-
<CommentMention
|
|
143
|
-
peopleList={[{ id: "1", name: "Alex Johnson", handle: "alex" }]}
|
|
144
|
-
onQueryChange={handleQueryChange}
|
|
145
|
-
/>
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
expect(handleQueryChange).toHaveBeenCalledWith("al");
|
|
149
|
-
});
|
|
150
|
-
});
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { Node, mergeAttributes } from "@tiptap/core";
|
|
3
|
-
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
|
4
|
-
import { Mention } from "@edux-design/mentions";
|
|
5
|
-
|
|
6
|
-
const MentionNodeView = ({ node }) => {
|
|
7
|
-
const handle = node.attrs?.handle ?? "";
|
|
8
|
-
const label = node.attrs?.label ?? "";
|
|
9
|
-
return (
|
|
10
|
-
<NodeViewWrapper
|
|
11
|
-
as="span"
|
|
12
|
-
className="inline-flex items-center align-middle -translate-y-2"
|
|
13
|
-
>
|
|
14
|
-
<Mention handle={handle} size="small">
|
|
15
|
-
@{handle} · {label}
|
|
16
|
-
</Mention>
|
|
17
|
-
</NodeViewWrapper>
|
|
18
|
-
);
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export const CommentMentionExtension = Node.create({
|
|
22
|
-
name: "commentMention",
|
|
23
|
-
group: "inline",
|
|
24
|
-
inline: true,
|
|
25
|
-
atom: true,
|
|
26
|
-
selectable: false,
|
|
27
|
-
|
|
28
|
-
addAttributes() {
|
|
29
|
-
return {
|
|
30
|
-
id: { default: null },
|
|
31
|
-
handle: { default: "" },
|
|
32
|
-
label: { default: "" },
|
|
33
|
-
};
|
|
34
|
-
},
|
|
35
|
-
|
|
36
|
-
parseHTML() {
|
|
37
|
-
return [{ tag: "span[data-comment-mention]" }];
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
renderHTML({ HTMLAttributes }) {
|
|
41
|
-
const handle = HTMLAttributes.handle || "";
|
|
42
|
-
const label = HTMLAttributes.label || `@${handle}`;
|
|
43
|
-
return [
|
|
44
|
-
"span",
|
|
45
|
-
mergeAttributes(HTMLAttributes, {
|
|
46
|
-
"data-comment-mention": "true",
|
|
47
|
-
"data-handle": handle,
|
|
48
|
-
}),
|
|
49
|
-
label,
|
|
50
|
-
];
|
|
51
|
-
},
|
|
52
|
-
|
|
53
|
-
addNodeView() {
|
|
54
|
-
return ReactNodeViewRenderer(MentionNodeView);
|
|
55
|
-
},
|
|
56
|
-
});
|
package/src/index.js
DELETED