diff --git a/core/modules/ckeditor/js/plugins/drupallink/plugin.js b/core/modules/ckeditor/js/plugins/drupallink/plugin.js
index ff64f88..f1ffa02 100644
--- a/core/modules/ckeditor/js/plugins/drupallink/plugin.js
+++ b/core/modules/ckeditor/js/plugins/drupallink/plugin.js
@@ -120,7 +120,12 @@
               // Use link URL as text with a collapsed cursor.
               if (range.collapsed) {
                 // Shorten mailto URLs to just the email address.
-                var text = new CKEDITOR.dom.text(returnValues.attributes.href.replace(/^mailto:/, ''), editor.document);
+                var linkText = returnValues.attributes.href.replace(/^mailto:/, '');
+                // Allow overriding the link text by using the special attribute "text".
+                if (returnValues.attributes.hasOwnProperty('text') && returnValues.attributes.text.length) {
+                  linkText = returnValues.attributes.text;
+                }
+                var text = new CKEDITOR.dom.text(linkText, editor.document);
                 range.insertNode(text);
                 range.selectNodeContents(text);
               }
diff --git a/core/modules/ckeditor/tests/modules/ckeditor_test.module b/core/modules/ckeditor/tests/modules/ckeditor_test.module
index a75ad86..eb6406d 100644
--- a/core/modules/ckeditor/tests/modules/ckeditor_test.module
+++ b/core/modules/ckeditor/tests/modules/ckeditor_test.module
@@ -5,6 +5,7 @@
  * Helper module for the CKEditor tests.
  */
 
