@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/dist/index.mjs CHANGED
@@ -21,7 +21,10 @@ var CommentContext = createContext({
21
21
  resolved: false
22
22
  });
23
23
  var useCommentContext = () => useContext(CommentContext);
24
- var Comment = forwardRef(function Comment2({
24
+ var flattenReplies = (children) => React.Children.toArray(children).flatMap(
25
+ (child) => isValidElement(child) && child.type === React.Fragment ? flattenReplies(child.props.children) : [child]
26
+ );
27
+ function CommentComponent({
25
28
  author = "Author",
26
29
  timestamp,
27
30
  avatar,
@@ -39,10 +42,7 @@ var Comment = forwardRef(function Comment2({
39
42
  }, ref) {
40
43
  const childArray = React.Children.toArray(children);
41
44
  const actionsSlot = childArray.find(
42
- (child) => {
43
- var _a;
44
- return isValidElement(child) && (child.type === CommentActions || ((_a = child.type) == null ? void 0 : _a.displayName) === "Comment.Actions");
45
- }
45
+ (child) => isValidElement(child) && (child.type === CommentActions || child.type?.displayName === "Comment.Actions")
46
46
  );
47
47
  const bodyNodes = childArray.filter((child) => child !== actionsSlot);
48
48
  const bodyContent = body ?? bodyNodes;
@@ -50,9 +50,11 @@ var Comment = forwardRef(function Comment2({
50
50
  const isResolved = resolved || variant === "resolved";
51
51
  const resolvedVariant = VARIANT_STYLES[isResolved ? "resolved" : variant] ?? VARIANT_STYLES.default;
52
52
  const resolvedAvatarName = avatarName ?? (typeof author === "string" ? author : void 0);
53
- const shouldRenderAvatar = Boolean(avatar || avatarProps || resolvedAvatarName);
53
+ const shouldRenderAvatar = Boolean(
54
+ avatar || avatarProps || resolvedAvatarName
55
+ );
54
56
  const replyItems = useMemo(
55
- () => React.Children.toArray(replies).filter(
57
+ () => flattenReplies(replies).filter(
56
58
  (reply) => reply !== null && reply !== void 0 && reply !== false
57
59
  ),
58
60
  [replies]
@@ -64,49 +66,55 @@ var Comment = forwardRef(function Comment2({
64
66
  setShowAllReplies(!shouldCollapseReplies);
65
67
  }, [shouldCollapseReplies]);
66
68
  const visibleReplies = shouldCollapseReplies && !showAllReplies ? [replyItems[replyCount - 1]] : replyItems;
67
- return /* @__PURE__ */ jsx(CommentContext.Provider, { value: { editable, onResolve, resolved: isResolved }, children: /* @__PURE__ */ jsxs(
68
- "div",
69
+ return /* @__PURE__ */ jsx(
70
+ CommentContext.Provider,
69
71
  {
70
- ref,
71
- className: cx(
72
- "flex gap-12 rounded-default border p-12",
73
- resolvedVariant,
74
- className
75
- ),
76
- "data-variant": variant,
77
- ...props,
78
- children: [
79
- shouldRenderAvatar && /* @__PURE__ */ jsx("div", { className: "shrink-0", children: avatar ?? /* @__PURE__ */ jsx(Avatar, { name: resolvedAvatarName, size: "small", ...avatarProps }) }),
80
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
81
- /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [
82
- /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-semibold text-fg-base", children: author }),
83
- timestamp && /* @__PURE__ */ jsx("span", { className: "text-xs text-fg-subtle", children: timestamp })
84
- ] }),
85
- hasBodyContent && !isResolved ? /* @__PURE__ */ jsx("div", { className: "mt-6 text-sm leading-relaxed text-fg-base", children: bodyContent }) : null,
86
- isResolved ? /* @__PURE__ */ jsx("div", { className: "mt-6 text-xs font-semibold uppercase tracking-wide text-fg-success-base", children: "Resolved" }) : null,
87
- actionsSlot ? /* @__PURE__ */ jsx("div", { className: "mt-8", children: actionsSlot }) : null,
88
- replies && !isResolved ? /* @__PURE__ */ jsxs("div", { className: "mt-10 border-l border-border-minimal pl-12", children: [
89
- shouldCollapseReplies && !showAllReplies && /* @__PURE__ */ jsxs(
90
- "button",
91
- {
92
- type: "button",
93
- className: "mb-8 text-xs font-semibold text-fg-info-base",
94
- onClick: () => setShowAllReplies(true),
95
- "aria-expanded": showAllReplies,
96
- children: [
97
- "Show all ",
98
- replyCount,
99
- " replies"
100
- ]
101
- }
102
- ),
103
- visibleReplies
104
- ] }) : null
105
- ] })
106
- ]
72
+ value: { editable, onResolve, resolved: isResolved },
73
+ children: /* @__PURE__ */ jsxs(
74
+ "div",
75
+ {
76
+ ref,
77
+ className: cx(
78
+ "flex gap-12 rounded-default border p-12",
79
+ resolvedVariant,
80
+ className
81
+ ),
82
+ "data-variant": variant,
83
+ ...props,
84
+ children: [
85
+ shouldRenderAvatar && /* @__PURE__ */ jsx("div", { className: "shrink-0", children: avatar ?? /* @__PURE__ */ jsx(Avatar, { name: resolvedAvatarName, size: "small", ...avatarProps }) }),
86
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
87
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [
88
+ /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-semibold text-fg-base", children: author }),
89
+ timestamp && /* @__PURE__ */ jsx("span", { className: "text-xs text-fg-subtle", children: timestamp })
90
+ ] }),
91
+ hasBodyContent && !isResolved ? /* @__PURE__ */ jsx("div", { className: "mt-6 text-sm leading-relaxed text-fg-base", children: bodyContent }) : null,
92
+ isResolved ? /* @__PURE__ */ jsx("div", { className: "mt-6 text-xs font-semibold uppercase tracking-wide text-fg-success-base", children: "Resolved" }) : null,
93
+ actionsSlot ? /* @__PURE__ */ jsx("div", { className: "mt-8", children: actionsSlot }) : null,
94
+ replies && !isResolved ? /* @__PURE__ */ jsxs("div", { className: "mt-[40px] border-l border-border-minimal pl-[20px]", children: [
95
+ shouldCollapseReplies && !showAllReplies && /* @__PURE__ */ jsxs(
96
+ "button",
97
+ {
98
+ type: "button",
99
+ className: "mb-8 text-xs font-semibold text-fg-info-base",
100
+ onClick: () => setShowAllReplies(true),
101
+ "aria-expanded": showAllReplies,
102
+ children: [
103
+ "Show all ",
104
+ replyCount,
105
+ " replies"
106
+ ]
107
+ }
108
+ ),
109
+ visibleReplies
110
+ ] }) : null
111
+ ] })
112
+ ]
113
+ }
114
+ )
107
115
  }
108
- ) });
109
- });
116
+ );
117
+ }
110
118
  var CommentActions = ({ children, className, ...props }) => /* @__PURE__ */ jsx(
111
119
  "div",
112
120
  {
@@ -166,10 +174,13 @@ var CommentResolve = ({
166
174
  }
167
175
  );
168
176
  };
169
- Comment.Actions = CommentActions;
170
- Comment.Reply = CommentReply;
171
- Comment.Edit = CommentEdit;
172
- Comment.Resolve = CommentResolve;
177
+ var Comment = Object.assign(forwardRef(CommentComponent), {
178
+ Actions: CommentActions,
179
+ Reply: CommentReply,
180
+ Edit: CommentEdit,
181
+ Resolve: CommentResolve
182
+ });
183
+ Comment.displayName = "Comment";
173
184
  CommentActions.displayName = "Comment.Actions";
174
185
  CommentReply.displayName = "Comment.Reply";
175
186
  CommentEdit.displayName = "Comment.Edit";
@@ -182,7 +193,9 @@ import React3, {
182
193
  useRef,
183
194
  createContext as createContext2,
184
195
  useContext as useContext2,
185
- useEffect as useEffect2
196
+ useEffect as useEffect2,
197
+ useImperativeHandle,
198
+ useState as useState2
186
199
  } from "react";
187
200
  import { cx as cx2 } from "@edux-design/utils";
188
201
  import { Avatar as Avatar2 } from "@edux-design/avatars";
@@ -198,9 +211,8 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
198
211
  import { Mention } from "@edux-design/mentions";
199
212
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
200
213
  var MentionNodeView = ({ node }) => {
201
- var _a, _b;
202
- const handle = ((_a = node.attrs) == null ? void 0 : _a.handle) ?? "";
203
- const label = ((_b = node.attrs) == null ? void 0 : _b.label) ?? "";
214
+ const handle = node.attrs?.handle ?? "";
215
+ const label = node.attrs?.label ?? "";
204
216
  return /* @__PURE__ */ jsx2(
205
217
  NodeViewWrapper,
206
218
  {
@@ -243,6 +255,11 @@ var CommentMentionExtension = Node.create({
243
255
  label
244
256
  ];
245
257
  },
258
+ renderText({ node }) {
259
+ const handle = node.attrs.handle || "";
260
+ const label = node.attrs.label || "";
261
+ return `@${handle || label || "mention"}`;
262
+ },
246
263
  addNodeView() {
247
264
  return ReactNodeViewRenderer(MentionNodeView);
248
265
  }
@@ -260,87 +277,237 @@ var useCommentComposer = () => {
260
277
  }
261
278
  return context;
262
279
  };
263
- var CommentComposer = forwardRef2(function CommentComposer2({
280
+ function readValue(editor) {
281
+ const mentions = [];
282
+ editor.state.doc.descendants((node) => {
283
+ if (node.type.name === "commentMention") {
284
+ mentions.push({
285
+ id: node.attrs.id ?? null,
286
+ handle: node.attrs.handle ?? "",
287
+ label: node.attrs.label ?? ""
288
+ });
289
+ }
290
+ });
291
+ const legacyText = editor.getText();
292
+ const text = editor.getText({
293
+ blockSeparator: "\n",
294
+ textSerializers: {
295
+ commentMention: ({ node }) => {
296
+ const { handle = "", label = "" } = node.attrs;
297
+ return label ? `@${handle} \xB7 ${label}` : `@${handle}`;
298
+ }
299
+ }
300
+ }).replace(/\u00a0/g, " ").replace(/[\u200b-\u200d\ufeff]/g, "").replace(/\r?\n/g, " ");
301
+ return {
302
+ text,
303
+ legacyText,
304
+ html: editor.getHTML(),
305
+ json: editor.getJSON(),
306
+ mentions,
307
+ isEmpty: !text.trim()
308
+ };
309
+ }
310
+ function stringContent(value, format) {
311
+ if (format !== "text") return value;
312
+ return {
313
+ type: "doc",
314
+ content: value.split(/\r?\n/).map((line) => ({
315
+ type: "paragraph",
316
+ ...line ? { content: [{ type: "text", text: line }] } : {}
317
+ }))
318
+ };
319
+ }
320
+ function CommentComposerComponent({
264
321
  value,
322
+ valueFormat = "html",
323
+ richValue,
265
324
  onChange,
325
+ onValueChange,
266
326
  onSubmit,
267
327
  onCancel,
328
+ composerRef,
329
+ editorLabel = "Comment",
330
+ editorAttributes: suppliedEditorAttributes,
331
+ autoFocus = false,
332
+ externalSubmit = false,
333
+ submitOnModEnter = false,
268
334
  placeholder = "Write a comment...",
269
335
  rows = 4,
270
336
  disabled = false,
271
337
  isSubmitting = false,
272
338
  submitLabel = "Post",
339
+ submittingLabel = "Posting...",
273
340
  cancelLabel = "Cancel",
274
341
  avatar,
275
342
  avatarProps,
276
343
  actions,
277
344
  className,
278
345
  children,
346
+ onKeyDown,
347
+ onKeyDownCapture,
279
348
  ...props
280
349
  }, ref) {
281
- const hasValue = value === void 0 ? true : Boolean(value && value.trim().length);
282
- const isSubmitDisabled = disabled || isSubmitting || !hasValue;
350
+ const containerRef = useRef(null);
351
+ const formRef = useRef(null);
352
+ const pendingFocusRef = useRef(null);
353
+ const handledShortcutEvents = useRef(/* @__PURE__ */ new WeakSet());
354
+ const callbacks = useRef({ onChange, onValueChange });
355
+ callbacks.current = { onChange, onValueChange };
356
+ const pendingEchoesRef = useRef([]);
357
+ const controlledRef = useRef(false);
358
+ controlledRef.current = value !== void 0 || richValue !== void 0;
359
+ const placeholderRef = useRef(placeholder);
360
+ placeholderRef.current = placeholder;
361
+ const [hasValue, setHasValue] = useState2(Boolean(richValue || value?.trim()));
362
+ const locked = disabled || isSubmitting;
363
+ const editorAttributes = useMemo2(
364
+ () => ({
365
+ ...suppliedEditorAttributes,
366
+ "data-comment-editor": "true",
367
+ role: "textbox",
368
+ "aria-label": editorLabel,
369
+ "aria-multiline": "true",
370
+ "aria-disabled": String(locked),
371
+ class: cx2(
372
+ "w-full bg-bg-base text-fg-base border-2 rounded-md border-border-base",
373
+ "outline-none transition-all duration-300 ease-in-out motion-reduce:transition-none",
374
+ "pl-[20px] py-12 font-normal text-base leading-relaxed overflow-y-auto",
375
+ "focus:shadow-focus focus-visible:shadow-focus",
376
+ locked && "bg-bg-subtle border-border-subtle text-fg-inactive"
377
+ ),
378
+ style: `height: ${Math.max(1, rows) * 30}px`
379
+ }),
380
+ [editorLabel, locked, rows, suppliedEditorAttributes]
381
+ );
382
+ const isSubmitDisabled = locked || !hasValue;
283
383
  const editor = useEditor({
384
+ immediatelyRender: false,
284
385
  extensions: [
285
386
  StarterKit,
286
387
  CommentMentionExtension,
287
- Placeholder.configure({
288
- placeholder
289
- })
388
+ Placeholder.configure({ placeholder: () => placeholderRef.current })
290
389
  ],
291
- content: value ?? "",
292
- editable: !disabled,
293
- editorProps: {
294
- attributes: {
295
- class: cx2(
296
- "w-full bg-white border-2 rounded-md border-border-base",
297
- "outline-none transition-all duration-300 ease-in-out",
298
- "pl-5 py-3 font-normal text-base leading-relaxed",
299
- "h-[120px] overflow-y-auto",
300
- "focus:shadow-focus focus-visible:shadow-focus",
301
- disabled && "bg-bg-subtle border-border-subtle text-fg-inactive"
302
- )
303
- }
304
- },
390
+ content: richValue ?? stringContent(value ?? "", valueFormat),
391
+ editable: !locked,
392
+ editorProps: { attributes: editorAttributes },
305
393
  onUpdate: ({ editor: editor2 }) => {
306
- onChange == null ? void 0 : onChange({ target: { value: editor2.getText() } });
394
+ const next = readValue(editor2);
395
+ if (controlledRef.current && (callbacks.current.onChange || callbacks.current.onValueChange)) {
396
+ pendingEchoesRef.current.push(next);
397
+ if (pendingEchoesRef.current.length > 100)
398
+ pendingEchoesRef.current.shift();
399
+ }
400
+ setHasValue(!next.isEmpty);
401
+ callbacks.current.onChange?.({ target: { value: next.legacyText } });
402
+ callbacks.current.onValueChange?.(next);
307
403
  }
308
404
  });
309
- const containerRef = useRef(null);
405
+ useImperativeHandle(ref, () => containerRef.current, []);
406
+ useImperativeHandle(
407
+ composerRef,
408
+ () => ({
409
+ focus: (position = "end") => {
410
+ if (locked) return false;
411
+ if (!editor) {
412
+ pendingFocusRef.current = position;
413
+ return true;
414
+ }
415
+ return editor.commands.focus(position);
416
+ },
417
+ clear: () => !locked && Boolean(editor?.commands.clearContent(true)),
418
+ getValue: () => editor ? readValue(editor) : null,
419
+ submit: () => {
420
+ if (!locked) formRef.current?.requestSubmit();
421
+ }
422
+ }),
423
+ [editor, locked]
424
+ );
310
425
  useEffect2(() => {
311
- if (typeof ref === "function") {
312
- ref(containerRef.current);
313
- } else if (ref) {
314
- ref.current = containerRef.current;
315
- }
316
- }, [ref]);
426
+ if (!editor) return;
427
+ editor.setEditable(!locked, false);
428
+ editor.setOptions({
429
+ editorProps: {
430
+ ...editor.options.editorProps,
431
+ attributes: editorAttributes
432
+ }
433
+ });
434
+ }, [editor, locked, editorAttributes, placeholder]);
317
435
  useEffect2(() => {
318
436
  if (!editor) return;
319
- if (value === "") {
320
- editor.commands.clearContent();
437
+ const next = richValue ?? value;
438
+ if (next === void 0) return;
439
+ const current = readValue(editor);
440
+ const echoIndex = pendingEchoesRef.current.findIndex(
441
+ (snapshot) => richValue !== void 0 ? JSON.stringify(snapshot.json) === JSON.stringify(richValue) : snapshot.text === value || snapshot.legacyText === value
442
+ );
443
+ if (echoIndex !== -1) {
444
+ pendingEchoesRef.current.splice(0, echoIndex + 1);
445
+ setHasValue(!current.isEmpty);
446
+ return;
321
447
  }
322
- }, [editor, value]);
448
+ pendingEchoesRef.current = [];
449
+ const equal = richValue !== void 0 ? JSON.stringify(current.json) === JSON.stringify(richValue) : current.text === value || current.legacyText === value;
450
+ if (!equal)
451
+ editor.commands.setContent(
452
+ richValue ?? stringContent(value, valueFormat),
453
+ { emitUpdate: false }
454
+ );
455
+ setHasValue(!readValue(editor).isEmpty);
456
+ }, [editor, value, richValue, valueFormat]);
457
+ useEffect2(() => {
458
+ if (editor) setHasValue(!readValue(editor).isEmpty);
459
+ }, [editor]);
460
+ useEffect2(() => {
461
+ if (!editor || locked) return;
462
+ if (!autoFocus && pendingFocusRef.current === null) return;
463
+ const position = pendingFocusRef.current ?? "end";
464
+ pendingFocusRef.current = null;
465
+ const frame = requestAnimationFrame(() => {
466
+ if (!editor.isDestroyed && editor.isEditable)
467
+ editor.commands.focus(position);
468
+ });
469
+ return () => cancelAnimationFrame(frame);
470
+ }, [editor, autoFocus, locked]);
323
471
  const contextValue = useMemo2(
324
472
  () => ({
325
473
  editor,
326
474
  placeholder,
475
+ editable: !locked,
327
476
  editorContainerRef: containerRef
328
477
  }),
329
- [editor, placeholder]
478
+ [editor, placeholder, locked]
330
479
  );
331
480
  return /* @__PURE__ */ jsx3(CommentComposerContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxs3(
332
481
  "form",
333
482
  {
483
+ ...props,
484
+ ref: formRef,
485
+ "data-comment-surface": "composer",
486
+ "aria-busy": isSubmitting || void 0,
334
487
  className: cx2(
335
488
  "relative flex gap-12 rounded-default border border-border-minimal bg-bg-base p-12",
336
489
  className
337
490
  ),
491
+ onKeyDown: (event) => {
492
+ if (!handledShortcutEvents.current.has(event.nativeEvent))
493
+ onKeyDown?.(event);
494
+ },
495
+ onKeyDownCapture: (event) => {
496
+ onKeyDownCapture?.(event);
497
+ if (!event.defaultPrevented && submitOnModEnter && !event.nativeEvent.isComposing && (event.ctrlKey || event.metaKey) && event.key === "Enter") {
498
+ handledShortcutEvents.current.add(event.nativeEvent);
499
+ onKeyDown?.(event);
500
+ if (event.defaultPrevented) return;
501
+ event.preventDefault();
502
+ if (!locked && editor && !readValue(editor).isEmpty)
503
+ formRef.current?.requestSubmit();
504
+ }
505
+ },
338
506
  onSubmit: (event) => {
339
507
  event.preventDefault();
340
- if (isSubmitDisabled) return;
341
- onSubmit == null ? void 0 : onSubmit(event);
508
+ if (!locked && editor && !readValue(editor).isEmpty)
509
+ onSubmit?.(event);
342
510
  },
343
- ...props,
344
511
  children: [
345
512
  (avatar || avatarProps) && /* @__PURE__ */ jsx3("div", { className: "shrink-0", children: avatar ?? /* @__PURE__ */ jsx3(Avatar2, { size: "small", ...avatarProps }) }),
346
513
  /* @__PURE__ */ jsxs3("div", { className: "min-w-0 flex-1", children: [
@@ -355,11 +522,11 @@ var CommentComposer = forwardRef2(function CommentComposer2({
355
522
  size: "small",
356
523
  isSecondary: true,
357
524
  onClick: onCancel,
358
- disabled: disabled || isSubmitting,
525
+ disabled: locked,
359
526
  children: cancelLabel
360
527
  }
361
528
  ),
362
- /* @__PURE__ */ jsx3(Button, { type: "submit", size: "small", disabled: isSubmitDisabled, children: isSubmitting ? "Posting..." : submitLabel })
529
+ !externalSubmit && /* @__PURE__ */ jsx3(Button, { type: "submit", size: "small", disabled: isSubmitDisabled, children: isSubmitting ? submittingLabel : submitLabel })
363
530
  ] })
364
531
  ] })
365
532
  ] }),
@@ -367,29 +534,97 @@ var CommentComposer = forwardRef2(function CommentComposer2({
367
534
  ]
368
535
  }
369
536
  ) });
370
- });
537
+ }
538
+ var CommentComposer = forwardRef2(CommentComposerComponent);
371
539
 
