@edux-design/comments 0.1.3 → 0.2.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.js CHANGED
@@ -46,10 +46,14 @@ var VARIANT_STYLES = {
46
46
  };
47
47
  var CommentContext = (0, import_react.createContext)({
48
48
  editable: true,
49
- onResolve: void 0
49
+ onResolve: void 0,
50
+ resolved: false
50
51
  });
51
52
  var useCommentContext = () => (0, import_react.useContext)(CommentContext);
52
- var Comment = (0, import_react.forwardRef)(function Comment2({
53
+ var flattenReplies = (children) => import_react.default.Children.toArray(children).flatMap(
54
+ (child) => (0, import_react.isValidElement)(child) && child.type === import_react.default.Fragment ? flattenReplies(child.props.children) : [child]
55
+ );
56
+ function CommentComponent({
53
57
  author = "Author",
54
58
  timestamp,
55
59
  avatar,
@@ -67,10 +71,7 @@ var Comment = (0, import_react.forwardRef)(function Comment2({
67
71
  }, ref) {
68
72
  const childArray = import_react.default.Children.toArray(children);
69
73
  const actionsSlot = childArray.find(
70
- (child) => {
71
- var _a;
72
- return (0, import_react.isValidElement)(child) && (child.type === CommentActions || ((_a = child.type) == null ? void 0 : _a.displayName) === "Comment.Actions");
73
- }
74
+ (child) => (0, import_react.isValidElement)(child) && (child.type === CommentActions || child.type?.displayName === "Comment.Actions")
74
75
  );
75
76
  const bodyNodes = childArray.filter((child) => child !== actionsSlot);
76
77
  const bodyContent = body ?? bodyNodes;
@@ -78,9 +79,11 @@ var Comment = (0, import_react.forwardRef)(function Comment2({
78
79
  const isResolved = resolved || variant === "resolved";
79
80
  const resolvedVariant = VARIANT_STYLES[isResolved ? "resolved" : variant] ?? VARIANT_STYLES.default;
80
81
  const resolvedAvatarName = avatarName ?? (typeof author === "string" ? author : void 0);
81
- const shouldRenderAvatar = Boolean(avatar || avatarProps || resolvedAvatarName);
82
+ const shouldRenderAvatar = Boolean(
83
+ avatar || avatarProps || resolvedAvatarName
84
+ );
82
85
  const replyItems = (0, import_react.useMemo)(
83
- () => import_react.default.Children.toArray(replies).filter(
86
+ () => flattenReplies(replies).filter(
84
87
  (reply) => reply !== null && reply !== void 0 && reply !== false
85
88
  ),
86
89
  [replies]
@@ -92,49 +95,55 @@ var Comment = (0, import_react.forwardRef)(function Comment2({
92
95
  setShowAllReplies(!shouldCollapseReplies);
93
96
  }, [shouldCollapseReplies]);
94
97
  const visibleReplies = shouldCollapseReplies && !showAllReplies ? [replyItems[replyCount - 1]] : replyItems;
95
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CommentContext.Provider, { value: { editable, onResolve }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
96
- "div",
98
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
99
+ CommentContext.Provider,
97
100
  {
98
- ref,
99
- className: (0, import_utils.cx)(
100
- "flex gap-12 rounded-default border p-12",
101
- resolvedVariant,
102
- className
103
- ),
104
- "data-variant": variant,
105
- ...props,
106
- children: [
107
- shouldRenderAvatar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "shrink-0", children: avatar ?? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_avatars.Avatar, { name: resolvedAvatarName, size: "small", ...avatarProps }) }),
108
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "min-w-0 flex-1", children: [
109
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "flex flex-wrap items-center gap-6", children: [
110
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "truncate text-sm font-semibold text-fg-base", children: author }),
111
- timestamp && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "text-xs text-fg-subtle", children: timestamp })
112
- ] }),
113
- hasBodyContent && !isResolved ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-6 text-sm leading-relaxed text-fg-base", children: bodyContent }) : null,
114
- isResolved ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-6 text-xs font-semibold uppercase tracking-wide text-fg-success-base", children: "Resolved" }) : null,
115
- actionsSlot ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-8", children: actionsSlot }) : null,
116
- replies && !isResolved ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "mt-10 border-l border-border-minimal pl-12", children: [
117
- shouldCollapseReplies && !showAllReplies && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
118
- "button",
119
- {
120
- type: "button",
121
- className: "mb-8 text-xs font-semibold text-fg-info-base",
122
- onClick: () => setShowAllReplies(true),
123
- "aria-expanded": showAllReplies,
124
- children: [
125
- "Show all ",
126
- replyCount,
127
- " replies"
128
- ]
129
- }
130
- ),
131
- visibleReplies
132
- ] }) : null
133
- ] })
134
- ]
101
+ value: { editable, onResolve, resolved: isResolved },
102
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
103
+ "div",
104
+ {
105
+ ref,
106
+ className: (0, import_utils.cx)(
107
+ "flex gap-12 rounded-default border p-12",
108
+ resolvedVariant,
109
+ className
110
+ ),
111
+ "data-variant": variant,
112
+ ...props,
113
+ children: [
114
+ shouldRenderAvatar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "shrink-0", children: avatar ?? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_avatars.Avatar, { name: resolvedAvatarName, size: "small", ...avatarProps }) }),
115
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "min-w-0 flex-1", children: [
116
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "flex flex-wrap items-center gap-6", children: [
117
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "truncate text-sm font-semibold text-fg-base", children: author }),
118
+ timestamp && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "text-xs text-fg-subtle", children: timestamp })
119
+ ] }),
120
+ hasBodyContent && !isResolved ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-6 text-sm leading-relaxed text-fg-base", children: bodyContent }) : null,
121
+ isResolved ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-6 text-xs font-semibold uppercase tracking-wide text-fg-success-base", children: "Resolved" }) : null,
122
+ actionsSlot ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-8", children: actionsSlot }) : null,
123
+ replies && !isResolved ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "mt-[40px] border-l border-border-minimal pl-[20px]", children: [
124
+ shouldCollapseReplies && !showAllReplies && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
125
+ "button",
126
+ {
127
+ type: "button",
128
+ className: "mb-8 text-xs font-semibold text-fg-info-base",
129
+ onClick: () => setShowAllReplies(true),
130
+ "aria-expanded": showAllReplies,
131
+ children: [
132
+ "Show all ",
133
+ replyCount,
134
+ " replies"
135
+ ]
136
+ }
137
+ ),
138
+ visibleReplies
139
+ ] }) : null
140
+ ] })
141
+ ]
142
+ }
143
+ )
135
144
  }