+use Drupal\Core\Form\FormStateInterface;
 use Drupal\editor\Entity\Editor;
 
 /**
@@ -13,3 +14,13 @@
 function ckeditor_test_ckeditor_css_alter(array &$css, Editor $editor) {
   $css[] = drupal_get_path('module', 'ckeditor_test') . '/ckeditor_test.css';
 }
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * Make the text field on EditorLinkDialog editable.
+ */
+function ckeditor_test_form_editor_link_dialog_alter(&$form, FormStateInterface $form_state, $form_id) {
+  $form['attributes']['text']['#type'] = 'textfield';
+  $form['attributes']['text']['#title'] = t('Link text');
+}
diff --git a/core/modules/ckeditor/tests/src/FunctionalJavascript/DrupalLinkTest.php b/core/modules/ckeditor/tests/src/FunctionalJavascript/DrupalLinkTest.php
new file mode 100644
index 0000000..924b87e
--- /dev/null
+++ b/core/modules/ckeditor/tests/src/FunctionalJavascript/DrupalLinkTest.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Drupal\Tests\ckeditor\FunctionalJavascript;
+
+use Drupal\Core\Entity\Entity\EntityFormDisplay;
+use Drupal\editor\Entity\Editor;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+use Drupal\node\Entity\NodeType;
+use Drupal\simpletest\ContentTypeCreationTrait;
+
+/**
+ * Tests the drupallink CKEditor plugin.
+ *
+ * @group ckeditor
+ */
+class DrupalLinkTest extends JavascriptTestBase {
+
+  use ContentTypeCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'ckeditor', 'filter', 'ckeditor_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Create text format, associate CKEditor.
+    $llama_format = FilterFormat::create([
+      'format' => 'llama',
+      'name' => 'Llama',
+      'weight' => 0,
+      'filters' => [],
+    ]);
+    $llama_format->save();
+    $editor = Editor::create([
+      'format' => 'llama',
+      'editor' => 'ckeditor',
+    ]);
+    $editor->save();
+
+    // Create a node type for testing.
+    NodeType::create(['type' => 'page', 'name' => 'page'])->save();
+
+    $field_storage = FieldStorageConfig::loadByName('node', 'body');
+
+    // Create a body field instance for the 'page' node type.
+    FieldConfig::create([
+      'field_storage' => $field_storage,
+      'bundle' => 'page',
+      'label' => 'Body',
+      'settings' => ['display_summary' => TRUE],
+      'required' => TRUE,
+    ])->save();
+
+    // Assign widget settings for the 'default' form mode.
+    EntityFormDisplay::create([
+      'targetEntityType' => 'node',
+      'bundle' => 'page',
+      'mode' => 'default',
+      'status' => TRUE,
+    ])
+    ->setComponent('body', ['type' => 'text_textarea_with_summary'])
+    ->save();
+
+    $account = $this->drupalCreateUser([
+      'administer nodes',
+      'create page content',
+      'use text format llama',
+    ]);
+    $this->drupalLogin($account);
+  }
+
+  /**
+   * Test the default link behavior.
+   */
+  public function testLinkText() {
+    $session = $this->getSession();
+    $web_assert = $this->assertSession();
+
+    // Go to node creation page.
+    $this->drupalGet('node/add/page');
+    $page = $session->getPage();
+
+    // Wait until the editor has been loaded.
+    $ckeditor_loaded = $this->getSession()->wait(5000, "jQuery('.cke_contents').length > 0");
+    $this->assertTrue($ckeditor_loaded, 'The editor has not been loaded.');
+
+    $link_button = $page->find('css', 'a.cke_button__drupallink');
+    $link_button->click();
+    $web_assert->assertWaitOnAjaxRequest();
+
+    // Fill in link.
+    $page->fillField('attributes[href]', '/node/add/page');
+
+    // Save the dialog input.
+    $button = $page->find('css', '.editor-link-dialog')->find('css', '.button.form-submit span');
+    $button->click();
+    $web_assert->assertWaitOnAjaxRequest();
+
+    // We can't use $session->switchToIFrame() here, because the iframe does not
+    // have a name.
+    $javascript = <<<JS
+      (function(){
+        var iframes = document.getElementsByClassName('cke_wysiwyg_frame');
+        if (iframes.length) {
+          var doc = iframes[0].contentDocument || iframes[0].contentWindow.document;
+          var link = doc.getElementsByTagName('a')[0];
+          return link.innerText.trim() || link.textContent || '';
+        }
+      })()
+JS;
+    $link_text = $session->evaluateScript($javascript);
+    $this->assertEquals('/node/add/page', $link_text, 'Inserted link text does not equals expected text.');
+  }
+
+  /**
+   * Test overriding the link text.
+   */
+  public function testOverrideLinkText() {
+    $session = $this->getSession();
+    $web_assert = $this->assertSession();
+
+    // Go to node creation page.
+    $this->drupalGet('node/add/page');
+    $page = $session->getPage();
+
+    // Wait until the editor has been loaded.
+    $ckeditor_loaded = $this->getSession()->wait(5000, "jQuery('.cke_contents').length > 0");
+    $this->assertTrue($ckeditor_loaded, 'The editor has not been loaded.');
+
+    $link_button = $page->find('css', 'a.cke_button__drupallink');
+    $link_button->click();
+    $web_assert->assertWaitOnAjaxRequest();
+
+    // Fill in link.
+    $page->fillField('attributes[href]', '/node/add/page');
+
+    // Fill in link text.
+    $page->fillField('attributes[text]', 'Add page');
+
+    // Save the dialog input.
+    $button = $page->find('css', '.editor-link-dialog')->find('css', '.button.form-submit span');
+    $button->click();
+    $web_assert->assertWaitOnAjaxRequest();
+
+    // We can't use $session->switchToIFrame() here, because the iframe does not
+    // have a name.
+    $javascript = <<<JS
+      (function(){
+        var iframes = document.getElementsByClassName('cke_wysiwyg_frame');
+        if (iframes.length) {
+          var doc = iframes[0].contentDocument || iframes[0].contentWindow.document;
+          var link = doc.getElementsByTagName('a')[0];
+          return link.innerText.trim() || link.textContent || '';
+        }
+      })()
+JS;
+    $link_text = $session->evaluateScript($javascript);
+    $this->assertEquals('Add page', $link_text, 'Inserted link text does not equals expected text.');
+  }
+
+}
diff --git a/core/modules/editor/src/Form/EditorLinkDialog.php b/core/modules/editor/src/Form/EditorLinkDialog.php
index 9c8a30f..453ccfa 100644
--- a/core/modules/editor/src/Form/EditorLinkDialog.php
+++ b/core/modules/editor/src/Form/EditorLinkDialog.php
@@ -47,6 +47,13 @@ public function buildForm(array $form, FormStateInterface $form_state, FilterFor
       '#default_value' => isset($input['href']) ? $input['href'] : '',
       '#maxlength' => 2048,
     );