372
540
  // src/elements/CommentMention.jsx
373
- import React4, { useMemo as useMemo3, useState as useState2, useCallback, useEffect as useEffect3, useRef as useRef2 } from "react";
541
+ import React4, {
542
+ useMemo as useMemo3,
543
+ useState as useState4,
544
+ useCallback,
545
+ useEffect as useEffect3,
546
+ useRef as useRef2
547
+ } from "react";
374
548
  import { Menu, MenuList, MenuItem } from "@edux-design/menus";
375
549
  import { Avatar as Avatar3 } from "@edux-design/avatars";
550
+
551
+ // src/elements/useMentionPortalContainer.js
552
+ import { useLayoutEffect, useState as useState3 } from "react";
553
+ function resolveMentionPortalContainer(anchor, override) {
554
+ const document = anchor?.ownerDocument;
555
+ if (!document || !anchor.isConnected) return void 0;
556
+ if (override === null) return void 0;
557
+ if (override?.isConnected && override.ownerDocument === document)
558
+ return override;
559
+ const dialog = anchor.closest(
560
+ '[role="dialog"], [role="alertdialog"], dialog'
561
+ );
562
+ return dialog?.isConnected ? dialog : void 0;
563
+ }
564
+ function useMentionPortalContainer(anchorRef, override, active) {
565
+ const [container, setContainer] = useState3(void 0);
566
+ useLayoutEffect(() => {
567
+ let disposed = false;
568
+ let observer;
569
+ let observedDocument;
570
+ const refresh = () => {
571
+ if (disposed) return;
572
+ const anchor = anchorRef?.current;
573
+ const next = resolveMentionPortalContainer(anchor, override);
574
+ setContainer((previous) => previous === next ? previous : next);
575
+ const document = anchor?.ownerDocument;
576
+ if (!active || observedDocument === document) return;
577
+ observer?.disconnect();
578
+ observedDocument = document;
579
+ const MutationObserver = document?.defaultView?.MutationObserver;
580
+ if (MutationObserver && document.documentElement) {
581
+ observer = new MutationObserver(refresh);
582
+ observer.observe(document.documentElement, {
583
+ childList: true,
584
+ subtree: true,
585
+ attributes: true,
586
+ attributeFilter: ["role"]
587
+ });
588
+ }
589
+ };
590
+ refresh();
591
+ return () => {
592
+ disposed = true;
593
+ observer?.disconnect();
594
+ };
595
+ }, [anchorRef, override, active]);
596
+ return container;
597
+ }
598
+
599
+ // src/elements/CommentMention.jsx
376
600
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
377
601
  var CommentMention = ({
378
602
  peopleList = [],
379
- onQueryChange
603
+ onQueryChange,
604
+ portalContainer
380
605
  }) => {
381
- const { editor, editorContainerRef } = useCommentComposer();
382
- const [menuOpen, setMenuOpen] = useState2(false);
383
- const [query, setQuery] = useState2("");
384
- const [range, setRange] = useState2(null);
385
- const [anchorStyle, setAnchorStyle] = useState2({ left: 0, top: 0 });
606
+ const { editor, editorContainerRef, editable = true } = useCommentComposer();
607
+ const [menuOpen, setMenuOpen] = useState4(false);
608
+ const mentionPortalContainer = useMentionPortalContainer(
609
+ editorContainerRef,
610
+ portalContainer,
611
+ menuOpen
612
+ );
613
+ const [query, setQuery] = useState4("");
614
+ const [anchorStyle, setAnchorStyle] = useState4({ left: 0, top: 0 });
386
615
  const menuListRef = useRef2(null);
387
616
  const rangeRef = useRef2(null);
388
617
  const lastQueryRef = useRef2("");
389
- const peopleByHandle = useMemo3(
390
- () => new Map(peopleList.map((person) => [person.handle.toLowerCase(), person])),
391
- [peopleList]
392
- );
618
+ const dismissedRangeRef = useRef2(null);
619
+ const focusFrameRef = useRef2(null);
620
+ const focusTimeoutRef = useRef2(null);
621
+ const dismissMenu = useCallback(() => {
622
+ const activeRange = rangeRef.current;
623
+ dismissedRangeRef.current = activeRange ? `${activeRange.from}:${activeRange.to}` : null;
624
+ setMenuOpen(false);
625
+ if (editor && !editor.isDestroyed && editor.isEditable !== false)
626
+ editor.view.focus();
627
+ }, [editor]);
393
628
  const filteredPeople = useMemo3(() => {
394
629
  if (!query) return peopleList;
395
630
  const lowered = query.toLowerCase();
@@ -398,14 +633,15 @@ var CommentMention = ({
398
633
  );
399
634
  }, [peopleList, query]);
400
635
  const updateMenuState = useCallback(() => {
401
- var _a, _b;
402
- if (!editor) return;
403
- const { from } = editor.state.selection;
636
+ if (!editor || !editable || editor.isEditable === false) {
637
+ setMenuOpen(false);
638
+ rangeRef.current = null;
639
+ return;
640
+ }
404
641
  const { parent, parentOffset, pos } = editor.state.selection.$from;
405
642
  if (!parent.isTextblock) {
406
643
  setMenuOpen(false);
407
644
  setQuery("");
408
- setRange(null);
409
645
  return;
410
646
  }
411
647
  const textBefore = parent.textBetween(0, parentOffset, "\n", "\0");
@@ -417,13 +653,12 @@ var CommentMention = ({
417
653
  onQueryChange(handleQuery);
418
654
  }
419
655
  setQuery(handleQuery);
420
- setMenuOpen(true);
421
656
  const fromPos = pos - match[0].length;
422
657
  const nextRange = { from: fromPos, to: pos };
423
- setRange(nextRange);
658
+ setMenuOpen(dismissedRangeRef.current !== `${fromPos}:${pos}`);
424
659
  rangeRef.current = nextRange;
425
660
  const coords = editor.view.coordsAtPos(fromPos);
426
- const containerRect = (_b = (_a = editorContainerRef == null ? void 0 : editorContainerRef.current) == null ? void 0 : _a.getBoundingClientRect) == null ? void 0 : _b.call(_a);
661
+ const containerRect = editorContainerRef?.current?.getBoundingClientRect?.();
427
662
  if (containerRect) {
428
663
  setAnchorStyle({
429
664
  left: coords.left - containerRect.left + 12,
@@ -434,16 +669,16 @@ var CommentMention = ({
434
669
  }
435
670
  setMenuOpen(false);
436
671
  setQuery("");
437
- setRange(null);
438
672
  rangeRef.current = null;
439
673
  if (onQueryChange && lastQueryRef.current !== "") {
440
674
  lastQueryRef.current = "";
441
675
  onQueryChange("");
442
676
  }
443
- }, [editor, editorContainerRef, onQueryChange]);
677
+ }, [editor, editorContainerRef, onQueryChange, editable]);
444
678
  const handleSelectPerson = useCallback(
445
679
  (person) => {
446
- if (!editor || !rangeRef.current) return;
680
+ if (!editor || !editable || editor.isEditable === false || !rangeRef.current)
681
+ return;
447
682
  const selectionRange = rangeRef.current;
448
683
  editor.commands.focus();
449
684
  editor.commands.setTextSelection(selectionRange);
@@ -463,21 +698,34 @@ var CommentMention = ({
463
698
  { updateSelection: false }
464
699
  );
465
700
  const nextPos = selectionRange.from + 2;
466
- requestAnimationFrame(() => {
701
+ editor.view.focus();
702
+ cancelAnimationFrame(focusFrameRef.current);
703
+ clearTimeout(focusTimeoutRef.current);
704
+ focusFrameRef.current = requestAnimationFrame(() => {
705
+ if (editor.isDestroyed || editor.isEditable === false) return;
467
706
  editor.commands.setTextSelection(nextPos);
468
707
  editor.view.focus();
469
- setTimeout(() => editor.view.focus(), 0);
708
+ focusTimeoutRef.current = setTimeout(() => {
709
+ if (!editor.isDestroyed && editor.isEditable !== false)
710
+ editor.view.focus();
711
+ }, 0);
470
712
  });
471
713
  setMenuOpen(false);
472
714
  setQuery("");
473
- setRange(null);
474
715
  rangeRef.current = null;
475
716
  if (onQueryChange && lastQueryRef.current !== "") {
476
717
  lastQueryRef.current = "";
477
718
  onQueryChange("");
478
719
  }
479
720
  },
480
- [editor, onQueryChange]
721
+ [editor, onQueryChange, editable]
722
+ );
723
+ useEffect3(
724
+ () => () => {
725
+ cancelAnimationFrame(focusFrameRef.current);
726
+ clearTimeout(focusTimeoutRef.current);
727
+ },
728
+ [editor]
481
729
  );
482
730
  useEffect3(() => {
483
731
  if (!editor) return;
@@ -490,25 +738,30 @@ var CommentMention = ({
490
738
  };
491
739
  }, [editor, updateMenuState]);
492
740
  useEffect3(() => {
493
- var _a;
494
741
  if (!editor) return;
495
742
  const handleKeyDown = (event) => {
496
- var _a2;
497
743
  if (!menuOpen) return;
744
+ if (event.key === "Escape") {
745
+ event.preventDefault();
746
+ event.stopPropagation();
747
+ dismissMenu();
748
+ return;
749
+ }
498
750
  if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
499
751
  event.preventDefault();
752
+ event.stopPropagation();
500
753
  const list = menuListRef.current;
501
- const items = list == null ? void 0 : list.querySelectorAll("[data-menu-item]");
502
- if (!list || !(items == null ? void 0 : items.length)) return;
754
+ const items = list?.querySelectorAll("[data-menu-item]");
755
+ if (!list || !items?.length) return;
503
756
  list.focus();
504
757
  const direction = event.key === "ArrowDown" ? 1 : -1;
505
758
  const targetIndex = direction === 1 ? 0 : items.length - 1;
506
- (_a2 = items[targetIndex]) == null ? void 0 : _a2.focus();
759
+ items[targetIndex]?.focus();
507
760
  };
508
- const dom = (_a = editor.view) == null ? void 0 : _a.dom;
509
- dom == null ? void 0 : dom.addEventListener("keydown", handleKeyDown);
510
- return () => dom == null ? void 0 : dom.removeEventListener("keydown", handleKeyDown);
511
- }, [editor, menuOpen]);
761
+ const dom = editor.view?.dom;
762
+ dom?.addEventListener("keydown", handleKeyDown, true);
763
+ return () => dom?.removeEventListener("keydown", handleKeyDown, true);
764
+ }, [editor, menuOpen, dismissMenu]);
512
765
  const menuContent = useMemo3(
513
766
  () => /* @__PURE__ */ jsx4(
514
767
  Menu,
@@ -520,6 +773,7 @@ var CommentMention = ({
520
773
  {
521
774
  type: "button",
522
775
  "aria-hidden": true,
776
+ tabIndex: -1,
523
777
  className: "absolute h-1 w-1 opacity-0",
524
778
  style: {
525
779
  left: `${anchorStyle.left}px`,
@@ -537,34 +791,56 @@ var CommentMention = ({
537
791
  sideOffset: 4,
538
792
  avoidCollisions: true,
539
793
  collisionPadding: 12,
794
+ container: mentionPortalContainer,
540
795
  onCloseAutoFocus: (event) => event.preventDefault()
541
796
  },
542
- children: /* @__PURE__ */ jsx4(MenuList, { ref: menuListRef, className: "min-w-[220px]", children: filteredPeople.map((person) => /* @__PURE__ */ jsx4(
543
- MenuItem,
797
+ children: /* @__PURE__ */ jsx4(
798
+ MenuList,
544
799
  {
545
- onMouseDown: (event) => {
546
- event.preventDefault();
547
- event.stopPropagation();
548
- handleSelectPerson(person);
800
+ ref: menuListRef,
801
+ className: "min-w-[220px]",
802
+ onKeyDownCapture: (event) => {
803
+ if (event.key === "Escape") {
804
+ event.preventDefault();
805
+ event.stopPropagation();
806
+ dismissMenu();
807
+ }
549
808
  },
550
- onClick: () => handleSelectPerson(person),
551
- onPointerDown: (event) => event.preventDefault(),
552
- children: /* @__PURE__ */ jsxs4("span", { className: "flex items-center gap-8", children: [
553
- /* @__PURE__ */ jsx4(Avatar3, { size: "small", ...person.avatarProps }),
554
- /* @__PURE__ */ jsxs4("span", { className: "flex flex-col", children: [
555
- /* @__PURE__ */ jsx4("span", { className: "text-sm text-fg-base", children: person.name }),
556
- /* @__PURE__ */ jsxs4("span", { className: "text-xs text-fg-subtle", children: [
557
- "@",
558
- person.handle
809
+ children: filteredPeople.map((person) => /* @__PURE__ */ jsx4(
810
+ MenuItem,
811
+ {
812
+ onMouseDown: (event) => {
813
+ event.preventDefault();
814
+ event.stopPropagation();
815
+ handleSelectPerson(person);
816
+ },
817
+ onClick: () => handleSelectPerson(person),
818
+ onPointerDown: (event) => event.preventDefault(),
819
+ children: /* @__PURE__ */ jsxs4("span", { className: "flex items-center gap-8", children: [
820
+ /* @__PURE__ */ jsx4(Avatar3, { size: "small", ...person.avatarProps }),
821
+ /* @__PURE__ */ jsxs4("span", { className: "flex flex-col", children: [
822
+ /* @__PURE__ */ jsx4("span", { className: "text-sm text-fg-base", children: person.name }),
823
+ /* @__PURE__ */ jsxs4("span", { className: "text-xs text-fg-subtle", children: [
824
+ "@",
825
+ person.handle
826
+ ] })
827
+ ] })
559
828
  ] })
560
- ] })
561
- ] })
562
- },
563
- person.id
564
- )) })
829
+ },
830
+ person.id
831
+ ))
832
+ }
833
+ )
565
834
  }
566
835
  ),
567
- [filteredPeople, handleSelectPerson, menuOpen]
836
+ [
837
+ filteredPeople,
838
+ handleSelectPerson,
839
+ menuOpen,
840
+ anchorStyle,
841
+ dismissMenu,
842
+ mentionPortalContainer
843
+ ]
568
844
  );
569
845
  if (!editor) return null;
570
846
  return menuContent;