136
- ) });
137
- });
145
+ );
146
+ }
138
147
  var CommentActions = ({ children, className, ...props }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
139
148
  "div",
140
149
  {
@@ -168,31 +177,39 @@ var CommentResolve = ({
168
177
  className,
169
178
  disabled,
170
179
  onClick,
180
+ label,
171
181
  ...props
172
182
  }) => {
173
- const { editable, onResolve } = useCommentContext();
183
+ const { editable, onResolve, resolved } = useCommentContext();
174
184
  const isDisabled = disabled || !editable;
175
185
  const handleClick = onClick ?? onResolve;
186
+ const isResolved = Boolean(resolved);
187
+ const actionLabel = label ?? (isResolved ? "Re-open" : "Resolve");
188
+ const toneClass = isResolved ? "text-fg-info-base" : "text-fg-success-base";
176
189
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
177
190
  "button",
178
191
  {
179
192
  type: "button",
180
193
  className: (0, import_utils.cx)(
181
- "text-xs font-semibold text-fg-success-base cursor-pointer",
194
+ "text-xs font-semibold cursor-pointer",
195
+ toneClass,
182
196
  className
183
197
  ),
184
198
  onClick: handleClick,
185
199
  disabled: isDisabled,
186
200
  "aria-disabled": isDisabled,
187
201
  ...props,
188
- children: "Resolve"
202
+ children: actionLabel
189
203
  }
190
204
  );
191
205
  };
192
- Comment.Actions = CommentActions;
193
- Comment.Reply = CommentReply;
194
- Comment.Edit = CommentEdit;
195
- Comment.Resolve = CommentResolve;
206
+ var Comment = Object.assign((0, import_react.forwardRef)(CommentComponent), {
207
+ Actions: CommentActions,
208
+ Reply: CommentReply,
209
+ Edit: CommentEdit,
210
+ Resolve: CommentResolve
211
+ });
212
+ Comment.displayName = "Comment";
196
213
  CommentActions.displayName = "Comment.Actions";
197
214
  CommentReply.displayName = "Comment.Reply";
198
215
  CommentEdit.displayName = "Comment.Edit";
@@ -214,9 +231,8 @@ var import_react3 = require("@tiptap/react");
214
231
  var import_mentions = require("@edux-design/mentions");
215
232
  var import_jsx_runtime2 = require("react/jsx-runtime");
216
233
  var MentionNodeView = ({ node }) => {
217
- var _a, _b;
218
- const handle = ((_a = node.attrs) == null ? void 0 : _a.handle) ?? "";
219
- const label = ((_b = node.attrs) == null ? void 0 : _b.label) ?? "";
234
+ const handle = node.attrs?.handle ?? "";
235
+ const label = node.attrs?.label ?? "";
220
236
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
221
237
  import_react3.NodeViewWrapper,
222
238
  {
@@ -259,6 +275,11 @@ var CommentMentionExtension = import_core.Node.create({
259
275
  label
260
276
  ];
261
277
  },
278
+ renderText({ node }) {
279
+ const handle = node.attrs.handle || "";
280
+ const label = node.attrs.label || "";
281
+ return `@${handle || label || "mention"}`;
282
+ },
262
283
  addNodeView() {
263
284
  return (0, import_react3.ReactNodeViewRenderer)(MentionNodeView);
264
285
  }
@@ -276,87 +297,237 @@ var useCommentComposer = () => {
276
297
  }
277
298
  return context;
278
299
  };
279
- var CommentComposer = (0, import_react4.forwardRef)(function CommentComposer2({
300
+ function readValue(editor) {
301
+ const mentions = [];
302
+ editor.state.doc.descendants((node) => {
303
+ if (node.type.name === "commentMention") {
304
+ mentions.push({
305
+ id: node.attrs.id ?? null,
306
+ handle: node.attrs.handle ?? "",
307
+ label: node.attrs.label ?? ""
308
+ });
309
+ }
310
+ });
311
+ const legacyText = editor.getText();
312
+ const text = editor.getText({
313
+ blockSeparator: "\n",
314
+ textSerializers: {
315
+ commentMention: ({ node }) => {
316
+ const { handle = "", label = "" } = node.attrs;
317
+ return label ? `@${handle} \xB7 ${label}` : `@${handle}`;
318
+ }
319
+ }
320
+ }).replace(/\u00a0/g, " ").replace(/[\u200b-\u200d\ufeff]/g, "").replace(/\r?\n/g, " ");
321
+ return {
322
+ text,
323
+ legacyText,
324
+ html: editor.getHTML(),
325
+ json: editor.getJSON(),
326
+ mentions,
327
+ isEmpty: !text.trim()
328
+ };
329
+ }
330
+ function stringContent(value, format) {
331
+ if (format !== "text") return value;
332
+ return {
333
+ type: "doc",
334
+ content: value.split(/\r?\n/).map((line) => ({
335
+ type: "paragraph",
336
+ ...line ? { content: [{ type: "text", text: line }] } : {}
337
+ }))
338
+ };
339
+ }
340
+ function CommentComposerComponent({
280
341
  value,
342
+ valueFormat = "html",
343
+ richValue,
281
344
  onChange,
345
+ onValueChange,
282
346
  onSubmit,
283
347
  onCancel,
348
+ composerRef,
349
+ editorLabel = "Comment",
350
+ editorAttributes: suppliedEditorAttributes,
351
+ autoFocus = false,
352
+ externalSubmit = false,
353
+ submitOnModEnter = false,
284
354
  placeholder = "Write a comment...",
285
355
  rows = 4,
286
356
  disabled = false,
287
357
  isSubmitting = false,
288
358
  submitLabel = "Post",
359
+ submittingLabel = "Posting...",
289
360
  cancelLabel = "Cancel",
290
361
  avatar,
291
362
  avatarProps,
292
363
  actions,
293
364
  className,
294
365
  children,
366
+ onKeyDown,
367
+ onKeyDownCapture,
295
368
  ...props
296
369
  }, ref) {
297
- const hasValue = value === void 0 ? true : Boolean(value && value.trim().length);
298
- const isSubmitDisabled = disabled || isSubmitting || !hasValue;
370
+ const containerRef = (0, import_react4.useRef)(null);
371
+ const formRef = (0, import_react4.useRef)(null);
372
+ const pendingFocusRef = (0, import_react4.useRef)(null);
373
+ const handledShortcutEvents = (0, import_react4.useRef)(/* @__PURE__ */ new WeakSet());
374
+ const callbacks = (0, import_react4.useRef)({ onChange, onValueChange });
375
+ callbacks.current = { onChange, onValueChange };
376
+ const pendingEchoesRef = (0, import_react4.useRef)([]);
377
+ const controlledRef = (0, import_react4.useRef)(false);
378
+ controlledRef.current = value !== void 0 || richValue !== void 0;
379
+ const placeholderRef = (0, import_react4.useRef)(placeholder);
380
+ placeholderRef.current = placeholder;
381
+ const [hasValue, setHasValue] = (0, import_react4.useState)(Boolean(richValue || value?.trim()));
382
+ const locked = disabled || isSubmitting;
383
+ const editorAttributes = (0, import_react4.useMemo)(
384
+ () => ({
385
+ ...suppliedEditorAttributes,
386
+ "data-comment-editor": "true",
387
+ role: "textbox",
388
+ "aria-label": editorLabel,
389
+ "aria-multiline": "true",
390
+ "aria-disabled": String(locked),
391
+ class: (0, import_utils2.cx)(
392
+ "w-full bg-bg-base text-fg-base border-2 rounded-md border-border-base",
393
+ "outline-none transition-all duration-300 ease-in-out motion-reduce:transition-none",
394
+ "pl-[20px] py-12 font-normal text-base leading-relaxed overflow-y-auto",
395
+ "focus:shadow-focus focus-visible:shadow-focus",
396
+ locked && "bg-bg-subtle border-border-subtle text-fg-inactive"
397
+ ),
398
+ style: `height: ${Math.max(1, rows) * 30}px`
399
+ }),
400
+ [editorLabel, locked, rows, suppliedEditorAttributes]
401
+ );
402
+ const isSubmitDisabled = locked || !hasValue;
299
403
  const editor = (0, import_react5.useEditor)({
404
+ immediatelyRender: false,
300
405
  extensions: [
301
406
  import_starter_kit.default,
302
407
  CommentMentionExtension,
303
- import_extension_placeholder.default.configure({
304
- placeholder
305
- })
408
+ import_extension_placeholder.default.configure({ placeholder: () => placeholderRef.current })
306
409
  ],
307
- content: value ?? "",
308
- editable: !disabled,
309
- editorProps: {
310
- attributes: {
311
- class: (0, import_utils2.cx)(
312
- "w-full bg-white border-2 rounded-md border-border-base",
313
- "outline-none transition-all duration-300 ease-in-out",
314
- "pl-5 py-3 font-normal text-base leading-relaxed",
315
- "h-[120px] overflow-y-auto",
316
- "focus:shadow-focus focus-visible:shadow-focus",
317
- disabled && "bg-bg-subtle border-border-subtle text-fg-inactive"
318
- )
319
- }
320
- },
410
+ content: richValue ?? stringContent(value ?? "", valueFormat),
411
+ editable: !locked,
412
+ editorProps: { attributes: editorAttributes },
321
413
  onUpdate: ({ editor: editor2 }) => {
322
- onChange == null ? void 0 : onChange({ target: { value: editor2.getText() } });
414
+ const next = readValue(editor2);
415
+ if (controlledRef.current && (callbacks.current.onChange || callbacks.current.onValueChange)) {
416
+ pendingEchoesRef.current.push(next);
417
+ if (pendingEchoesRef.current.length > 100)
418
+ pendingEchoesRef.current.shift();
419
+ }
420
+ setHasValue(!next.isEmpty);
421
+ callbacks.current.onChange?.({ target: { value: next.legacyText } });
422
+ callbacks.current.onValueChange?.(next);
323
423
  }
324
424
  });
325
- const containerRef = (0, import_react4.useRef)(null);
425
+ (0, import_react4.useImperativeHandle)(ref, () => containerRef.current, []);
426
+ (0, import_react4.useImperativeHandle)(
427
+ composerRef,
428
+ () => ({
429
+ focus: (position = "end") => {
430
+ if (locked) return false;
431
+ if (!editor) {
432
+ pendingFocusRef.current = position;
433
+ return true;
434
+ }
435
+ return editor.commands.focus(position);
436
+ },
437
+ clear: () => !locked && Boolean(editor?.commands.clearContent(true)),
438
+ getValue: () => editor ? readValue(editor) : null,
439
+ submit: () => {
440
+ if (!locked) formRef.current?.requestSubmit();
441
+ }
442
+ }),
443
+ [editor, locked]
444
+ );
326
445
  (0, import_react4.useEffect)(() => {
327
- if (typeof ref === "function") {
328
- ref(containerRef.current);
329
- } else if (ref) {
330
- ref.current = containerRef.current;
331
- }
332
- }, [ref]);
446
+ if (!editor) return;
447
+ editor.setEditable(!locked, false);
448
+ editor.setOptions({
449
+ editorProps: {
450
+ ...editor.options.editorProps,
451
+ attributes: editorAttributes
452
+ }
453
+ });
454
+ }, [editor, locked, editorAttributes, placeholder]);
333
455
  (0, import_react4.useEffect)(() => {
334
456
  if (!editor) return;
335
- if (value === "") {
336
- editor.commands.clearContent();
457
+ const next = richValue ?? value;
458
+ if (next === void 0) return;
459
+ const current = readValue(editor);
460
+ const echoIndex = pendingEchoesRef.current.findIndex(
461
+ (snapshot) => richValue !== void 0 ? JSON.stringify(snapshot.json) === JSON.stringify(richValue) : snapshot.text === value || snapshot.legacyText === value
462
+ );
463
+ if (echoIndex !== -1) {
464
+ pendingEchoesRef.current.splice(0, echoIndex + 1);
465
+ setHasValue(!current.isEmpty);
466
+ return;
337
467
  }
338
- }, [editor, value]);
468
+ pendingEchoesRef.current = [];
469
+ const equal = richValue !== void 0 ? JSON.stringify(current.json) === JSON.stringify(richValue) : current.text === value || current.legacyText === value;
470
+ if (!equal)
471
+ editor.commands.setContent(
472
+ richValue ?? stringContent(value, valueFormat),
473
+ { emitUpdate: false }
474
+ );
475
+ setHasValue(!readValue(editor).isEmpty);
476
+ }, [editor, value, richValue, valueFormat]);
477
+ (0, import_react4.useEffect)(() => {
478
+ if (editor) setHasValue(!readValue(editor).isEmpty);
479
+ }, [editor]);
480
+ (0, import_react4.useEffect)(() => {
481
+ if (!editor || locked) return;
482
+ if (!autoFocus && pendingFocusRef.current === null) return;
483
+ const position = pendingFocusRef.current ?? "end";
484
+ pendingFocusRef.current = null;
485
+ const frame = requestAnimationFrame(() => {
486
+ if (!editor.isDestroyed && editor.isEditable)
487
+ editor.commands.focus(position);
488
+ });
489
+ return () => cancelAnimationFrame(frame);
490
+ }, [editor, autoFocus, locked]);
339
491
  const contextValue = (0, import_react4.useMemo)(
340
492
  () => ({
341
493
  editor,
342
494
  placeholder,
495
+ editable: !locked,
343
496
  editorContainerRef: containerRef
344
497
  }),
345
- [editor, placeholder]
498
+ [editor, placeholder, locked]
346
499
  );
347
500
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(CommentComposerContext.Provider, { value: contextValue, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
348
501
  "form",
349
502
  {
503
+ ...props,
504
+ ref: formRef,
505
+ "data-comment-surface": "composer",
506
+ "aria-busy": isSubmitting || void 0,
350
507
  className: (0, import_utils2.cx)(
351
508
  "relative flex gap-12 rounded-default border border-border-minimal bg-bg-base p-12",
352
509
  className
353
510
  ),
511
+ onKeyDown: (event) => {
512
+ if (!handledShortcutEvents.current.has(event.nativeEvent))
513
+ onKeyDown?.(event);
514
+ },
515
+ onKeyDownCapture: (event) => {
516
+ onKeyDownCapture?.(event);
517
+ if (!event.defaultPrevented && submitOnModEnter && !event.nativeEvent.isComposing && (event.ctrlKey || event.metaKey) && event.key === "Enter") {
518
+ handledShortcutEvents.current.add(event.nativeEvent);
519
+ onKeyDown?.(event);
520
+ if (event.defaultPrevented) return;
521
+ event.preventDefault();
522
+ if (!locked && editor && !readValue(editor).isEmpty)
523
+ formRef.current?.requestSubmit();
524
+ }
525
+ },
354
526
  onSubmit: (event) => {
355
527
  event.preventDefault();
356
- if (isSubmitDisabled) return;
357
- onSubmit == null ? void 0 : onSubmit(event);
528
+ if (!locked && editor && !readValue(editor).isEmpty)
529
+ onSubmit?.(event);
358
530
  },
359
- ...props,
360
531
  children: [
361
532
  (avatar || avatarProps) && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "shrink-0", children: avatar ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_avatars2.Avatar, { size: "small", ...avatarProps }) }),
362
533
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "min-w-0 flex-1", children: [
@@ -371,11 +542,11 @@ var CommentComposer = (0, import_react4.forwardRef)(function CommentComposer2({
371
542
  size: "small",
372
543
  isSecondary: true,
373
544
  onClick: onCancel,
374
- disabled: disabled || isSubmitting,
545
+ disabled: locked,
375
546
  children: cancelLabel
376
547
  }
377
548
  ),
378
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_buttons.Button, { type: "submit", size: "small", disabled: isSubmitDisabled, children: isSubmitting ? "Posting..." : submitLabel })
549
+ !externalSubmit && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_buttons.Button, { type: "submit", size: "small", disabled: isSubmitDisabled, children: isSubmitting ? submittingLabel : submitLabel })
379
550
  ] })
380
551
  ] })
