diff --git a/apps/web/src/common/components/CommentInputNewline.test.tsx b/apps/web/src/common/components/CommentInputNewline.test.tsx
new file mode 100644
index 0000000000..c4b29e7c2a
--- /dev/null
+++ b/apps/web/src/common/components/CommentInputNewline.test.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import LabCommentInput from '@modules/lab/model/Analyze/CommentDrawer/CommentInput';
+import SandboxCommentInput from '@modules/oh/DataView/HatchingTreatment/Sandbox/EvaluationInfo/MetricDrawer/CommentInput';
+import HatchCommentInput from '@modules/oh/DataView/HatchingTreatment/Hatch/EvaluationInfo/MetricDrawer/CommentInput';
+import GraduateCommentInput from '@modules/oh/DataView/HatchingTreatment/Graduate/EvaluationInfo/MetricDrawer/CommentInput';
+
+jest.mock('next-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@common/hooks/useImagePreview', () => ({
+ __esModule: true,
+ default: () => ({ ref: jest.fn(), open: jest.fn(), close: jest.fn() }),
+}));
+
+describe.each([
+ { name: 'Lab', Component: LabCommentInput },
+ { name: 'Sandbox', Component: SandboxCommentInput },
+ { name: 'Hatch', Component: HatchCommentInput },
+ { name: 'Graduate', Component: GraduateCommentInput },
+])('$name comment newlines', ({ Component }) => {
+ describe.each([
+ { name: 'Shift+Enter', modifiers: { shiftKey: true } },
+ { name: 'Ctrl+Enter', modifiers: { ctrlKey: true } },
+ ])('$name', ({ modifiers }) => {
+ it.each([
+ { position: 'start', start: 0, end: 0, expected: '\nabcdef' },
+ { position: 'middle', start: 3, end: 3, expected: 'abc\ndef' },
+ { position: 'selection', start: 2, end: 4, expected: 'ab\nef' },
+ { position: 'end', start: 6, end: 6, expected: 'abcdef\n' },
+ ])('inserts a newline at the $position', ({ start, end, expected }) => {
+ const onSubmit = jest.fn();
+ render();
+ const input = screen.getByRole('textbox') as HTMLTextAreaElement;
+ fireEvent.change(input, { target: { value: 'abcdef' } });
+ input.focus();
+ input.setSelectionRange(start, end);
+
+ const accepted = fireEvent.keyDown(input, {
+ key: 'Enter',
+ code: 'Enter',
+ ...modifiers,
+ });
+
+ expect(input.value).toBe(expected);
+ expect(input.selectionStart).toBe(start + 1);
+ expect(input.selectionEnd).toBe(start + 1);
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(accepted).toBe(false);
+ });
+ });
+
+ it('continues submitting plain Enter', () => {
+ const onSubmit = jest.fn();
+ render();
+ const input = screen.getByRole('textbox');
+ fireEvent.change(input, { target: { value: 'abcdef' } });
+
+ fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit).toHaveBeenCalledWith('abcdef', []);
+ });
+
+ it('does not insert a newline or submit while loading', () => {
+ const onSubmit = jest.fn();
+ render();
+ const input = screen.getByRole('textbox') as HTMLTextAreaElement;
+ fireEvent.change(input, { target: { value: 'abcdef' } });
+ input.setSelectionRange(2, 2);
+
+ fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
+ fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true });
+
+ expect(input.value).toBe('abcdef');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/modules/lab/model/Analyze/CommentDrawer/CommentInput.tsx b/apps/web/src/modules/lab/model/Analyze/CommentDrawer/CommentInput.tsx
index 6e74a3273f..df2a5ed491 100644
--- a/apps/web/src/modules/lab/model/Analyze/CommentDrawer/CommentInput.tsx
+++ b/apps/web/src/modules/lab/model/Analyze/CommentDrawer/CommentInput.tsx
@@ -5,6 +5,7 @@ import React, {
forwardRef,
useImperativeHandle,
} from 'react';
+import { flushSync } from 'react-dom';
import { FiImage } from 'react-icons/fi';
import { BiLoaderAlt } from 'react-icons/bi';
import { toast } from 'react-hot-toast';
@@ -116,7 +117,19 @@ const CommentInput = forwardRef(
if (loading) return;
if (event.key == 'Enter' || event.code == 'Enter') {
if (event.shiftKey || event.ctrlKey) {
- setValue((p) => p + '\n');
+ const input = event.currentTarget;
+ const { selectionStart, selectionEnd, value } = input;
+ flushSync(() => {
+ setValue(
+ value.slice(0, selectionStart) +
+ '\n' +
+ value.slice(selectionEnd)
+ );
+ });
+ input.setSelectionRange(
+ selectionStart + 1,
+ selectionStart + 1
+ );
} else {
if (value) {
onSubmit(value, images);
diff --git a/apps/web/src/modules/oh/DataView/HatchingTreatment/Graduate/EvaluationInfo/MetricDrawer/CommentInput.tsx b/apps/web/src/modules/oh/DataView/HatchingTreatment/Graduate/EvaluationInfo/MetricDrawer/CommentInput.tsx
index 5aa8b0df60..34268a6de9 100644
--- a/apps/web/src/modules/oh/DataView/HatchingTreatment/Graduate/EvaluationInfo/MetricDrawer/CommentInput.tsx
+++ b/apps/web/src/modules/oh/DataView/HatchingTreatment/Graduate/EvaluationInfo/MetricDrawer/CommentInput.tsx
@@ -4,6 +4,7 @@ import React, {
forwardRef,
useImperativeHandle,
} from 'react';
+import { flushSync } from 'react-dom';
import { BiLoaderAlt } from 'react-icons/bi';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'next-i18next';
@@ -124,7 +125,19 @@ const CommentInput = forwardRef(
if (loading) return;
if (event.key == 'Enter' || event.code == 'Enter') {
if (event.shiftKey || event.ctrlKey) {
- setValue((p) => p + '\n');
+ const input = event.currentTarget;
+ const { selectionStart, selectionEnd, value } = input;
+ flushSync(() => {
+ setValue(
+ value.slice(0, selectionStart) +
+ '\n' +
+ value.slice(selectionEnd)
+ );
+ });
+ input.setSelectionRange(
+ selectionStart + 1,
+ selectionStart + 1
+ );
} else {
if (value) {
onSubmit(value, images);
diff --git a/apps/web/src/modules/oh/DataView/HatchingTreatment/Hatch/EvaluationInfo/MetricDrawer/CommentInput.tsx b/apps/web/src/modules/oh/DataView/HatchingTreatment/Hatch/EvaluationInfo/MetricDrawer/CommentInput.tsx
index 18c409ea45..71096bcac7 100644
--- a/apps/web/src/modules/oh/DataView/HatchingTreatment/Hatch/EvaluationInfo/MetricDrawer/CommentInput.tsx
+++ b/apps/web/src/modules/oh/DataView/HatchingTreatment/Hatch/EvaluationInfo/MetricDrawer/CommentInput.tsx
@@ -5,6 +5,7 @@ import React, {
forwardRef,
useImperativeHandle,
} from 'react';
+import { flushSync } from 'react-dom';
import { FiImage } from 'react-icons/fi';
import { BiLoaderAlt } from 'react-icons/bi';
import { toast } from 'react-hot-toast';
@@ -128,7 +129,19 @@ const CommentInput = forwardRef(
if (loading) return;
if (event.key == 'Enter' || event.code == 'Enter') {
if (event.shiftKey || event.ctrlKey) {
- setValue((p) => p + '\n');
+ const input = event.currentTarget;
+ const { selectionStart, selectionEnd, value } = input;
+ flushSync(() => {
+ setValue(
+ value.slice(0, selectionStart) +
+ '\n' +
+ value.slice(selectionEnd)
+ );
+ });
+ input.setSelectionRange(
+ selectionStart + 1,
+ selectionStart + 1
+ );
} else {
if (value) {
onSubmit(value, images);
diff --git a/apps/web/src/modules/oh/DataView/HatchingTreatment/Sandbox/EvaluationInfo/MetricDrawer/CommentInput.tsx b/apps/web/src/modules/oh/DataView/HatchingTreatment/Sandbox/EvaluationInfo/MetricDrawer/CommentInput.tsx
index 6240a07ce9..2020298d51 100644
--- a/apps/web/src/modules/oh/DataView/HatchingTreatment/Sandbox/EvaluationInfo/MetricDrawer/CommentInput.tsx
+++ b/apps/web/src/modules/oh/DataView/HatchingTreatment/Sandbox/EvaluationInfo/MetricDrawer/CommentInput.tsx
@@ -4,6 +4,7 @@ import React, {
forwardRef,
useImperativeHandle,
} from 'react';
+import { flushSync } from 'react-dom';
import { BiLoaderAlt } from 'react-icons/bi';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'next-i18next';
@@ -86,7 +87,19 @@ const CommentInput = forwardRef(
if (loading) return;
if (event.key == 'Enter' || event.code == 'Enter') {
if (event.shiftKey || event.ctrlKey) {
- setValue((p) => p + '\n');
+ const input = event.currentTarget;
+ const { selectionStart, selectionEnd, value } = input;
+ flushSync(() => {
+ setValue(
+ value.slice(0, selectionStart) +
+ '\n' +
+ value.slice(selectionEnd)
+ );
+ });
+ input.setSelectionRange(
+ selectionStart + 1,
+ selectionStart + 1
+ );
} else {
if (value) {
onSubmit(value, images);