+    // Attribute value of the link text. This may be converted into a textfield
+    // by contributed modules or overridden in the submit function to allow users
+    // to modify the text.
+    $form['attributes']['text'] = array(
+      '#type' => 'value',
+      '#default_value' => isset($input['text']) ? $input['text'] : '',
+    );
 
     $form['actions'] = array(
       '#type' => 'actions',
diff --git a/core/modules/editor/tests/src/Kernel/EditorLinkDialogTest.php b/core/modules/editor/tests/src/Kernel/EditorLinkDialogTest.php
new file mode 100644
index 0000000..a31fe3f
--- /dev/null
+++ b/core/modules/editor/tests/src/Kernel/EditorLinkDialogTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\Tests\editor\Kernel;
+
+use Drupal\Core\Form\FormState;
+use Drupal\editor\Entity\Editor;
+use Drupal\editor\Form\EditorLinkDialog;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * Tests EditorLinkDialog validation and conversion functionality.
+ *
+ * @group editor
+ */
+class EditorLinkDialogTest extends EntityKernelTestBase {
+
+  /**
+   * Filter format for testing.
+   *
+   * @var \Drupal\filter\FilterFormatInterface
+   */
+  protected $format;
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['node', 'editor', 'editor_test', 'user', 'system'];
+
+  /**
+   * Sets up the test.
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->installSchema('system', ['key_value_expire']);
+    $this->installSchema('node', ['node_access']);
+    $this->installConfig(['node']);
+    $this->installEntitySchema('user');
+
+    // Add a text format.
+    $this->format = FilterFormat::create([
+      'format' => 'filtered_html',
+      'name' => 'Filtered HTML',
+      'weight' => 0,
+      'filters' => [
+        'filter_align' => ['status' => TRUE],
+        'filter_caption' => ['status' => TRUE],
+      ],
+    ]);
+    $this->format->save();
+
+    // Set up editor.
+    $editor = Editor::create([
+      'format' => 'filtered_html',
+      'editor' => 'unicorn',
+    ]);
+    $editor->save();
+
+    // Create a node type for testing.
+    $type = NodeType::create(['type' => 'page', 'name' => 'page']);
+    $type->save();
+    node_add_body_field($type);
+
+    \Drupal::service('router.builder')->rebuild();
+  }
+
+  /**
+   * Tests that editor link dialog works as expected.
+   */
+  public function testEditorLinkDialog() {
+    $input = [
+      'editor_object' => [
+        'href' => '/node/1',
+        'text' => 'some text',
+      ],
+      'dialogOptions' => [
+        'title' => 'Edit Link',
+        'dialogClass' => 'editor-link-dialog',
+        'autoResize' => 'true',
+      ],
+      '_drupal_ajax' => '1',
+      'ajax_page_state' => [
+        'theme' => 'bartik',
+        'theme_token' => 'some-token',
+        'libraries' => '',
+      ],
+    ];
+    $form_state = (new FormState())
+      ->setRequestMethod('POST')
+      ->setUserInput($input)
+      ->addBuildInfo('args', [$this->format]);
+
+    $form_builder = $this->container->get('form_builder');
+    $form_object = new EditorLinkDialog();
+    $form_id = $form_builder->getFormId($form_object, $form_state);
+    $form = $form_builder->retrieveForm($form_id, $form_state);
+    $form_builder->prepareForm($form_id, $form, $form_state);
+    $form_builder->processForm($form_id, $form, $form_state);
+
+    // Assert these two values are present and we don't get the 'not-this'
+    // default back.
+    $this->assertEquals('some text', $form_state->getValue(['attributes', 'text'], 'not-this'), 'Attribute "text" exists and has the correct value.');
+  }
+
+}