381
552
  ] }),
@@ -383,27 +554,32 @@ var CommentComposer = (0, import_react4.forwardRef)(function CommentComposer2({
383
554
  ]
384
555
  }
385
556
  ) });
386
- });
557
+ }
558
+ var CommentComposer = (0, import_react4.forwardRef)(CommentComposerComponent);
387
559
 
388
560
  // src/elements/CommentMention.jsx
389
561
  var import_react6 = __toESM(require("react"));
390
562
  var import_menus = require("@edux-design/menus");
391
563
  var import_avatars3 = require("@edux-design/avatars");
392
564
  var import_jsx_runtime4 = require("react/jsx-runtime");
393
- var CommentMention = ({
394
- peopleList = []
395
- }) => {
396
- const { editor, editorContainerRef } = useCommentComposer();
565
+ var CommentMention = ({ peopleList = [], onQueryChange }) => {
566
+ const { editor, editorContainerRef, editable = true } = useCommentComposer();
397
567
  const [menuOpen, setMenuOpen] = (0, import_react6.useState)(false);
398
568
  const [query, setQuery] = (0, import_react6.useState)("");
399
- const [range, setRange] = (0, import_react6.useState)(null);
400
569
  const [anchorStyle, setAnchorStyle] = (0, import_react6.useState)({ left: 0, top: 0 });
401
570
  const menuListRef = (0, import_react6.useRef)(null);
402
571
  const rangeRef = (0, import_react6.useRef)(null);
403
- const peopleByHandle = (0, import_react6.useMemo)(
404
- () => new Map(peopleList.map((person) => [person.handle.toLowerCase(), person])),
405
- [peopleList]
406
- );
572
+ const lastQueryRef = (0, import_react6.useRef)("");
573
+ const dismissedRangeRef = (0, import_react6.useRef)(null);
574
+ const focusFrameRef = (0, import_react6.useRef)(null);
575
+ const focusTimeoutRef = (0, import_react6.useRef)(null);
576
+ const dismissMenu = (0, import_react6.useCallback)(() => {
577
+ const activeRange = rangeRef.current;
578
+ dismissedRangeRef.current = activeRange ? `${activeRange.from}:${activeRange.to}` : null;
579
+ setMenuOpen(false);
580
+ if (editor && !editor.isDestroyed && editor.isEditable !== false)
581
+ editor.view.focus();
582
+ }, [editor]);
407
583
  const filteredPeople = (0, import_react6.useMemo)(() => {
408
584
  if (!query) return peopleList;
409
585
  const lowered = query.toLowerCase();
@@ -412,28 +588,32 @@ var CommentMention = ({
412
588
  );
413
589
  }, [peopleList, query]);
414
590
  const updateMenuState = (0, import_react6.useCallback)(() => {
415
- var _a, _b;
416
- if (!editor) return;
417
- const { from } = editor.state.selection;
591
+ if (!editor || !editable || editor.isEditable === false) {
592
+ setMenuOpen(false);
593
+ rangeRef.current = null;
594
+ return;
595
+ }
418
596
  const { parent, parentOffset, pos } = editor.state.selection.$from;
419
597
  if (!parent.isTextblock) {
420
598
  setMenuOpen(false);
421
599
  setQuery("");
422
- setRange(null);
423
600
  return;
424
601
  }
425
602
  const textBefore = parent.textBetween(0, parentOffset, "\n", "\0");
426
603
  const match = textBefore.match(/@([\w-]*)$/);
427
604
  if (match) {
428
605
  const handleQuery = match[1] ?? "";
606
+ if (onQueryChange && handleQuery !== lastQueryRef.current) {
607
+ lastQueryRef.current = handleQuery;
608
+ onQueryChange(handleQuery);
609
+ }
429
610
  setQuery(handleQuery);
430
- setMenuOpen(true);
431
611
  const fromPos = pos - match[0].length;
432
612
  const nextRange = { from: fromPos, to: pos };
433
- setRange(nextRange);
613
+ setMenuOpen(dismissedRangeRef.current !== `${fromPos}:${pos}`);
434
614
  rangeRef.current = nextRange;
435
615
  const coords = editor.view.coordsAtPos(fromPos);
436
- const containerRect = (_b = (_a = editorContainerRef == null ? void 0 : editorContainerRef.current) == null ? void 0 : _a.getBoundingClientRect) == null ? void 0 : _b.call(_a);
616
+ const containerRect = editorContainerRef?.current?.getBoundingClientRect?.();
437
617
  if (containerRect) {
438
618
  setAnchorStyle({
439
619
  left: coords.left - containerRect.left + 12,
@@ -444,12 +624,16 @@ var CommentMention = ({
444
624
  }
445
625
  setMenuOpen(false);
446
626
  setQuery("");
447
- setRange(null);
448
627
  rangeRef.current = null;
449
- }, [editor]);
628
+ if (onQueryChange && lastQueryRef.current !== "") {
629
+ lastQueryRef.current = "";
630
+ onQueryChange("");
631
+ }
632
+ }, [editor, editorContainerRef, onQueryChange, editable]);
450
633
  const handleSelectPerson = (0, import_react6.useCallback)(
451
634
  (person) => {
452
- if (!editor || !rangeRef.current) return;
635
+ if (!editor || !editable || editor.isEditable === false || !rangeRef.current)
636
+ return;
453
637
  const selectionRange = rangeRef.current;
454
638
  editor.commands.focus();
455
639
  editor.commands.setTextSelection(selectionRange);
@@ -469,15 +653,32 @@ var CommentMention = ({
469
653
  { updateSelection: false }
470
654
  );
471
655
  const nextPos = selectionRange.from + 2;
472
- requestAnimationFrame(() => {
656
+ editor.view.focus();
657
+ cancelAnimationFrame(focusFrameRef.current);
658
+ clearTimeout(focusTimeoutRef.current);
659
+ focusFrameRef.current = requestAnimationFrame(() => {
660
+ if (editor.isDestroyed || editor.isEditable === false) return;
473
661
  editor.commands.setTextSelection(nextPos);
474
662
  editor.view.focus();
475
- setTimeout(() => editor.view.focus(), 0);
663
+ focusTimeoutRef.current = setTimeout(() => {
664
+ if (!editor.isDestroyed && editor.isEditable !== false)
665
+ editor.view.focus();
666
+ }, 0);
476
667
  });
477
668
  setMenuOpen(false);
478
669
  setQuery("");
479
- setRange(null);
480
670
  rangeRef.current = null;
671
+ if (onQueryChange && lastQueryRef.current !== "") {
672
+ lastQueryRef.current = "";
673
+ onQueryChange("");
674
+ }
675
+ },
676
+ [editor, onQueryChange, editable]
677
+ );
678
+ (0, import_react6.useEffect)(
679
+ () => () => {
680
+ cancelAnimationFrame(focusFrameRef.current);
681
+ clearTimeout(focusTimeoutRef.current);
481
682
  },
482
683
  [editor]
483
684
  );
@@ -492,25 +693,30 @@ var CommentMention = ({
492
693
  };
493
694
  }, [editor, updateMenuState]);
494
695
  (0, import_react6.useEffect)(() => {
495
- var _a;
496
696
  if (!editor) return;
497
697
  const handleKeyDown = (event) => {
498
- var _a2;
499
698
  if (!menuOpen) return;
699
+ if (event.key === "Escape") {
700
+ event.preventDefault();
701
+ event.stopPropagation();
702
+ dismissMenu();
703
+ return;
704
+ }
500
705
  if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
501
706
  event.preventDefault();
707
+ event.stopPropagation();
502
708
  const list = menuListRef.current;
503
- const items = list == null ? void 0 : list.querySelectorAll("[data-menu-item]");
504
- if (!list || !(items == null ? void 0 : items.length)) return;
709
+ const items = list?.querySelectorAll("[data-menu-item]");
710
+ if (!list || !items?.length) return;
505
711
  list.focus();
506
712
  const direction = event.key === "ArrowDown" ? 1 : -1;
507
713
  const targetIndex = direction === 1 ? 0 : items.length - 1;
508
- (_a2 = items[targetIndex]) == null ? void 0 : _a2.focus();
714
+ items[targetIndex]?.focus();
509
715
  };
510
- const dom = (_a = editor.view) == null ? void 0 : _a.dom;
511
- dom == null ? void 0 : dom.addEventListener("keydown", handleKeyDown);
512
- return () => dom == null ? void 0 : dom.removeEventListener("keydown", handleKeyDown);
513
- }, [editor, menuOpen]);
716
+ const dom = editor.view?.dom;
717
+ dom?.addEventListener("keydown", handleKeyDown, true);
718
+ return () => dom?.removeEventListener("keydown", handleKeyDown, true);
719
+ }, [editor, menuOpen, dismissMenu]);
514
720
  const menuContent = (0, import_react6.useMemo)(
515
721
  () => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
516
722
  import_menus.Menu,
@@ -522,6 +728,7 @@ var CommentMention = ({
522
728
  {
523
729
  type: "button",
524
730
  "aria-hidden": true,
731
+ tabIndex: -1,
525
732
  className: "absolute h-1 w-1 opacity-0",
526
733
  style: {
527
734
  left: `${anchorStyle.left}px`,
@@ -541,32 +748,46 @@ var CommentMention = ({
541
748
  collisionPadding: 12,
542
749
  onCloseAutoFocus: (event) => event.preventDefault()
543
750
  },
544
- children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_menus.MenuList, { ref: menuListRef, className: "min-w-[220px]", children: filteredPeople.map((person) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
545
- import_menus.MenuItem,
751
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
752
+ import_menus.MenuList,
546
753
  {
547
- onMouseDown: (event) => {
548
- event.preventDefault();
549
- event.stopPropagation();
550
- handleSelectPerson(person);
754
+ ref: menuListRef,
755
+ className: "min-w-[220px]",
756
+ onKeyDownCapture: (event) => {
757
+ if (event.key === "Escape") {
758
+ event.preventDefault();
759
+ event.stopPropagation();
760
+ dismissMenu();
761
+ }
551
762
  },
552
- onClick: () => handleSelectPerson(person),
553
- onPointerDown: (event) => event.preventDefault(),
554
- children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "flex items-center gap-8", children: [
555
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_avatars3.Avatar, { size: "small", ...person.avatarProps }),
556
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "flex flex-col", children: [
557
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "text-sm text-fg-base", children: person.name }),
558
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "text-xs text-fg-subtle", children: [
559
- "@",
560
- person.handle
763
+ children: filteredPeople.map((person) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
764
+ import_menus.MenuItem,
765
+ {
766
+ onMouseDown: (event) => {
767
+ event.preventDefault();
768
+ event.stopPropagation();
769
+ handleSelectPerson(person);
770
+ },
771
+ onClick: () => handleSelectPerson(person),
772
+ onPointerDown: (event) => event.preventDefault(),
773
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "flex items-center gap-8", children: [
774
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_avatars3.Avatar, { size: "small", ...person.avatarProps }),
775
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "flex flex-col", children: [
776
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "text-sm text-fg-base", children: person.name }),
777
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "text-xs text-fg-subtle", children: [
778
+ "@",
779
+ person.handle
780
+ ] })
781
+ ] })
561
782
  ] })
562
- ] })
563
- ] })
564
- },
565
- person.id
566
- )) })
783
+ },
784
+ person.id
785
+ ))
786
+ }
787
+ )
567
788
  }
568
789
  ),
569
- [filteredPeople, handleSelectPerson, menuOpen]
790
+ [filteredPeople, handleSelectPerson, menuOpen, anchorStyle, dismissMenu]
570
791
  );
571
792
  if (!editor) return null;
572
793
  return menuContent;