8
0
Просмотр исходного кода

Merge tag 'v1.0.0-beta.1' into stable

Release: v1.0.0-beta.1.
Piotrek Koszuliński 7 лет назад
Родитель
Сommit
739d4c8985
77 измененных файлов с 3191 добавлено и 1658 удалено
  1. 1 1
      packages/ckeditor5-link/.eslintrc.js
  2. 15 11
      packages/ckeditor5-link/.travis.yml
  3. 23 0
      packages/ckeditor5-link/CHANGELOG.md
  4. 1 1
      packages/ckeditor5-link/LICENSE.md
  5. 3 1
      packages/ckeditor5-link/README.md
  6. 4 1
      packages/ckeditor5-link/lang/contexts.json
  7. 14 2
      packages/ckeditor5-link/lang/translations/ast.po
  8. 14 2
      packages/ckeditor5-link/lang/translations/da.po
  9. 15 3
      packages/ckeditor5-link/lang/translations/de.po
  10. 14 2
      packages/ckeditor5-link/lang/translations/el.po
  11. 15 3
      packages/ckeditor5-link/lang/translations/en-au.po
  12. 14 2
      packages/ckeditor5-link/lang/translations/en.po
  13. 14 2
      packages/ckeditor5-link/lang/translations/eo.po
  14. 16 4
      packages/ckeditor5-link/lang/translations/es.po
  15. 30 0
      packages/ckeditor5-link/lang/translations/et.po
  16. 30 0
      packages/ckeditor5-link/lang/translations/eu.po
  17. 15 3
      packages/ckeditor5-link/lang/translations/fi.po
  18. 15 3
      packages/ckeditor5-link/lang/translations/fr.po
  19. 14 2
      packages/ckeditor5-link/lang/translations/gl.po
  20. 14 2
      packages/ckeditor5-link/lang/translations/hr.po
  21. 15 3
      packages/ckeditor5-link/lang/translations/hu.po
  22. 14 2
      packages/ckeditor5-link/lang/translations/it.po
  23. 15 3
      packages/ckeditor5-link/lang/translations/ja.po
  24. 15 3
      packages/ckeditor5-link/lang/translations/km.po
  25. 14 2
      packages/ckeditor5-link/lang/translations/kn.po
  26. 15 3
      packages/ckeditor5-link/lang/translations/ko.po
  27. 14 2
      packages/ckeditor5-link/lang/translations/ku.po
  28. 15 3
      packages/ckeditor5-link/lang/translations/nb.po
  29. 30 0
      packages/ckeditor5-link/lang/translations/nl.po
  30. 14 2
      packages/ckeditor5-link/lang/translations/pl.po
  31. 14 2
      packages/ckeditor5-link/lang/translations/pt-br.po
  32. 14 2
      packages/ckeditor5-link/lang/translations/pt.po
  33. 15 3
      packages/ckeditor5-link/lang/translations/ro.po
  34. 30 0
      packages/ckeditor5-link/lang/translations/ru.po
  35. 15 3
      packages/ckeditor5-link/lang/translations/sk.po
  36. 30 0
      packages/ckeditor5-link/lang/translations/sq.po
  37. 14 2
      packages/ckeditor5-link/lang/translations/sv.po
  38. 17 5
      packages/ckeditor5-link/lang/translations/tr.po
  39. 30 0
      packages/ckeditor5-link/lang/translations/ug.po
  40. 14 2
      packages/ckeditor5-link/lang/translations/uk.po
  41. 15 3
      packages/ckeditor5-link/lang/translations/zh-cn.po
  42. 15 3
      packages/ckeditor5-link/lang/translations/zh.po
  43. 15 15
      packages/ckeditor5-link/package.json
  44. 1 1
      packages/ckeditor5-link/src/findlinkrange.js
  45. 6 340
      packages/ckeditor5-link/src/link.js
  46. 22 18
      packages/ckeditor5-link/src/linkcommand.js
  47. 139 0
      packages/ckeditor5-link/src/linkediting.js
  48. 0 20
      packages/ckeditor5-link/src/linkelement.js
  49. 0 63
      packages/ckeditor5-link/src/linkengine.js
  50. 530 0
      packages/ckeditor5-link/src/linkui.js
  51. 235 0
      packages/ckeditor5-link/src/ui/linkactionsview.js
  52. 18 37
      packages/ckeditor5-link/src/ui/linkformview.js
  53. 6 9
      packages/ckeditor5-link/src/unlinkcommand.js
  54. 34 0
      packages/ckeditor5-link/src/utils.js
  55. 17 16
      packages/ckeditor5-link/tests/findlinkrange.js
  56. 7 730
      packages/ckeditor5-link/tests/link.js
  57. 72 44
      packages/ckeditor5-link/tests/linkcommand.js
  58. 266 0
      packages/ckeditor5-link/tests/linkediting.js
  59. 0 13
      packages/ckeditor5-link/tests/linkelement.js
  60. 0 126
      packages/ckeditor5-link/tests/linkengine.js
  61. 771 0
      packages/ckeditor5-link/tests/linkui.js
  62. 1 1
      packages/ckeditor5-link/tests/manual/link.js
  63. 3 0
      packages/ckeditor5-link/tests/manual/linkhighlight.html
  64. 25 0
      packages/ckeditor5-link/tests/manual/linkhighlight.js
  65. 8 0
      packages/ckeditor5-link/tests/manual/linkhighlight.md
  66. 10 14
      packages/ckeditor5-link/tests/manual/tickets/113/1.js
  67. 199 0
      packages/ckeditor5-link/tests/ui/linkactionsview.js
  68. 4 25
      packages/ckeditor5-link/tests/ui/linkformview.js
  69. 52 40
      packages/ckeditor5-link/tests/unlinkcommand.js
  70. 45 0
      packages/ckeditor5-link/tests/utils.js
  71. 0 47
      packages/ckeditor5-link/theme/components/linkform.scss
  72. 1 1
      packages/ckeditor5-link/theme/icons/link.svg
  73. 1 1
      packages/ckeditor5-link/theme/icons/unlink.svg
  74. 10 0
      packages/ckeditor5-link/theme/link.css
  75. 14 0
      packages/ckeditor5-link/theme/linkactions.css
  76. 14 0
      packages/ckeditor5-link/theme/linkform.css
  77. 0 4
      packages/ckeditor5-link/theme/theme.scss

+ 1 - 1
packages/ckeditor5-link/.eslintrc.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 

+ 15 - 11
packages/ckeditor5-link/.travis.yml

@@ -1,24 +1,28 @@
 sudo: required
 dist: trusty
 addons:
+  firefox: "latest"
   apt:
     sources:
-      - google-chrome
+    - google-chrome
     packages:
-      - google-chrome-stable
+    - google-chrome-stable
 language: node_js
 node_js:
-  - "6"
+- '6'
 cache:
-  - node_modules
+- node_modules
 before_install:
-  - export DISPLAY=:99.0
-  - sh -e /etc/init.d/xvfb start
+- export DISPLAY=:99.0
+- sh -e /etc/init.d/xvfb start
 install:
-  - npm install @ckeditor/ckeditor5-dev-tests
-  - ckeditor5-dev-tests-install-dependencies
+- npm install @ckeditor/ckeditor5-dev-tests
+- ckeditor5-dev-tests-install-dependencies
 script:
-  - ckeditor5-dev-tests-travis
+- ckeditor5-dev-tests-travis
 after_success:
-  - codeclimate-test-reporter < coverage/lcov.info
-  - ckeditor5-dev-tests-save-revision
+- ckeditor5-dev-tests-save-revision
+env:
+  global:
+  - secure: shAtCiv51iZllbFxG4h98vFzGYtBgm/7KyHyy0Mk76r0wYETjbXrXO0URHlyEKSpu0xzjqUmhC8PuRxudsGjg6BFU7Ifrehr06vN0bZFl2NCsJyjaW7/JD/guImNGT/Yfwafv2MfKkOT4wNJGcGoC24pBn9EF/BUePKLO1KzrzUD4sFI2ZnFREurBC61jt9Vf80q0k/SMkO80nOdlewjidsSKYwvEO2P+YKcBwQpV4+r6BI4P7nuRBtemToBS0SYrw009c2IrNSlhdx8NBhtJ2AObElbsn9YoYSMmO4EHWR7A534nVRrt5r+fawcldIRzAcgoOOz6BzxYyltBHefjzW7mMQjDOCrdJh+ad0Z0sOAJF58fTYc8gDvkbTrYxu6j1AcuVHZI7beZfX3U6gfrTDKPbnDAY0/94bUi2QR0f1ZV/dbiU374dmrcNUt+9Wa0hJXmuP4XT4TtHCdyc8zOP748fOVfBm1BbdlhS6L8nmYDvx3YZU5Opd0+wn+N+SkKG6E+jRadMyJiA0DV6G4sV63KDr6yaBrH/hVAErXbSbEF+OXKi/VIM5NutAqTsmZKIUUvJWkvaCjHQPynnk71TN7Y4IsHXlkzIqAlNR1aKCXzZ96wqDgujJ51CazGVNTLhzCRpJ2diXtlteapxuShUgKbn/Qn+lpTqW+MdSJLBc=
+  - secure: LF9Gt2RcTThqrQ8xjoO1DjlLcepxGXN/s+IjcQOUul6QypvllfLpxnP30vkriF1v5eU6+iEMfKL4lcSOSEnZ/TEgZ0d3DqOBG0XhTsRWkj1m+NpsI16squvSV9MZzQ7OIy83qbhJrArNWpSMW9wtmZmgFfeh2cfDVZCfW/XgrnirVirNQlZvIaAPl8QGwqHPHVPo9rERLEBDORGY1UTdtCofvm5O0r/rtFR4DmbMVa6mu24DClh8JrS4Q5/KFxWbBMHYfak9GgYWSX9rMMNLcSJ9/3fxDi9aOp6W5MZn+fYMoZn94EqYw+xMCDmu5UDUqe2Vv5WIwR/lY/NY6Mq8SIj2bB8jFFOz7pZbN4wqZJsvbbsS30Fjk/Zv8YVSRsDXS48PEuiiv/qh/retqBonpeWA4Ysguy0OFULz3IJKm3PSBrmVX9VwUQybQfXDUDqeg/Wh2hfNHyfolGfSMmu5ALk1BgcwC+xo1kZU9KKeVTss+XiQFJTX6//Gs7jaVwVvza51GuLWKcHW4iWe0Z5lI2A65rk0w6+A2gLF/duGXPyDnPOyWJXiY9qLyEuQFDqpT3X0d3sYiDNjNP/6Ysyq5hFv02RfwxKdNXL0c4NedaBFqZTWqz04xOev4YUXruxky0oNC/vKPOxqUej6cQCdFP2QTUCPwjyux+FP36L/KZE=

+ 23 - 0
packages/ckeditor5-link/CHANGELOG.md

@@ -1,6 +1,29 @@
 Changelog
 =========
 
+## [1.0.0-beta.1](https://github.com/ckeditor/ckeditor5-link/compare/v1.0.0-alpha.2...v1.0.0-beta.1) (2018-03-15)
+
+### Features
+
+* Added two-step caret movement for links. Closes [#72](https://github.com/ckeditor/ckeditor5-link/issues/72). ([985bb40](https://github.com/ckeditor/ckeditor5-link/commit/985bb40))
+* Implemented a 2–step link UI with a refreshed look&feel (see [ckeditor/ckeditor5#645](https://github.com/ckeditor/ckeditor5/issues/645)). Closes [#31](https://github.com/ckeditor/ckeditor5-link/issues/31). ([6baee95](https://github.com/ckeditor/ckeditor5-link/commit/6baee95))
+
+### Bug fixes
+
+* Link feature should not create empty text nodes with `linkHref` attribute. Closes [#169](https://github.com/ckeditor/ckeditor5-link/issues/169). ([0641978](https://github.com/ckeditor/ckeditor5-link/commit/0641978))
+
+### Other changes
+
+* Aligned feature class naming to the new scheme. ([5d8e67d](https://github.com/ckeditor/ckeditor5-link/commit/5d8e67d))
+* Migrated package styles to PostCSS. Moved visual styles to `@ckeditor/ckeditor5-theme-lark` (see [ckeditor/ckeditor5-ui#144](https://github.com/ckeditor/ckeditor5-ui/issues/144)). ([f16d263](https://github.com/ckeditor/ckeditor5-link/commit/f16d263))
+* Removed `LinkElement`. We should be using custom properties instead. Closes [#162](https://github.com/ckeditor/ckeditor5-link/issues/162). ([3785e50](https://github.com/ckeditor/ckeditor5-link/commit/3785e50))
+* Updated translations. ([d285ad3](https://github.com/ckeditor/ckeditor5-link/commit/d285ad3))
+
+### BREAKING CHANGES
+
+* The structure of the link UI has changed dramatically. Some pieces of the `LinkFormView` belong now to the `LinkActionsView` class. The CSS classes have also changed along with component templates.
+
+
 ## [1.0.0-alpha.2](https://github.com/ckeditor/ckeditor5-link/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2017-11-14)
 
 ### Other changes

+ 1 - 1
packages/ckeditor5-link/LICENSE.md

@@ -2,7 +2,7 @@ Software License Agreement
 ==========================
 
 **Link feature for CKEditor 5.** – https://github.com/ckeditor/ckeditor5-link <br>
-Copyright (c) 2003-2017, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved.
+Copyright (c) 2003-2018, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved.
 
 Licensed under the terms of any of the following licenses at your choice:
 

+ 3 - 1
packages/ckeditor5-link/README.md

@@ -4,7 +4,9 @@ CKEditor 5 link feature
 [![Join the chat at https://gitter.im/ckeditor/ckeditor5](https://badges.gitter.im/ckeditor/ckeditor5.svg)](https://gitter.im/ckeditor/ckeditor5?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
 [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-link.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-link)
 [![Build Status](https://travis-ci.org/ckeditor/ckeditor5-link.svg?branch=master)](https://travis-ci.org/ckeditor/ckeditor5-link)
-[![Test Coverage](https://codeclimate.com/github/ckeditor/ckeditor5-link/badges/coverage.svg)](https://codeclimate.com/github/ckeditor/ckeditor5-link/coverage)
+[![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=d3hvenZqQVZERFQ5d09FWXdyT0ozVXhLaVltRFRjTTUyZGpvQWNmWVhUUT0tLUZqNlJ1YWRUd0RvdEVOaEptM1B2Q0E9PQ==--c9d3dee40b9b4471ff3fb516d9ecf8d09292c7e0)](https://www.browserstack.com/automate/public-build/d3hvenZqQVZERFQ5d09FWXdyT0ozVXhLaVltRFRjTTUyZGpvQWNmWVhUUT0tLUZqNlJ1YWRUd0RvdEVOaEptM1B2Q0E9PQ==--c9d3dee40b9b4471ff3fb516d9ecf8d09292c7e0)
+[![Coverage Status](https://coveralls.io/repos/github/ckeditor/ckeditor5-link/badge.svg?branch=master)](https://coveralls.io/github/ckeditor/ckeditor5-link?branch=master)
+<br>
 [![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-link/status.svg)](https://david-dm.org/ckeditor/ckeditor5-link)
 [![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-link/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-link?type=dev)
 

+ 4 - 1
packages/ckeditor5-link/lang/contexts.json

@@ -1,5 +1,8 @@
 {
 	"Unlink": "Toolbar button tooltip for the Unlink feature.",
 	"Link": "Toolbar button tooltip for the Link feature.",
-	"Link URL": "Label for the URL input in the Link dialog."
+	"Link URL": "Label for the URL input in the Link URL editing balloon.",
+	"Edit link": "Button opening the Link URL editing balloon.",
+	"Open link in new tab": "Button opening the link in new browser tab.",
+	"This link has no URL": "Label explaining that a link has no URL set (the URL is empty)."
 }

+ 14 - 2
packages/ckeditor5-link/lang/translations/ast.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: ast\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Enllazar"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/da.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: da\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Link"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/de.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: de\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Link"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "Link-URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/el.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: el\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Σύνδεσμος"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/en-au.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: en_AU\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Link"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "Link URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/en.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: \n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Link"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr "Link URL"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Edit link"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Open link in new tab"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "This link has no URL"

+ 14 - 2
packages/ckeditor5-link/lang/translations/eo.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: eo\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Ligilo"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 16 - 4
packages/ckeditor5-link/lang/translations/es.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: es\n"
@@ -7,12 +7,24 @@ msgstr ""
 
 msgctxt "Toolbar button tooltip for the Unlink feature."
 msgid "Unlink"
-msgstr ""
+msgstr "Quitar enlace"
 
 msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Enlace"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr ""
+msgstr "URL del enlace"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Editar enlace"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Abrir enlace en una pestaña nueva"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Este enlace no tiene URL"

+ 30 - 0
packages/ckeditor5-link/lang/translations/et.po

@@ -0,0 +1,30 @@
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+msgid ""
+msgstr ""
+"Language: et\n"
+"Language-Team: Estonian (https://www.transifex.com/ckeditor/teams/11143/et/)\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgctxt "Toolbar button tooltip for the Unlink feature."
+msgid "Unlink"
+msgstr "Eemalda link"
+
+msgctxt "Toolbar button tooltip for the Link feature."
+msgid "Link"
+msgstr "Link"
+
+msgctxt "Label for the URL input in the Link URL editing balloon."
+msgid "Link URL"
+msgstr "Lingi URL"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Muuda linki"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Ava link uuel vahekaardil"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Sellel lingil puudub URL"

+ 30 - 0
packages/ckeditor5-link/lang/translations/eu.po

@@ -0,0 +1,30 @@
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+msgid ""
+msgstr ""
+"Language: eu\n"
+"Language-Team: Basque (https://www.transifex.com/ckeditor/teams/11143/eu/)\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgctxt "Toolbar button tooltip for the Unlink feature."
+msgid "Unlink"
+msgstr "Desestekatu"
+
+msgctxt "Toolbar button tooltip for the Link feature."
+msgid "Link"
+msgstr "Esteka"
+
+msgctxt "Label for the URL input in the Link URL editing balloon."
+msgid "Link URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/fi.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: fi\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Linkki"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "Linkin osoite"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/fr.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: fr\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Lien"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "URL du lien"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/gl.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: gl\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Ligar"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr "URL de ligazón"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Editar a ligazón"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Abrir a ligazón nunha nova lapela"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Esta ligazón non ten URL"

+ 14 - 2
packages/ckeditor5-link/lang/translations/hr.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: hr\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Veza"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr "URL veze"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Uredi vezu"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Otvori vezu u novoj kartici"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Ova veza nema URL"

+ 15 - 3
packages/ckeditor5-link/lang/translations/hu.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: hu\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Link"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "Link URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/it.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: it\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Collegamento"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr "URL del collegamento"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Modifica collegamento"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Apri collegamento in nuova scheda"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Questo collegamento non ha un URL"

+ 15 - 3
packages/ckeditor5-link/lang/translations/ja.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: ja\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "リンク"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "リンクURL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/km.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: km\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "តំណ"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "URL តំណ"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/kn.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: kn\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "‍ಕೊಂಡಿ"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/ko.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: ko\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "링크"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "링크 주소"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/ku.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: ku\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "بەستەر"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr "ناونیشانی بەستەر"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "دەسککاری بەستەر"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "کردنەوەی بەستەرەکە لە پەڕەیەکی نوێ"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "ئەم بەستەررە ناونیشانی نیە"

+ 15 - 3
packages/ckeditor5-link/lang/translations/nb.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: nb\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Lenke"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "URL for lenke"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 30 - 0
packages/ckeditor5-link/lang/translations/nl.po

@@ -0,0 +1,30 @@
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+msgid ""
+msgstr ""
+"Language: nl\n"
+"Language-Team: Dutch (https://www.transifex.com/ckeditor/teams/11143/nl/)\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgctxt "Toolbar button tooltip for the Unlink feature."
+msgid "Unlink"
+msgstr "Verwijder link"
+
+msgctxt "Toolbar button tooltip for the Link feature."
+msgid "Link"
+msgstr "Link"
+
+msgctxt "Label for the URL input in the Link URL editing balloon."
+msgid "Link URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/pl.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: pl\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Wstaw odnośnik"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/pt-br.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: pt_BR\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Link"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/pt.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: pt\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Hiperligação"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/ro.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: ro\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Link"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "Link URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 30 - 0
packages/ckeditor5-link/lang/translations/ru.po

@@ -0,0 +1,30 @@
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+msgid ""
+msgstr ""
+"Language: ru\n"
+"Language-Team: Russian (https://www.transifex.com/ckeditor/teams/11143/ru/)\n"
+"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
+
+msgctxt "Toolbar button tooltip for the Unlink feature."
+msgid "Unlink"
+msgstr "Убрать ссылку"
+
+msgctxt "Toolbar button tooltip for the Link feature."
+msgid "Link"
+msgstr "Ссылка"
+
+msgctxt "Label for the URL input in the Link URL editing balloon."
+msgid "Link URL"
+msgstr "Ссылка URL"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Редактировать ссылку"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Открыть ссылку в новой вкладке"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Для этой ссылки не установлен адрес URL"

+ 15 - 3
packages/ckeditor5-link/lang/translations/sk.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: sk\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Odkaz"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "URL odkazu"
+msgstr "URL adresa"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Upraviť odkaz"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Otvoriť odkaz v novom okne"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Tento odkaz nemá nastavenú URL adresu"

+ 30 - 0
packages/ckeditor5-link/lang/translations/sq.po

@@ -0,0 +1,30 @@
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+msgid ""
+msgstr ""
+"Language: sq\n"
+"Language-Team: Albanian (https://www.transifex.com/ckeditor/teams/11143/sq/)\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgctxt "Toolbar button tooltip for the Unlink feature."
+msgid "Unlink"
+msgstr "Largo Nyjën"
+
+msgctxt "Toolbar button tooltip for the Link feature."
+msgid "Link"
+msgstr "Shto Nyjën"
+
+msgctxt "Label for the URL input in the Link URL editing balloon."
+msgid "Link URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/sv.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: sv\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Länk"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 17 - 5
packages/ckeditor5-link/lang/translations/tr.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: tr\n"
@@ -7,12 +7,24 @@ msgstr ""
 
 msgctxt "Toolbar button tooltip for the Unlink feature."
 msgid "Unlink"
-msgstr "Linki kaldır"
+msgstr "Bağlantıyı kaldır"
 
 msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
-msgstr "Link"
+msgstr "Bağlantı"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr ""
+msgstr "Bağlantı Adresi"
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr "Bağlantıyı değiştir"
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr "Yeni sekmede aç"
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr "Bağlantı adresi yok"

+ 30 - 0
packages/ckeditor5-link/lang/translations/ug.po

@@ -0,0 +1,30 @@
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+msgid ""
+msgstr ""
+"Language: ug\n"
+"Language-Team: Uighur (https://www.transifex.com/ckeditor/teams/11143/ug/)\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+msgctxt "Toolbar button tooltip for the Unlink feature."
+msgid "Unlink"
+msgstr "ئۈزۈش"
+
+msgctxt "Toolbar button tooltip for the Link feature."
+msgid "Link"
+msgstr "ئۇلاش"
+
+msgctxt "Label for the URL input in the Link URL editing balloon."
+msgid "Link URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 14 - 2
packages/ckeditor5-link/lang/translations/uk.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: uk\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "Посилання"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
 msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/zh-cn.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: zh_CN\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "超链接"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "超链接地址"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 3
packages/ckeditor5-link/lang/translations/zh.po

@@ -1,4 +1,4 @@
-# Copyright (c) Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+# Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 msgid ""
 msgstr ""
 "Language: zh_TW\n"
@@ -13,6 +13,18 @@ msgctxt "Toolbar button tooltip for the Link feature."
 msgid "Link"
 msgstr "連結"
 
-msgctxt "Label for the URL input in the Link dialog."
+msgctxt "Label for the URL input in the Link URL editing balloon."
 msgid "Link URL"
-msgstr "連結˙ URL"
+msgstr ""
+
+msgctxt "Button opening the Link URL editing balloon."
+msgid "Edit link"
+msgstr ""
+
+msgctxt "Button opening the link in new browser tab."
+msgid "Open link in new tab"
+msgstr ""
+
+msgctxt "Label explaining that a link has no URL set (the URL is empty)."
+msgid "This link has no URL"
+msgstr ""

+ 15 - 15
packages/ckeditor5-link/package.json

@@ -1,29 +1,29 @@
 {
   "name": "@ckeditor/ckeditor5-link",
-  "version": "1.0.0-alpha.2",
+  "version": "1.0.0-beta.1",
   "description": "Link feature for CKEditor 5.",
   "keywords": [
     "ckeditor5",
     "ckeditor5-feature"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-core": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-engine": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-theme-lark": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-ui": "^1.0.0-alpha.2"
+    "@ckeditor/ckeditor5-core": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-engine": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-theme-lark": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-ui": "^1.0.0-beta.1"
   },
   "devDependencies": {
-    "@ckeditor/ckeditor5-editor-classic": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-enter": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-heading": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-paragraph": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-typing": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-undo": "^1.0.0-alpha.2",
-    "@ckeditor/ckeditor5-utils": "^1.0.0-alpha.2",
-    "eslint": "^4.8.0",
-    "eslint-config-ckeditor5": "^1.0.6",
+    "@ckeditor/ckeditor5-editor-classic": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-enter": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-heading": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-paragraph": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-typing": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-undo": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-utils": "^1.0.0-beta.1",
+    "eslint": "^4.15.0",
+    "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",
-    "lint-staged": "^4.2.3"
+    "lint-staged": "^6.0.0"
   },
   "engines": {
     "node": ">=6.0.0",

+ 1 - 1
packages/ckeditor5-link/src/findlinkrange.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 

+ 6 - 340
packages/ckeditor5-link/src/link.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -8,27 +8,14 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
-import Range from '@ckeditor/ckeditor5-engine/src/view/range';
-import LinkEngine from './linkengine';
-import LinkElement from './linkelement';
-import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
-
-import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
-
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import LinkFormView from './ui/linkformview';
-
-import linkIcon from '../theme/icons/link.svg';
-import '../theme/theme.scss';
-
-const linkKeystroke = 'Ctrl+K';
+import LinkEditing from './linkediting';
+import LinkUI from './linkui';
 
 /**
  * The link plugin. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
  *
- * It uses the {@link module:link/linkengine~LinkEngine link engine plugin} and the
- * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ * It loads the {@link module:link/linkediting~LinkEditing link editing feature}
+ * and {@link module:link/linkui~LinkUI link UI feature}.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -37,7 +24,7 @@ export default class Link extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ LinkEngine, ContextualBalloon ];
+		return [ LinkEditing, LinkUI ];
 	}
 
 	/**
@@ -46,325 +33,4 @@ export default class Link extends Plugin {
 	static get pluginName() {
 		return 'Link';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-
-		editor.editing.view.addObserver( ClickObserver );
-
-		/**
-		 * The form view displayed inside the balloon.
-		 *
-		 * @member {module:link/ui/linkformview~LinkFormView}
-		 */
-		this.formView = this._createForm();
-
-		/**
-		 * The contextual balloon plugin instance.
-		 *
-		 * @private
-		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
-		 */
-		this._balloon = editor.plugins.get( ContextualBalloon );
-
-		// Create toolbar buttons.
-		this._createToolbarLinkButton();
-
-		// Attach lifecycle actions to the the balloon.
-		this._attachActions();
-	}
-
-	/**
-	 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
-	 *
-	 * @private
-	 * @returns {module:link/ui/linkformview~LinkFormView} The link form instance.
-	 */
-	_createForm() {
-		const editor = this.editor;
-		const formView = new LinkFormView( editor.locale );
-		const linkCommand = editor.commands.get( 'link' );
-		const unlinkCommand = editor.commands.get( 'unlink' );
-
-		formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' );
-
-		// Form elements should be read-only when corresponding commands are disabled.
-		formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value );
-		formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand );
-		formView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand );
-
-		// Execute link command after clicking on formView `Save` button.
-		this.listenTo( formView, 'submit', () => {
-			editor.execute( 'link', formView.urlInputView.inputView.element.value );
-			this._hidePanel( true );
-		} );
-
-		// Execute unlink command after clicking on formView `Unlink` button.
-		this.listenTo( formView, 'unlink', () => {
-			editor.execute( 'unlink' );
-			this._hidePanel( true );
-		} );
-
-		// Hide the panel after clicking on formView `Cancel` button.
-		this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
-
-		// Close the panel on esc key press when the form has focus.
-		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
-			this._hidePanel( true );
-			cancel();
-		} );
-
-		return formView;
-	}
-
-	/**
-	 * Creates a toolbar Link button. Clicking this button will show
-	 * a {@link #_balloon} attached to the selection.
-	 *
-	 * @private
-	 */
-	_createToolbarLinkButton() {
-		const editor = this.editor;
-		const linkCommand = editor.commands.get( 'link' );
-		const t = editor.t;
-
-		// Handle the `Ctrl+K` keystroke and show the panel.
-		editor.keystrokes.set( linkKeystroke, ( keyEvtData, cancel ) => {
-			// Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154.
-			cancel();
-
-			if ( linkCommand.isEnabled ) {
-				this._showPanel( true );
-			}
-		} );
-
-		editor.ui.componentFactory.add( 'link', locale => {
-			const button = new ButtonView( locale );
-
-			button.isEnabled = true;
-			button.label = t( 'Link' );
-			button.icon = linkIcon;
-			button.keystroke = linkKeystroke;
-			button.tooltip = true;
-
-			// Bind button to the command.
-			button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
-
-			// Show the panel on button click.
-			this.listenTo( button, 'execute', () => this._showPanel( true ) );
-
-			return button;
-		} );
-	}
-
-	/**
-	 * Attaches actions that control whether the balloon panel containing the
-	 * {@link #formView} is visible or not.
-	 *
-	 * @private
-	 */
-	_attachActions() {
-		const viewDocument = this.editor.editing.view;
-
-		// Handle click on view document and show panel when selection is placed inside the link element.
-		// Keep panel open until selection will be inside the same link element.
-		this.listenTo( viewDocument, 'click', () => {
-			const parentLink = this._getSelectedLinkElement();
-
-			if ( parentLink ) {
-				// Then show panel but keep focus inside editor editable.
-				this._showPanel();
-			}
-		} );
-
-		// Focus the form if the balloon is visible and the Tab key has been pressed.
-		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
-			if ( this._balloon.visibleView === this.formView && !this.formView.focusTracker.isFocused ) {
-				this.formView.focus();
-				cancel();
-			}
-		}, {
-			// Use the high priority because the link UI navigation is more important
-			// than other feature's actions, e.g. list indentation.
-			// https://github.com/ckeditor/ckeditor5-link/issues/146
-			priority: 'high'
-		} );
-
-		// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
-		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
-			if ( this._balloon.visibleView === this.formView ) {
-				this._hidePanel();
-				cancel();
-			}
-		} );
-
-		// Close on click outside of balloon panel element.
-		clickOutsideHandler( {
-			emitter: this.formView,
-			activator: () => this._balloon.hasView( this.formView ),
-			contextElements: [ this._balloon.view.element ],
-			callback: () => this._hidePanel()
-		} );
-	}
-
-	/**
-	 * Adds the {@link #formView} to the {@link #_balloon}.
-	 *
-	 * @protected
-	 * @param {Boolean} [focusInput=false] When `true`, the link form will be focused on panel show.
-	 */
-	_showPanel( focusInput ) {
-		const editor = this.editor;
-		const linkCommand = editor.commands.get( 'link' );
-		const unlinkCommand = editor.commands.get( 'unlink' );
-		const editing = editor.editing;
-		const showViewDocument = editing.view;
-		const showIsCollapsed = showViewDocument.selection.isCollapsed;
-		const showSelectedLink = this._getSelectedLinkElement();
-
-		this.listenTo( showViewDocument, 'render', () => {
-			const renderSelectedLink = this._getSelectedLinkElement();
-			const renderIsCollapsed = showViewDocument.selection.isCollapsed;
-			const hasSellectionExpanded = showIsCollapsed && !renderIsCollapsed;
-
-			// Hide the panel if:
-			//   * the selection went out of the original link element
-			//     (e.g. paragraph containing the link was removed),
-			//   * the selection has expanded
-			// upon the #render event.
-			if ( hasSellectionExpanded || showSelectedLink !== renderSelectedLink ) {
-				this._hidePanel( true );
-			}
-			// Update the position of the panel when:
-			//  * the selection remains in the original link element,
-			//  * there was no link element in the first place, i.e. creating a new link
-			else {
-				// If still in a link element, simply update the position of the balloon.
-				// If there was no link, upon #render, the balloon must be moved
-				// to the new position in the editing view (a new native DOM range).
-				this._balloon.updatePosition( this._getBalloonPositionData() );
-			}
-		} );
-
-		if ( this._balloon.hasView( this.formView ) ) {
-			// Check if formView should be focused and focus it if is visible.
-			if ( focusInput && this._balloon.visibleView === this.formView ) {
-				this.formView.urlInputView.select();
-			}
-		} else {
-			this._balloon.add( {
-				view: this.formView,
-				position: this._getBalloonPositionData()
-			} );
-
-			if ( focusInput ) {
-				this.formView.urlInputView.select();
-			}
-		}
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/53
-		this.formView.unlinkButtonView.isVisible = unlinkCommand.isEnabled;
-
-		// Make sure that each time the panel shows up, the URL field remains in sync with the value of
-		// the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays
-		// unaltered) and re-opened it without changing the value of the link command (e.g. because they
-		// clicked the same link), they would see the old value instead of the actual value of the command.
-		// https://github.com/ckeditor/ckeditor5-link/issues/78
-		// https://github.com/ckeditor/ckeditor5-link/issues/123
-		this.formView.urlInputView.inputView.element.value = linkCommand.value || '';
-	}
-
-	/**
-	 * Removes the {@link #formView} from the {@link #_balloon}.
-	 *
-	 * See {@link #_showPanel}.
-	 *
-	 * @protected
-	 * @param {Boolean} [focusEditable=false] When `true`, editable focus will be restored on panel hide.
-	 */
-	_hidePanel( focusEditable ) {
-		this.stopListening( this.editor.editing.view, 'render' );
-
-		if ( !this._balloon.hasView( this.formView ) ) {
-			return;
-		}
-
-		if ( focusEditable ) {
-			this.editor.editing.view.focus();
-		}
-
-		this.stopListening( this.editor.editing.view, 'render' );
-		this._balloon.remove( this.formView );
-	}
-
-	/**
-	 * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached
-	 * to the target element or selection.
-	 *
-	 * If the selection is collapsed and inside a link element, the panel will be attached to the
-	 * entire link element. Otherwise, it will be attached to the selection.
-	 *
-	 * @private
-	 * @returns {module:utils/dom/position~Options}
-	 */
-	_getBalloonPositionData() {
-		const viewDocument = this.editor.editing.view;
-		const targetLink = this._getSelectedLinkElement();
-
-		const target = targetLink ?
-			// When selection is inside link element, then attach panel to this element.
-			viewDocument.domConverter.mapViewToDom( targetLink ) :
-			// Otherwise attach panel to the selection.
-			viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
-
-		return { target };
-	}
-
-	/**
-	 * Returns the {@link module:link/linkelement~LinkElement} under
-	 * the {@link module:engine/view/document~Document editing view's} selection or `null`
-	 * if there is none.
-	 *
-	 * **Note**: For a non–collapsed selection the `LinkElement` is only returned when **fully**
-	 * selected and the **only** element within the selection boundaries.
-	 *
-	 * @private
-	 * @returns {module:link/linkelement~LinkElement|null}
-	 */
-	_getSelectedLinkElement() {
-		const selection = this.editor.editing.view.selection;
-
-		if ( selection.isCollapsed ) {
-			return findLinkElementAncestor( selection.getFirstPosition() );
-		} else {
-			// The range for fully selected link is usually anchored in adjacent text nodes.
-			// Trim it to get closer to the actual LinkElement.
-			const range = selection.getFirstRange().getTrimmed();
-			const startLink = findLinkElementAncestor( range.start );
-			const endLink = findLinkElementAncestor( range.end );
-
-			if ( !startLink || startLink != endLink ) {
-				return null;
-			}
-
-			// Check if the LinkElement is fully selected.
-			if ( Range.createIn( startLink ).getTrimmed().isEqual( range ) ) {
-				return startLink;
-			} else {
-				return null;
-			}
-		}
-	}
-}
-
-// Returns a `LinkElement` if there's one among the ancestors of the provided `Position`.
-//
-// @private
-// @param {module:engine/view/position~Position} View position to analyze.
-// @returns {module:link/linkelement~LinkElement|null} LinkElement at the position or null.
-function findLinkElementAncestor( position ) {
-	return position.getAncestors().find( ancestor => ancestor instanceof LinkElement );
 }

+ 22 - 18
packages/ckeditor5-link/src/linkcommand.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -8,9 +8,9 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import Text from '@ckeditor/ckeditor5-engine/src/model/text';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 import findLinkRange from './findlinkrange';
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 
 /**
  * The link command. It is used by the {@link module:link/link~Link link feature}.
@@ -30,10 +30,11 @@ export default class LinkCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		const doc = this.editor.document;
+		const model = this.editor.model;
+		const doc = model.document;
 
 		this.value = doc.selection.getAttribute( 'linkHref' );
-		this.isEnabled = doc.schema.checkAttributeInSelection( doc.selection, 'linkHref' );
+		this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );
 	}
 
 	/**
@@ -53,13 +54,10 @@ export default class LinkCommand extends Command {
 	 * @param {String} href Link destination.
 	 */
 	execute( href ) {
-		const doc = this.editor.document;
-		const selection = doc.selection;
-
-		doc.enqueueChanges( () => {
-			// Keep it as one undo step.
-			const batch = doc.batch();
+		const model = this.editor.model;
+		const selection = model.document.selection;
 
+		model.change( writer => {
 			// If selection is collapsed then update selected link or insert new one at the place of caret.
 			if ( selection.isCollapsed ) {
 				const position = selection.getFirstPosition();
@@ -69,27 +67,33 @@ export default class LinkCommand extends Command {
 					// Then update `linkHref` value.
 					const linkRange = findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ) );
 
-					batch.setAttribute( linkRange, 'linkHref', href );
+					writer.setAttribute( 'linkHref', href, linkRange );
 
 					// Create new range wrapping changed link.
-					selection.setRanges( [ linkRange ] );
+					writer.setSelection( linkRange );
 				}
 				// If not then insert text node with `linkHref` attribute in place of caret.
-				else {
-					const node = new Text( href, { linkHref: href } );
+				// However, since selection in collapsed, attribute value will be used as data for text node.
+				// So, if `href` is empty, do not create text node.
+				else if ( href !== '' ) {
+					const attributes = toMap( selection.getAttributes() );
+
+					attributes.set( 'linkHref', href );
+
+					const node = writer.createText( href, attributes );
 
-					batch.insert( position, node );
+					writer.insert( node, position );
 
 					// Create new range wrapping created node.
-					selection.setRanges( [ Range.createOn( node ) ] );
+					writer.setSelection( Range.createOn( node ) );
 				}
 			} else {
 				// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
 				// omitting nodes where `linkHref` attribute is disallowed.
-				const ranges = doc.schema.getValidRanges( selection.getRanges(), 'linkHref' );
+				const ranges = model.schema.getValidRanges( selection.getRanges(), 'linkHref' );
 
 				for ( const range of ranges ) {
-					batch.setAttribute( range, 'linkHref', href );
+					writer.setAttribute( 'linkHref', href, range );
 				}
 			}
 		} );

+ 139 - 0
packages/ckeditor5-link/src/linkediting.js

@@ -0,0 +1,139 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module link/linkediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import {
+	downcastAttributeToElement,
+	downcastMarkerToHighlight,
+	createViewElementFromHighlightDescriptor
+} from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import { upcastElementToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+import LinkCommand from './linkcommand';
+import UnlinkCommand from './unlinkcommand';
+import { createLinkElement } from './utils';
+import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';
+import findLinkRange from './findlinkrange';
+import '../theme/link.css';
+import DocumentSelection from '@ckeditor/ckeditor5-engine/src/model/documentselection';
+import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
+
+/**
+ * The link engine feature.
+ *
+ * It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class LinkEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		// Allow link attribute on all inline nodes.
+		editor.model.schema.extend( '$text', { allowAttributes: 'linkHref' } );
+
+		editor.conversion.for( 'downcast' )
+			.add( downcastAttributeToElement( { model: 'linkHref', view: createLinkElement } ) );
+
+		editor.conversion.for( 'upcast' )
+			.add( upcastElementToAttribute( {
+				view: {
+					name: 'a',
+					attribute: {
+						href: true
+					}
+				},
+				model: {
+					key: 'linkHref',
+					value: viewElement => viewElement.getAttribute( 'href' )
+				}
+			} ) );
+
+		// Create linking commands.
+		editor.commands.add( 'link', new LinkCommand( editor ) );
+		editor.commands.add( 'unlink', new UnlinkCommand( editor ) );
+
+		// Enable two-step caret movement for `linkHref` attribute.
+		bindTwoStepCaretToAttribute( editor.editing.view, editor.model, this, 'linkHref' );
+
+		// Setup highlight over selected link.
+		this._setupLinkHighlight();
+	}
+
+	/**
+	 * Adds highlight over link which has selection inside, together with two-step caret movement indicates whenever
+	 * user is typing inside the link.
+	 *
+	 * @private
+	 */
+	_setupLinkHighlight() {
+		const editor = this.editor;
+		const model = this.editor.model;
+		const doc = model.document;
+		const highlightDescriptor = {
+			id: 'linkBoundaries',
+			class: 'ck-link_selected',
+			priority: 1
+		};
+
+		// Convert linkBoundaries marker to view highlight.
+		editor.conversion.for( 'editingDowncast' )
+			.add( downcastMarkerToHighlight( {
+				model: 'linkBoundaries',
+				view: highlightDescriptor
+			} ) );
+
+		// Create marker over whole link when selection has "linkHref" attribute.
+		doc.registerPostFixer( writer => {
+			const selection = doc.selection;
+			const marker = model.markers.get( 'linkBoundaries' );
+
+			// Create marker over link when selection is inside or remove marker otherwise.
+			if ( selection.hasAttribute( 'linkHref' ) ) {
+				const modelRange = findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ) );
+
+				if ( !marker || !marker.getRange().isEqual( modelRange ) ) {
+					writer.setMarker( 'linkBoundaries', modelRange );
+					return true;
+				}
+			} else if ( marker ) {
+				writer.removeMarker( 'linkBoundaries' );
+				return true;
+			}
+
+			return false;
+		} );
+
+		// Custom converter for selection's "linkHref" attribute - when collapsed selection has this attribute it is
+		// wrapped with <span> similar to that used by highlighting mechanism. This <span> will be merged together with
+		// highlight wrapper. This prevents link splitting When selection is at the beginning or at the end of the link.
+		// Without this converter:
+		//
+		//		<a href="url">{}</a><span class="ck-link_selected"><a href="url">foo</a></span>
+		//
+		// When converter is applied:
+		//
+		//		<span class="ck-link_selected"><a href="url">{}foo</a></span>
+		editor.editing.downcastDispatcher.on( 'attribute:linkHref', ( evt, data, conversionApi ) => {
+			const selection = data.item;
+
+			if ( !( selection instanceof DocumentSelection || selection instanceof ModelSelection ) || !selection.isCollapsed ) {
+				return;
+			}
+
+			const writer = conversionApi.writer;
+			const viewSelection = writer.document.selection;
+			const wrapper = createViewElementFromHighlightDescriptor( highlightDescriptor );
+
+			conversionApi.writer.wrap( viewSelection.getFirstRange(), wrapper );
+		} );
+	}
+}

+ 0 - 20
packages/ckeditor5-link/src/linkelement.js

@@ -1,20 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * @module link/linkelement
- */
-
-import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement';
-
-/**
- * This class is to mark a specific {@link module:engine/view/node~Node} as a {@link module:link/linkelement~LinkElement}.
- * For example, there could be a situation when different features will create nodes with the same names,
- * and hence they must be identified somehow.
- *
- * @extends module:engine/view/attributelement~AttributeElement
- */
-export default class LinkElement extends AttributeElement {
-}

+ 0 - 63
packages/ckeditor5-link/src/linkengine.js

@@ -1,63 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * @module link/linkengine
- */
-
-import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
-import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildviewconverter';
-import LinkElement from './linkelement';
-import LinkCommand from './linkcommand';
-import UnlinkCommand from './unlinkcommand';
-
-/**
- * The link engine feature.
- *
- * It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element.
- *
- * @extends module:core/plugin~Plugin
- */
-export default class LinkEngine extends Plugin {
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-		const data = editor.data;
-		const editing = editor.editing;
-
-		// Allow link attribute on all inline nodes.
-		editor.document.schema.allow( { name: '$inline', attributes: 'linkHref', inside: '$block' } );
-		// Temporary workaround. See https://github.com/ckeditor/ckeditor5/issues/477.
-		editor.document.schema.allow( { name: '$inline', attributes: 'linkHref', inside: '$clipboardHolder' } );
-
-		// Build converter from model to view for data and editing pipelines.
-		buildModelConverter().for( data.modelToView, editing.modelToView )
-			.fromAttribute( 'linkHref' )
-			.toElement( linkHref => {
-				const linkElement = new LinkElement( 'a', { href: linkHref } );
-
-				// https://github.com/ckeditor/ckeditor5-link/issues/121
-				linkElement.priority = 5;
-
-				return linkElement;
-			} );
-
-		// Build converter from view to model for data pipeline.
-		buildViewConverter().for( data.viewToModel )
-			// Convert <a> with href (value doesn't matter).
-			.from( { name: 'a', attribute: { href: /.?/ } } )
-			.toAttribute( viewElement => ( {
-				key: 'linkHref',
-				value: viewElement.getAttribute( 'href' )
-			} ) );
-
-		// Create linking commands.
-		editor.commands.add( 'link', new LinkCommand( editor ) );
-		editor.commands.add( 'unlink', new UnlinkCommand( editor ) );
-	}
-}

+ 530 - 0
packages/ckeditor5-link/src/linkui.js

@@ -0,0 +1,530 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module link/linkui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
+import Range from '@ckeditor/ckeditor5-engine/src/view/range';
+import { isLinkElement } from './utils';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+
+import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import LinkFormView from './ui/linkformview';
+import LinkActionsView from './ui/linkactionsview';
+
+import linkIcon from '../theme/icons/link.svg';
+
+const linkKeystroke = 'Ctrl+K';
+
+/**
+ * The link UI plugin. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
+ *
+ * It uses the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class LinkUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.editing.view.addObserver( ClickObserver );
+
+		/**
+		 * The actions view displayed inside of the balloon.
+		 *
+		 * @member {module:link/ui/linkactionsview~LinkActionsView}
+		 */
+		this.actionsView = this._createActionsView();
+
+		/**
+		 * The form view displayed inside the balloon.
+		 *
+		 * @member {module:link/ui/linkformview~LinkFormView}
+		 */
+		this.formView = this._createFormView();
+
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = editor.plugins.get( ContextualBalloon );
+
+		// Create toolbar buttons.
+		this._createToolbarLinkButton();
+
+		// Attach lifecycle actions to the the balloon.
+		this._enableUserBalloonInteractions();
+	}
+
+	/**
+	 * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance.
+	 *
+	 * @private
+	 * @returns {module:link/ui/linkactionsview~LinkActionsView} The link actions view instance.
+	 */
+	_createActionsView() {
+		const editor = this.editor;
+		const actionsView = new LinkActionsView( editor.locale );
+		const linkCommand = editor.commands.get( 'link' );
+		const unlinkCommand = editor.commands.get( 'unlink' );
+
+		actionsView.bind( 'href' ).to( linkCommand, 'value' );
+		actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand );
+		actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand );
+
+		// Execute unlink command after clicking on the "Edit" button.
+		this.listenTo( actionsView, 'edit', () => {
+			this._addFormView();
+		} );
+
+		// Execute unlink command after clicking on the "Unlink" button.
+		this.listenTo( actionsView, 'unlink', () => {
+			editor.execute( 'unlink' );
+			this._hideUI();
+		} );
+
+		// Close the panel on esc key press when the **actions have focus**.
+		actionsView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hideUI();
+			cancel();
+		} );
+
+		return actionsView;
+	}
+
+	/**
+	 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
+	 *
+	 * @private
+	 * @returns {module:link/ui/linkformview~LinkFormView} The link form instance.
+	 */
+	_createFormView() {
+		const editor = this.editor;
+		const formView = new LinkFormView( editor.locale );
+		const linkCommand = editor.commands.get( 'link' );
+
+		formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' );
+
+		// Form elements should be read-only when corresponding commands are disabled.
+		formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value );
+		formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand );
+
+		// Execute link command after clicking the "Save" button.
+		this.listenTo( formView, 'submit', () => {
+			editor.execute( 'link', formView.urlInputView.inputView.element.value );
+			this._removeFormView();
+		} );
+
+		// Hide the panel after clicking the "Cancel" button.
+		this.listenTo( formView, 'cancel', () => {
+			this._removeFormView();
+		} );
+
+		// Close the panel on esc key press when the **form has focus**.
+		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._removeFormView();
+			cancel();
+		} );
+
+		return formView;
+	}
+
+	/**
+	 * Creates a toolbar Link button. Clicking this button will show
+	 * a {@link #_balloon} attached to the selection.
+	 *
+	 * @private
+	 */
+	_createToolbarLinkButton() {
+		const editor = this.editor;
+		const linkCommand = editor.commands.get( 'link' );
+		const t = editor.t;
+
+		// Handle the `Ctrl+K` keystroke and show the panel.
+		editor.keystrokes.set( linkKeystroke, ( keyEvtData, cancel ) => {
+			// Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154.
+			cancel();
+
+			if ( linkCommand.isEnabled ) {
+				this._showUI();
+			}
+		} );
+
+		editor.ui.componentFactory.add( 'link', locale => {
+			const button = new ButtonView( locale );
+
+			button.isEnabled = true;
+			button.label = t( 'Link' );
+			button.icon = linkIcon;
+			button.keystroke = linkKeystroke;
+			button.tooltip = true;
+
+			// Bind button to the command.
+			button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
+
+			// Show the panel on button click.
+			this.listenTo( button, 'execute', () => this._showUI() );
+
+			return button;
+		} );
+	}
+
+	/**
+	 * Attaches actions that control whether the balloon panel containing the
+	 * {@link #formView} is visible or not.
+	 *
+	 * @private
+	 */
+	_enableUserBalloonInteractions() {
+		const viewDocument = this.editor.editing.view.document;
+
+		// Handle click on view document and show panel when selection is placed inside the link element.
+		// Keep panel open until selection will be inside the same link element.
+		this.listenTo( viewDocument, 'click', () => {
+			const parentLink = this._getSelectedLinkElement();
+
+			if ( parentLink ) {
+				// Then show panel but keep focus inside editor editable.
+				this._showUI();
+			}
+		} );
+
+		// Focus the form if the balloon is visible and the Tab key has been pressed.
+		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
+			if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) {
+				this.actionsView.focus();
+				cancel();
+			}
+		}, {
+			// Use the high priority because the link UI navigation is more important
+			// than other feature's actions, e.g. list indentation.
+			// https://github.com/ckeditor/ckeditor5-link/issues/146
+			priority: 'high'
+		} );
+
+		// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
+		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
+			if ( this._isUIVisible ) {
+				this._hideUI();
+				cancel();
+			}
+		} );
+
+		// Close on click outside of balloon panel element.
+		clickOutsideHandler( {
+			emitter: this.formView,
+			activator: () => this._isUIVisible,
+			contextElements: [ this._balloon.view.element ],
+			callback: () => this._hideUI()
+		} );
+	}
+
+	/**
+	 * Adds the {@link #actionsView} to the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_addActionsView() {
+		this._balloon.add( {
+			view: this.actionsView,
+			position: this._getBalloonPositionData()
+		} );
+	}
+
+	/**
+	 * Adds the {@link #formView} to the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_addFormView() {
+		const editor = this.editor;
+		const linkCommand = editor.commands.get( 'link' );
+
+		this._balloon.add( {
+			view: this.formView,
+			position: this._getBalloonPositionData()
+		} );
+
+		this.formView.urlInputView.select();
+
+		// Make sure that each time the panel shows up, the URL field remains in sync with the value of
+		// the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays
+		// unaltered) and re-opened it without changing the value of the link command (e.g. because they
+		// clicked the same link), they would see the old value instead of the actual value of the command.
+		// https://github.com/ckeditor/ckeditor5-link/issues/78
+		// https://github.com/ckeditor/ckeditor5-link/issues/123
+		this.formView.urlInputView.inputView.element.value = linkCommand.value || '';
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_removeFormView() {
+		if ( this._isFormInPanel ) {
+			this._balloon.remove( this.formView );
+
+			// Because the form has an input which has focus, the focus must be brought back
+			// to the editor. Otherwise, it would be lost.
+			this.editor.editing.view.focus();
+		}
+	}
+
+	/**
+	 * Shows the right kind of the UI for current state of the command. It's either
+	 * {@link #formView} or {@link #actionsView}.
+	 *
+	 * @private
+	 */
+	_showUI() {
+		const editor = this.editor;
+		const linkCommand = editor.commands.get( 'link' );
+
+		if ( !linkCommand.isEnabled || this._isUIInPanel ) {
+			return;
+		}
+
+		// When there's no link under the selection, go straight to the editing UI.
+		if ( !this._getSelectedLinkElement() ) {
+			this._addActionsView();
+			this._addFormView();
+		}
+		// Otherwise display just the actions UI.
+		else {
+			this._addActionsView();
+		}
+
+		// Begin responding to view#render once the UI is added.
+		this._startUpdatingUIOnViewRender();
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * See {@link #_addFormView}, {@link #_addActionsView}.
+	 *
+	 * @protected
+	 */
+	_hideUI() {
+		if ( !this._isUIInPanel ) {
+			return;
+		}
+
+		const editingView = this.editor.editing.view;
+
+		this.stopListening( editingView, 'render' );
+
+		// Remove form first because it's on top of the stack.
+		this._removeFormView();
+
+		// Then remove the actions view because it's beneath the form.
+		this._balloon.remove( this.actionsView );
+
+		// Make sure the focus always gets back to the editable.
+		editingView.focus();
+	}
+
+	/**
+	 * Makes the UI react to the {@link module:engine/view/view~View#event:render} in the view to
+	 * reposition itself as the document changes.
+	 *
+	 * See: {@link #_hideUI} to learn when the UI stops reacting to the `render` event.
+	 *
+	 * @protected
+	 */
+	_startUpdatingUIOnViewRender() {
+		const editor = this.editor;
+		const editing = editor.editing;
+		const editingView = editing.view;
+
+		let prevSelectedLink = this._getSelectedLinkElement();
+		let prevSelectionParent = getSelectionParent();
+
+		this.listenTo( editingView, 'render', () => {
+			const selectedLink = this._getSelectedLinkElement();
+			const selectionParent = getSelectionParent();
+
+			// Hide the panel if:
+			//
+			// * the selection went out of the EXISTING link element. E.g. user moved the caret out
+			//   of the link,
+			// * the selection went to a different parent when creating a NEW link. E.g. someone
+			//   else modified the document.
+			// * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow).
+			//
+			// Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only
+			// when fully selected.
+			if ( ( prevSelectedLink && !selectedLink ) ||
+				( !prevSelectedLink && selectionParent !== prevSelectionParent ) ) {
+				this._hideUI();
+			}
+			// Update the position of the panel when:
+			//  * the selection remains in the original link element,
+			//  * there was no link element in the first place, i.e. creating a new link
+			else {
+				// If still in a link element, simply update the position of the balloon.
+				// If there was no link (e.g. inserting one), the balloon must be moved
+				// to the new position in the editing view (a new native DOM range).
+				this._balloon.updatePosition( this._getBalloonPositionData() );
+			}
+
+			prevSelectedLink = selectedLink;
+			prevSelectionParent = selectionParent;
+		} );
+
+		function getSelectionParent() {
+			return editingView.document.selection.focus.getAncestors()
+				.reverse()
+				.find( node => node.is( 'element' ) );
+		}
+	}
+
+	/**
+	 * Returns true when {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isFormInPanel() {
+		return this._balloon.hasView( this.formView );
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _areActionsInPanel() {
+		return this._balloon.hasView( this.actionsView );
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} is in the {@link #_balloon} and it is
+	 * currently visible.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _areActionsVisible() {
+		return this._balloon.visibleView === this.actionsView;
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIInPanel() {
+		return this._isFormInPanel || this._areActionsInPanel;
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is
+	 * currently visible.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIVisible() {
+		const visibleView = this._balloon.visibleView;
+
+		return visibleView == this.formView || this._areActionsVisible;
+	}
+
+	/**
+	 * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached
+	 * to the target element or selection.
+	 *
+	 * If the selection is collapsed and inside a link element, the panel will be attached to the
+	 * entire link element. Otherwise, it will be attached to the selection.
+	 *
+	 * @private
+	 * @returns {module:utils/dom/position~Options}
+	 */
+	_getBalloonPositionData() {
+		const view = this.editor.editing.view;
+		const viewDocument = view.document;
+		const targetLink = this._getSelectedLinkElement();
+
+		const target = targetLink ?
+			// When selection is inside link element, then attach panel to this element.
+			view.domConverter.mapViewToDom( targetLink ) :
+			// Otherwise attach panel to the selection.
+			view.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
+
+		return { target };
+	}
+
+	/**
+	 * Returns the link {@link module:engine/view/attributeelement~AttributeElement} under
+	 * the {@link module:engine/view/document~Document editing view's} selection or `null`
+	 * if there is none.
+	 *
+	 * **Note**: For a non–collapsed selection the link element is only returned when **fully**
+	 * selected and the **only** element within the selection boundaries.
+	 *
+	 * @private
+	 * @returns {module:engine/view/attributeelement~AttributeElement|null}
+	 */
+	_getSelectedLinkElement() {
+		const selection = this.editor.editing.view.document.selection;
+
+		if ( selection.isCollapsed ) {
+			return findLinkElementAncestor( selection.getFirstPosition() );
+		} else {
+			// The range for fully selected link is usually anchored in adjacent text nodes.
+			// Trim it to get closer to the actual link element.
+			const range = selection.getFirstRange().getTrimmed();
+			const startLink = findLinkElementAncestor( range.start );
+			const endLink = findLinkElementAncestor( range.end );
+
+			if ( !startLink || startLink != endLink ) {
+				return null;
+			}
+
+			// Check if the link element is fully selected.
+			if ( Range.createIn( startLink ).getTrimmed().isEqual( range ) ) {
+				return startLink;
+			} else {
+				return null;
+			}
+		}
+	}
+}
+
+// Returns a link element if there's one among the ancestors of the provided `Position`.
+//
+// @private
+// @param {module:engine/view/position~Position} View position to analyze.
+// @returns {module:engine/view/attributeelement~AttributeElement|null} Link element at the position or null.
+function findLinkElementAncestor( position ) {
+	return position.getAncestors().find( ancestor => isLinkElement( ancestor ) );
+}

+ 235 - 0
packages/ckeditor5-link/src/ui/linkactionsview.js

@@ -0,0 +1,235 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module link/ui/linkactionsview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+
+import unlinkIcon from '../../theme/icons/unlink.svg';
+import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg';
+import '../../theme/linkactions.css';
+
+/**
+ * The link actions view class. This view displays link preview, allows
+ * unlinking or editing the link.
+ *
+ * @extends module:ui/view~View
+ */
+export default class LinkActionsView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const t = locale.t;
+
+		/**
+		 * Tracks information about DOM focus in the actions.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * The href preview view.
+		 *
+		 * @member {module:ui/view~View}
+		 */
+		this.previewButtonView = this._createPreviewButton();
+
+		/**
+		 * The unlink button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.unlinkButtonView = this._createButton( t( 'Unlink' ), unlinkIcon, 'unlink' );
+
+		/**
+		 * The edit link button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.editButtonView = this._createButton( t( 'Edit link' ), pencilIcon, 'edit' );
+
+		/**
+		 * Value of the "href" attribute of the link to use in the {@link #previewButtonView}.
+		 *
+		 * @observable
+		 * @member {String}
+		 */
+		this.set( 'href' );
+
+		/**
+		 * A collection of views which can be focused in the view.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the view.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		this.setTemplate( {
+			tag: 'div',
+
+			attributes: {
+				class: [
+					'ck-link-actions',
+				],
+
+				// https://github.com/ckeditor/ckeditor5-link/issues/90
+				tabindex: '-1'
+			},
+
+			children: [
+				this.previewButtonView,
+				this.editButtonView,
+				this.unlinkButtonView
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		const childViews = [
+			this.previewButtonView,
+			this.editButtonView,
+			this.unlinkButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the actions.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * Creates a button view.
+	 *
+	 * @private
+	 * @param {String} label The button label.
+	 * @param {String} icon The button's icon.
+	 * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to.
+	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
+	 */
+	_createButton( label, icon, eventName ) {
+		const button = new ButtonView( this.locale );
+
+		button.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
+
+		button.delegate( 'execute' ).to( this, eventName );
+
+		return button;
+	}
+
+	/**
+	 * Creates a link href preview button.
+	 *
+	 * @private
+	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
+	 */
+	_createPreviewButton() {
+		const button = new ButtonView( this.locale );
+		const bind = this.bindTemplate;
+		const t = this.t;
+
+		button.set( {
+			withText: true,
+			tooltip: t( 'Open link in new tab' )
+		} );
+
+		button.extendTemplate( {
+			attributes: {
+				class: [
+					'ck-link-actions__preview'
+				],
+				href: bind.to( 'href' ),
+				target: '_blank'
+			}
+		} );
+
+		button.bind( 'label' ).to( this, 'href', href => {
+			return href || t( 'This link has no URL' );
+		} );
+
+		button.bind( 'isEnabled' ).to( this, 'href', href => !!href );
+
+		button.template.tag = 'a';
+		button.template.eventListeners = {};
+
+		return button;
+	}
+}
+
+/**
+ * Fired when the {@link #editButtonView} is clicked.
+ *
+ * @event edit
+ */
+
+/**
+ * Fired when the {@link #unlinkButtonView} is clicked.
+ *
+ * @event unlink
+ */

+ 18 - 37
packages/ckeditor5-link/src/ui/linkformview.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -19,6 +19,10 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
 import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+import '../../theme/linkform.css';
+
 /**
  * The link form view controller class.
  *
@@ -63,7 +67,7 @@ export default class LinkFormView extends View {
 		 *
 		 * @member {module:ui/button/buttonview~ButtonView}
 		 */
-		this.saveButtonView = this._createButton( t( 'Save' ) );
+		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon );
 		this.saveButtonView.type = 'submit';
 
 		/**
@@ -71,14 +75,7 @@ export default class LinkFormView extends View {
 		 *
 		 * @member {module:ui/button/buttonview~ButtonView}
 		 */
-		this.cancelButtonView = this._createButton( t( 'Cancel' ), 'cancel' );
-
-		/**
-		 * The Unlink button view.
-		 *
-		 * @member {module:ui/button/buttonview~ButtonView}
-		 */
-		this.unlinkButtonView = this._createButton( t( 'Unlink' ), 'unlink' );
+		this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'cancel' );
 
 		/**
 		 * A collection of views which can be focused in the form.
@@ -131,21 +128,8 @@ export default class LinkFormView extends View {
 
 			children: [
 				this.urlInputView,
-				{
-					tag: 'div',
-
-					attributes: {
-						class: [
-							'ck-link-form__actions'
-						]
-					},
-
-					children: [
-						this.saveButtonView,
-						this.cancelButtonView,
-						this.unlinkButtonView
-					]
-				}
+				this.saveButtonView,
+				this.cancelButtonView
 			]
 		} );
 	}
@@ -163,8 +147,7 @@ export default class LinkFormView extends View {
 		const childViews = [
 			this.urlInputView,
 			this.saveButtonView,
-			this.cancelButtonView,
-			this.unlinkButtonView
+			this.cancelButtonView
 		];
 
 		childViews.forEach( v => {
@@ -207,15 +190,19 @@ export default class LinkFormView extends View {
 	 * Creates a button view.
 	 *
 	 * @private
-	 * @param {String} label The button label
+	 * @param {String} label The button label.
+	 * @param {String} icon The button's icon.
 	 * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to.
 	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
 	 */
-	_createButton( label, eventName ) {
+	_createButton( label, icon, eventName ) {
 		const button = new ButtonView( this.locale );
 
-		button.label = label;
-		button.withText = true;
+		button.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
 
 		if ( eventName ) {
 			button.delegate( 'execute' ).to( this, eventName );
@@ -237,9 +224,3 @@ export default class LinkFormView extends View {
  *
  * @event cancel
  */
-
-/**
- * Fired when the {@link #unlinkButtonView} is clicked.
- *
- * @event unlink
- */

+ 6 - 9
packages/ckeditor5-link/src/unlinkcommand.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -20,7 +20,7 @@ export default class UnlinkCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		this.isEnabled = this.editor.document.selection.hasAttribute( 'linkHref' );
+		this.isEnabled = this.editor.model.document.selection.hasAttribute( 'linkHref' );
 	}
 
 	/**
@@ -32,20 +32,17 @@ export default class UnlinkCommand extends Command {
 	 * @fires execute
 	 */
 	execute() {
-		const document = this.editor.document;
-		const selection = document.selection;
+		const model = this.editor.model;
+		const selection = model.document.selection;
 
-		document.enqueueChanges( () => {
+		model.change( writer => {
 			// Get ranges to unlink.
 			const rangesToUnlink = selection.isCollapsed ?
 				[ findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ) ) ] : selection.getRanges();
 
-			// Keep it as one undo step.
-			const batch = document.batch();
-
 			// Remove `linkHref` attribute from specified ranges.
 			for ( const range of rangesToUnlink ) {
-				batch.removeAttribute( range, 'linkHref' );
+				writer.removeAttribute( 'linkHref', range );
 			}
 		} );
 	}

+ 34 - 0
packages/ckeditor5-link/src/utils.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module link/utils
+ */
+
+const linkElementSymbol = Symbol( 'linkElement' );
+
+/**
+ * Returns `true` if a given view node is the link element.
+ *
+ * @param {module:engine/view/node~Node} node
+ * @return {Boolean}
+ */
+export function isLinkElement( node ) {
+	return node.is( 'attributeElement' ) && !!node.getCustomProperty( linkElementSymbol );
+}
+
+/**
+ * Creates link {@link module:engine/view/attributeelement~AttributeElement} with provided `href` attribute.
+ *
+ * @param {String} href
+ * @return {module:engine/view/attributeelement~AttributeElement}
+ */
+export function createLinkElement( href, writer ) {
+	// Priority 5 - https://github.com/ckeditor/ckeditor5-link/issues/121.
+	const linkElement = writer.createAttributeElement( 'a', { href }, 5 );
+	writer.setCustomProperty( linkElementSymbol, true, linkElement );
+
+	return linkElement;
+}

+ 17 - 16
packages/ckeditor5-link/tests/findlinkrange.js

@@ -1,26 +1,27 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
 import findLinkRange from '../src/findlinkrange';
-import Document from '@ckeditor/ckeditor5-engine/src/model/document';
+import Model from '@ckeditor/ckeditor5-engine/src/model/model';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'findLinkRange', () => {
-	let document, root;
+	let model, document, root;
 
 	beforeEach( () => {
-		document = new Document();
+		model = new Model();
+		document = model.document;
 		root = document.createRoot();
-		document.schema.allow( { name: '$text', inside: '$root' } );
-		document.schema.registerItem( 'p', '$block' );
+		model.schema.extend( '$text', { allowIn: '$root' } );
+		model.schema.register( 'p', { inheritAllFrom: '$block' } );
 	} );
 
 	it( 'should find link range searching from the center of the link #1', () => {
-		setData( document, '<$text linkHref="url">foobar</$text>' );
+		setData( model, '<$text linkHref="url">foobar</$text>' );
 
 		const startPosition = new Position( root, [ 3 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -30,7 +31,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range searching from the center of the link #2', () => {
-		setData( document, 'abc <$text linkHref="url">foobar</$text> abc' );
+		setData( model, 'abc <$text linkHref="url">foobar</$text> abc' );
 
 		const startPosition = new Position( root, [ 7 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -40,7 +41,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range searching from the beginning of the link #1', () => {
-		setData( document, '<$text linkHref="url">foobar</$text>' );
+		setData( model, '<$text linkHref="url">foobar</$text>' );
 
 		const startPosition = new Position( root, [ 0 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -50,7 +51,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range searching from the beginning of the link #2', () => {
-		setData( document, 'abc <$text linkHref="url">foobar</$text> abc' );
+		setData( model, 'abc <$text linkHref="url">foobar</$text> abc' );
 
 		const startPosition = new Position( root, [ 4 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -60,7 +61,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range searching from the end of the link #1', () => {
-		setData( document, '<$text linkHref="url">foobar</$text>' );
+		setData( model, '<$text linkHref="url">foobar</$text>' );
 
 		const startPosition = new Position( root, [ 6 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -70,7 +71,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range searching from the end of the link #2', () => {
-		setData( document, 'abc <$text linkHref="url">foobar</$text> abc' );
+		setData( model, 'abc <$text linkHref="url">foobar</$text> abc' );
 
 		const startPosition = new Position( root, [ 10 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -80,7 +81,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range when link stick to other link searching from the center of the link', () => {
-		setData( document, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
+		setData( model, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
 
 		const startPosition = new Position( root, [ 6 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -90,7 +91,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range when link stick to other link searching from the beginning of the link', () => {
-		setData( document, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
+		setData( model, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
 
 		const startPosition = new Position( root, [ 3 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -100,7 +101,7 @@ describe( 'findLinkRange', () => {
 	} );
 
 	it( 'should find link range when link stick to other link searching from the end of the link', () => {
-		setData( document, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
+		setData( model, '<$text linkHref="other">abc</$text><$text linkHref="url">foobar</$text><$text linkHref="other">abc</$text>' );
 
 		const startPosition = new Position( root, [ 9 ] );
 		const result = findLinkRange( startPosition, 'url' );
@@ -111,7 +112,7 @@ describe( 'findLinkRange', () => {
 
 	it( 'should find link range only inside current parent', () => {
 		setData(
-			document,
+			model,
 			'<p><$text linkHref="url">foobar</$text></p>' +
 			'<p><$text linkHref="url">foobar</$text></p>' +
 			'<p><$text linkHref="url">foobar</$text></p>'

+ 7 - 730
packages/ckeditor5-link/tests/link.js

@@ -1,741 +1,18 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
-/* globals document, Event */
-
-import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
-import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Link from '../src/link';
-import LinkEngine from '../src/linkengine';
-import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-
-import Range from '@ckeditor/ckeditor5-engine/src/view/range';
-import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
-
-testUtils.createSinonSandbox();
+import LinkEditing from '../src/linkediting';
+import LinkUI from '../src/linkui';
 
 describe( 'Link', () => {
-	let editor, linkFeature, linkButton, balloon, formView, editorElement;
-
-	beforeEach( () => {
-		editorElement = document.createElement( 'div' );
-		document.body.appendChild( editorElement );
-
-		return ClassicTestEditor
-			.create( editorElement, {
-				plugins: [ Link, Paragraph ]
-			} )
-			.then( newEditor => {
-				newEditor.editing.view.attachDomRoot( editorElement );
-
-				editor = newEditor;
-
-				linkFeature = editor.plugins.get( Link );
-				linkButton = editor.ui.componentFactory.create( 'link' );
-				balloon = editor.plugins.get( ContextualBalloon );
-				formView = linkFeature.formView;
-
-				// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
-				testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} );
-				testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} );
-
-				formView.render();
-			} );
-	} );
-
-	afterEach( () => {
-		editorElement.remove();
-
-		return editor.destroy();
+	it( 'should require LinkEditing and LinkUI', () => {
+		expect( Link.requires ).to.deep.equal( [ LinkEditing, LinkUI ] );
 	} );
 
-	it( 'should be loaded', () => {
-		expect( linkFeature ).to.instanceOf( Link );
-	} );
-
-	it( 'should load LinkEngine', () => {
-		expect( editor.plugins.get( LinkEngine ) ).to.instanceOf( LinkEngine );
-	} );
-
-	it( 'should load ContextualBalloon', () => {
-		expect( editor.plugins.get( ContextualBalloon ) ).to.instanceOf( ContextualBalloon );
-	} );
-
-	it( 'should register click observer', () => {
-		expect( editor.editing.view.getObserver( ClickObserver ) ).to.instanceOf( ClickObserver );
-	} );
-
-	describe( '_showPanel()', () => {
-		let balloonAddSpy;
-
-		beforeEach( () => {
-			balloonAddSpy = testUtils.sinon.spy( balloon, 'add' );
-			editor.editing.view.isFocused = true;
-		} );
-
-		it( 'should add #formView to the #_balloon and attach the #_balloon to the selection when text fragment is selected', () => {
-			setModelData( editor.document, '<paragraph>f[o]o</paragraph>' );
-			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
-
-			linkFeature._showPanel();
-
-			expect( balloon.visibleView ).to.equal( formView );
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
-				position: {
-					target: selectedRange
-				}
-			} );
-		} );
-
-		it( 'should add #formView to the #_balloon and attach the #_balloon to the selection when selection is collapsed', () => {
-			setModelData( editor.document, '<paragraph>f[]oo</paragraph>' );
-			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
-
-			linkFeature._showPanel();
-
-			expect( balloon.visibleView ).to.equal( formView );
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
-				position: {
-					target: selectedRange
-				}
-			} );
-		} );
-
-		it( 'should add #formView to the #_balloon and attach the #_balloon to the link element when collapsed selection is inside ' +
-			'that link',
-		() => {
-			setModelData( editor.document, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-			const linkElement = editorElement.querySelector( 'a' );
-
-			linkFeature._showPanel();
-
-			expect( balloon.visibleView ).to.equal( formView );
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
-				position: {
-					target: linkElement
-				}
-			} );
-		} );
-
-		it( 'should not focus the #formView at default', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel();
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should not focus the #formView when called with a `false` parameter', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel( false );
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should not focus the #formView when called with a `true` parameter while the balloon is opened but link ' +
-			'form is not visible', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-			const viewMock = {
-				ready: true,
-				render: () => {},
-				destroy: () => {}
-			};
-
-			linkFeature._showPanel( false );
-			balloon.add( { view: viewMock } );
-			linkFeature._showPanel( true );
-
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should focus the #formView when called with a `true` parameter', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel( true );
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'should focus the #formView when called with a `true` parameter while the balloon is open and the #formView is visible', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel( false );
-			linkFeature._showPanel( true );
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'should disable #formView elements when link and unlink commands are disabled', () => {
-			setModelData( editor.document, '<paragraph>f[o]o</paragraph>' );
-
-			linkFeature._showPanel();
-
-			editor.commands.get( 'link' ).isEnabled = true;
-			editor.commands.get( 'unlink' ).isEnabled = true;
-
-			expect( formView.urlInputView.isReadOnly ).to.false;
-			expect( formView.saveButtonView.isEnabled ).to.true;
-			expect( formView.unlinkButtonView.isEnabled ).to.true;
-			expect( formView.cancelButtonView.isEnabled ).to.true;
-
-			editor.commands.get( 'link' ).isEnabled = false;
-			editor.commands.get( 'unlink' ).isEnabled = false;
-
-			expect( formView.urlInputView.isReadOnly ).to.true;
-			expect( formView.saveButtonView.isEnabled ).to.false;
-			expect( formView.unlinkButtonView.isEnabled ).to.false;
-			expect( formView.cancelButtonView.isEnabled ).to.true;
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/53
-		it( 'should set formView.unlinkButtonView#isVisible depending on the selection in a link or not', () => {
-			setModelData( editor.document, '<paragraph>f[]oo</paragraph>' );
-
-			linkFeature._showPanel();
-			expect( formView.unlinkButtonView.isVisible ).to.be.false;
-
-			setModelData( editor.document, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-			linkFeature._showPanel();
-			expect( formView.unlinkButtonView.isVisible ).to.be.true;
-
-			setModelData( editor.document, '<paragraph><$text linkHref="url">[fo]o</$text></paragraph>' );
-
-			linkFeature._showPanel();
-			expect( formView.unlinkButtonView.isVisible ).to.be.true;
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/78
-		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (selected link)', () => {
-			setModelData( editor.document, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-			// Mock some leftover value **in DOM**, e.g. after previous editing.
-			formView.urlInputView.inputView.element.value = 'leftover';
-
-			linkFeature._showPanel();
-			expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' );
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/123
-		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => {
-			setModelData( editor.document, '<paragraph>f[]oo</paragraph>' );
-
-			linkFeature._showPanel();
-			expect( formView.urlInputView.inputView.element.value ).to.equal( '' );
-		} );
-
-		describe( 'when the document is rendering', () => {
-			it( 'should not duplicate #render listeners', () => {
-				const viewDocument = editor.editing.view;
-
-				setModelData( editor.document, '<paragraph>f[]oo</paragraph>' );
-
-				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-
-				linkFeature._showPanel();
-				viewDocument.render();
-				linkFeature._hidePanel();
-
-				linkFeature._showPanel();
-				viewDocument.render();
-				sinon.assert.calledTwice( spy );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'updates the position of the panel – editing a link, then the selection remains in the link upon #render', () => {
-				const viewDocument = editor.editing.view;
-
-				setModelData( editor.document, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-				linkFeature._showPanel();
-				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
-
-				// Move selection to foo[].
-				viewDocument.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true );
-				viewDocument.render();
-
-				sinon.assert.calledOnce( spy );
-				sinon.assert.calledWithExactly( spy, {
-					target: viewDocument.domConverter.mapViewToDom( root.getChild( 0 ).getChild( 0 ) )
-				} );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'updates the position of the panel – creating a new link, then the selection moved upon #render', () => {
-				const viewDocument = editor.editing.view;
-
-				setModelData( editor.document, '<paragraph>f[]oo</paragraph>' );
-
-				linkFeature._showPanel();
-				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-
-				// Fires #render.
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 );
-
-				viewDocument.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true );
-				viewDocument.render();
-
-				sinon.assert.calledOnce( spy );
-				sinon.assert.calledWithExactly( spy, {
-					target: editorElement.ownerDocument.getSelection().getRangeAt( 0 )
-				} );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides of the panel – editing a link, then the selection moved out of the link upon #render', () => {
-				const viewDocument = editor.editing.view;
-
-				setModelData( editor.document, '<paragraph><$text linkHref="url">f[]oo</$text>bar</paragraph>' );
-
-				linkFeature._showPanel();
-
-				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 1 );
-
-				// Move selection to b[]ar.
-				viewDocument.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
-				viewDocument.render();
-
-				sinon.assert.calledOnce( spyHide );
-				sinon.assert.notCalled( spyUpdate );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides of the panel – editing a link, then the selection moved to another link upon #render', () => {
-				const viewDocument = editor.editing.view;
-
-				setModelData(
-					editor.document,
-					'<paragraph><$text linkHref="url">f[]oo</$text>bar<$text linkHref="url">b[]az</$text></paragraph>'
-				);
-
-				linkFeature._showPanel();
-
-				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 2 ).getChild( 0 );
-
-				// Move selection to b[]az.
-				viewDocument.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
-				viewDocument.render();
-
-				sinon.assert.calledOnce( spyHide );
-				sinon.assert.notCalled( spyUpdate );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides the panel – editing a link, then the selection expands upon #render', () => {
-				const viewDocument = editor.editing.view;
-
-				setModelData( editor.document, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-				linkFeature._showPanel();
-
-				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
-
-				// Move selection to f[o]o.
-				viewDocument.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 2 ) ], true );
-				viewDocument.render();
-
-				sinon.assert.calledOnce( spyHide );
-				sinon.assert.notCalled( spyUpdate );
-			} );
-		} );
-	} );
-
-	describe( '_hidePanel()', () => {
-		beforeEach( () => {
-			return balloon.add( { view: formView } );
-		} );
-
-		it( 'should remove #formView from the #_balloon', () => {
-			linkFeature._hidePanel();
-			expect( balloon.hasView( formView ) ).to.false;
-		} );
-
-		it( 'should not focus the `editable` by default', () => {
-			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-
-			linkFeature._hidePanel();
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should not focus the `editable` when called with a `false` parameter', () => {
-			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-
-			linkFeature._hidePanel( false );
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should focus the `editable` when called with a `true` parameter', () => {
-			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-
-			linkFeature._hidePanel( true );
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'should not throw an error when #formView is not added to the `balloon`', () => {
-			linkFeature._hidePanel( true );
-
-			expect( () => {
-				linkFeature._hidePanel( true );
-			} ).to.not.throw();
-		} );
-
-		it( 'should clear `render` listener from ViewDocument', () => {
-			const spy = sinon.spy();
-
-			linkFeature.listenTo( editor.editing.view, 'render', spy );
-			linkFeature._hidePanel();
-			editor.editing.view.render();
-
-			sinon.assert.notCalled( spy );
-		} );
-	} );
-
-	describe( 'link toolbar button', () => {
-		it( 'should register link button', () => {
-			expect( linkButton ).to.instanceOf( ButtonView );
-		} );
-
-		it( 'should bind linkButtonView to link command', () => {
-			const command = editor.commands.get( 'link' );
-
-			command.isEnabled = true;
-			expect( linkButton.isEnabled ).to.be.true;
-
-			command.isEnabled = false;
-			expect( linkButton.isEnabled ).to.be.false;
-		} );
-
-		it( 'should show the #_balloon on execute event with the selected #formView', () => {
-			const spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} );
-
-			linkButton.fire( 'execute' );
-			sinon.assert.calledWithExactly( spy, true );
-		} );
-	} );
-
-	describe( 'keyboard support', () => {
-		it( 'should show the #_balloon with selected #formView on Ctrl+K keystroke', () => {
-			const spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} );
-			const command = editor.commands.get( 'link' );
-
-			command.isEnabled = false;
-			editor.keystrokes.press( {
-				keyCode: keyCodes.k,
-				ctrlKey: true,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			} );
-			sinon.assert.notCalled( spy );
-
-			command.isEnabled = true;
-			editor.keystrokes.press( {
-				keyCode: keyCodes.k,
-				ctrlKey: true,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			} );
-			sinon.assert.calledWithExactly( spy, true );
-		} );
-
-		it( 'should prevent default action on Ctrl+K keystroke', () => {
-			const preventDefaultSpy = sinon.spy();
-			const stopPropagationSpy = sinon.spy();
-
-			editor.keystrokes.press( {
-				keyCode: keyCodes.k,
-				ctrlKey: true,
-				preventDefault: preventDefaultSpy,
-				stopPropagation: stopPropagationSpy
-			} );
-
-			sinon.assert.calledOnce( preventDefaultSpy );
-			sinon.assert.calledOnce( stopPropagationSpy );
-		} );
-
-		it( 'should focus the the #formView on `Tab` key press when the #_balloon is open', () => {
-			const keyEvtData = {
-				keyCode: keyCodes.tab,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			};
-
-			const normalPriorityTabCallbackSpy = sinon.spy();
-			const highestPriorityTabCallbackSpy = sinon.spy();
-			editor.keystrokes.set( 'Tab', normalPriorityTabCallbackSpy );
-			editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } );
-
-			// Balloon is invisible, form not focused.
-			formView.focusTracker.isFocused = false;
-
-			const spy = sinon.spy( formView, 'focus' );
-
-			editor.keystrokes.press( keyEvtData );
-			sinon.assert.notCalled( keyEvtData.preventDefault );
-			sinon.assert.notCalled( keyEvtData.stopPropagation );
-			sinon.assert.notCalled( spy );
-			sinon.assert.calledOnce( normalPriorityTabCallbackSpy );
-			sinon.assert.calledOnce( highestPriorityTabCallbackSpy );
-
-			// Balloon is visible, form focused.
-			linkFeature._showPanel( true );
-			formView.focusTracker.isFocused = true;
-
-			editor.keystrokes.press( keyEvtData );
-			sinon.assert.notCalled( keyEvtData.preventDefault );
-			sinon.assert.notCalled( keyEvtData.stopPropagation );
-			sinon.assert.notCalled( spy );
-			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
-			sinon.assert.calledTwice( highestPriorityTabCallbackSpy );
-
-			// Balloon is still visible, form not focused.
-			formView.focusTracker.isFocused = false;
-
-			editor.keystrokes.press( keyEvtData );
-			sinon.assert.calledOnce( keyEvtData.preventDefault );
-			sinon.assert.calledOnce( keyEvtData.stopPropagation );
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
-			sinon.assert.calledThrice( highestPriorityTabCallbackSpy );
-		} );
-
-		it( 'should hide the #_balloon after Esc key press (from editor) and not focus the editable', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-			const keyEvtData = {
-				keyCode: keyCodes.esc,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			};
-
-			// Balloon is visible.
-			linkFeature._showPanel( false );
-			editor.keystrokes.press( keyEvtData );
-
-			sinon.assert.calledWithExactly( spy );
-		} );
-
-		it( 'should not hide #_balloon after Esc key press (from editor) when #_balloon is open but is not visible', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-			const keyEvtData = {
-				keyCode: keyCodes.esc,
-				preventDefault: () => {},
-				stopPropagation: () => {}
-			};
-
-			const viewMock = {
-				ready: true,
-				render: () => {},
-				destroy: () => {}
-			};
-
-			linkFeature._showPanel( false );
-			balloon.add( { view: viewMock } );
-			editor.keystrokes.press( keyEvtData );
-
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should hide the #_balloon after Esc key press (from the form) and focus the editable', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-			const keyEvtData = {
-				keyCode: keyCodes.esc,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			};
-
-			linkFeature._showPanel( true );
-
-			formView.keystrokes.press( keyEvtData );
-			sinon.assert.calledWithExactly( spy, true );
-		} );
-	} );
-
-	describe( 'mouse support', () => {
-		it( 'should hide #_balloon and not focus editable on click outside the #_balloon', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-
-			linkFeature._showPanel( true );
-			document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
-
-			sinon.assert.calledWithExactly( spy );
-		} );
-
-		it( 'should not hide #_balloon on click inside the #_balloon', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-
-			linkFeature._showPanel( true );
-			balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
-
-			sinon.assert.notCalled( spy );
-		} );
-
-		describe( 'clicking on editable', () => {
-			let observer, spy;
-
-			beforeEach( () => {
-				observer = editor.editing.view.getObserver( ClickObserver );
-				editor.document.schema.allow( { name: '$text', inside: '$root' } );
-
-				spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} );
-			} );
-
-			it( 'should open with not selected formView when collapsed selection is inside link element', () => {
-				setModelData( editor.document, '<$text linkHref="url">fo[]o</$text>' );
-
-				observer.fire( 'click', { target: document.body } );
-				sinon.assert.calledWithExactly( spy );
-			} );
-
-			it( 'should open when selection exclusively encloses a LinkElement (#1)', () => {
-				setModelData( editor.document, '[<$text linkHref="url">foo</$text>]' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.calledWithExactly( spy );
-			} );
-
-			it( 'should open when selection exclusively encloses a LinkElement (#2)', () => {
-				setModelData( editor.document, '<$text linkHref="url">[foo]</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.calledWithExactly( spy );
-			} );
-
-			it( 'should not open when selection is not inside link element', () => {
-				setModelData( editor.document, '[]' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a LinkElement (#1)', () => {
-				setModelData( editor.document, '<$text linkHref="url">f[o]o</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a LinkElement (#2)', () => {
-				setModelData( editor.document, '<$text linkHref="url">[fo]o</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a LinkElement (#3)', () => {
-				setModelData( editor.document, '<$text linkHref="url">f[oo]</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a LinkElement (#4)', () => {
-				setModelData( editor.document, 'ba[r<$text linkHref="url">foo]</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a LinkElement (#5)', () => {
-				setModelData( editor.document, 'ba[r<$text linkHref="url">foo</$text>]' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-		} );
-	} );
-
-	describe( 'link form', () => {
-		let focusEditableSpy;
-
-		beforeEach( () => {
-			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-		} );
-
-		it( 'should mark the editor ui as focused when the #formView is focused', () => {
-			linkFeature._showPanel();
-			editor.ui.focusTracker.isFocused = false;
-			formView.element.dispatchEvent( new Event( 'focus' ) );
-
-			expect( editor.ui.focusTracker.isFocused ).to.true;
-		} );
-
-		describe( 'binding', () => {
-			it( 'should bind formView.urlInputView#value to link command value', () => {
-				const command = editor.commands.get( 'link' );
-
-				expect( formView.urlInputView.value ).to.undefined;
-
-				command.value = 'http://cksource.com';
-				expect( formView.urlInputView.value ).to.equal( 'http://cksource.com' );
-			} );
-
-			it( 'should execute link command on formView#submit event', () => {
-				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
-
-				formView.urlInputView.value = 'http://ckeditor.com';
-				expect( formView.urlInputView.inputView.element.value ).to.equal( 'http://ckeditor.com' );
-
-				formView.urlInputView.inputView.element.value = 'http://cksource.com';
-				formView.fire( 'submit' );
-
-				expect( executeSpy.calledOnce ).to.true;
-				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.true;
-			} );
-
-			it( 'should hide and focus editable on formView#submit event', () => {
-				linkFeature._showPanel();
-				formView.fire( 'submit' );
-
-				expect( balloon.visibleView ).to.null;
-				expect( focusEditableSpy.calledOnce ).to.true;
-			} );
-
-			it( 'should execute unlink command on formView#unlink event', () => {
-				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
-
-				formView.fire( 'unlink' );
-
-				expect( executeSpy.calledOnce ).to.true;
-				expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true;
-			} );
-
-			it( 'should hide and focus editable on formView#unlink event', () => {
-				linkFeature._showPanel();
-				formView.fire( 'unlink' );
-
-				expect( balloon.visibleView ).to.null;
-				expect( focusEditableSpy.calledOnce ).to.true;
-			} );
-
-			it( 'should hide and focus editable on formView#cancel event', () => {
-				linkFeature._showPanel();
-				formView.fire( 'cancel' );
-
-				expect( balloon.visibleView ).to.null;
-				expect( focusEditableSpy.calledOnce ).to.true;
-			} );
-		} );
+	it( 'should be named', () => {
+		expect( Link.pluginName ).to.equal( 'Link' );
 	} );
 } );

+ 72 - 44
packages/ckeditor5-link/tests/linkcommand.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -8,22 +8,21 @@ import LinkCommand from '../src/linkcommand';
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'LinkCommand', () => {
-	let editor, document, command;
+	let editor, model, command;
 
 	beforeEach( () => {
 		return ModelTestEditor.create()
 			.then( newEditor => {
 				editor = newEditor;
-				document = editor.document;
+				model = editor.model;
 				command = new LinkCommand( editor );
 
-				// Allow text in $root.
-				document.schema.allow( { name: '$text', inside: '$root' } );
+				model.schema.extend( '$text', {
+					allowIn: '$root',
+					allowAttributes: 'linkHref'
+				} );
 
-				// Allow text with `linkHref` attribute in paragraph.
-				document.schema.registerItem( 'p', '$block' );
-				document.schema.allow( { name: '$text', attributes: 'linkHref', inside: '$root' } );
-				document.schema.allow( { name: '$text', attributes: 'linkHref', inside: 'p' } );
+				model.schema.register( 'p', { inheritAllFrom: '$block' } );
 			} );
 	} );
 
@@ -36,30 +35,35 @@ describe( 'LinkCommand', () => {
 		// refresh() uses `isAttributeAllowedInSelection` helper which is fully tested in his own test.
 
 		beforeEach( () => {
-			document.schema.registerItem( 'x', '$block' );
-			document.schema.disallow( { name: '$text', inside: 'x', attributes: 'linkHref' } );
+			model.schema.register( 'x', { inheritAllFrom: '$block' } );
+
+			model.schema.addAttributeCheck( ( ctx, attributeName ) => {
+				if ( ctx.endsWith( 'x $text' ) && attributeName == 'linkHref' ) {
+					return false;
+				}
+			} );
 		} );
 
 		describe( 'when selection is collapsed', () => {
 			it( 'should be true if characters with the attribute can be placed at caret position', () => {
-				setData( document, '<p>f[]oo</p>' );
+				setData( model, '<p>f[]oo</p>' );
 				expect( command.isEnabled ).to.be.true;
 			} );
 
 			it( 'should be false if characters with the attribute cannot be placed at caret position', () => {
-				setData( document, '<x>fo[]o</x>' );
+				setData( model, '<x>fo[]o</x>' );
 				expect( command.isEnabled ).to.be.false;
 			} );
 		} );
 
 		describe( 'when selection is not collapsed', () => {
 			it( 'should be true if there is at least one node in selection that can have the attribute', () => {
-				setData( document, '<p>[foo]</p>' );
+				setData( model, '<p>[foo]</p>' );
 				expect( command.isEnabled ).to.be.true;
 			} );
 
 			it( 'should be false if there are no nodes in selection that can have the attribute', () => {
-				setData( document, '<x>[foo]</x>' );
+				setData( model, '<x>[foo]</x>' );
 				expect( command.isEnabled ).to.be.false;
 			} );
 		} );
@@ -68,13 +72,13 @@ describe( 'LinkCommand', () => {
 	describe( 'value', () => {
 		describe( 'collapsed selection', () => {
 			it( 'should be equal attribute value when selection is placed inside element with `linkHref` attribute', () => {
-				setData( document, '<$text linkHref="url">foo[]bar</$text>' );
+				setData( model, '<$text linkHref="url">foo[]bar</$text>' );
 
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should be undefined when selection is placed inside element without `linkHref` attribute', () => {
-				setData( document, '<$text bold="true">foo[]bar</$text>' );
+				setData( model, '<$text bold="true">foo[]bar</$text>' );
 
 				expect( command.value ).to.be.undefined;
 			} );
@@ -82,13 +86,13 @@ describe( 'LinkCommand', () => {
 
 		describe( 'non-collapsed selection', () => {
 			it( 'should be equal attribute value when selection contains only elements with `linkHref` attribute', () => {
-				setData( document, 'fo[<$text linkHref="url">ob</$text>]ar' );
+				setData( model, 'fo[<$text linkHref="url">ob</$text>]ar' );
 
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should be undefined when selection contains not only elements with `linkHref` attribute', () => {
-				setData( document, 'f[o<$text linkHref="url">ob</$text>]ar' );
+				setData( model, 'f[o<$text linkHref="url">ob</$text>]ar' );
 
 				expect( command.value ).to.be.undefined;
 			} );
@@ -98,25 +102,25 @@ describe( 'LinkCommand', () => {
 	describe( 'execute()', () => {
 		describe( 'non-collapsed selection', () => {
 			it( 'should set `linkHref` attribute to selected text', () => {
-				setData( document, 'f[ooba]r' );
+				setData( model, 'f[ooba]r' );
 
 				expect( command.value ).to.be.undefined;
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal( 'f[<$text linkHref="url">ooba</$text>]r' );
+				expect( getData( model ) ).to.equal( 'f[<$text linkHref="url">ooba</$text>]r' );
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should set `linkHref` attribute to selected text when text already has attributes', () => {
-				setData( document, 'f[o<$text bold="true">oba]r</$text>' );
+				setData( model, 'f[o<$text bold="true">oba]r</$text>' );
 
 				expect( command.value ).to.be.undefined;
 
 				command.execute( 'url' );
 
 				expect( command.value ).to.equal( 'url' );
-				expect( getData( document ) ).to.equal(
+				expect( getData( model ) ).to.equal(
 					'f[<$text linkHref="url">o</$text>' +
 					'<$text bold="true" linkHref="url">oba</$text>]' +
 					'<$text bold="true">r</$text>'
@@ -124,24 +128,24 @@ describe( 'LinkCommand', () => {
 			} );
 
 			it( 'should overwrite existing `linkHref` attribute when selected text wraps text with `linkHref` attribute', () => {
-				setData( document, 'f[o<$text linkHref="other url">o</$text>ba]r' );
+				setData( model, 'f[o<$text linkHref="other url">o</$text>ba]r' );
 
 				expect( command.value ).to.be.undefined;
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal( 'f[<$text linkHref="url">ooba</$text>]r' );
+				expect( getData( model ) ).to.equal( 'f[<$text linkHref="url">ooba</$text>]r' );
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should split text and overwrite attribute value when selection is inside text with `linkHref` attribute', () => {
-				setData( document, 'f<$text linkHref="other url">o[ob]a</$text>r' );
+				setData( model, 'f<$text linkHref="other url">o[ob]a</$text>r' );
 
 				expect( command.value ).to.equal( 'other url' );
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal(
+				expect( getData( model ) ).to.equal(
 					'f' +
 					'<$text linkHref="other url">o</$text>' +
 					'[<$text linkHref="url">ob</$text>]' +
@@ -154,51 +158,50 @@ describe( 'LinkCommand', () => {
 			it( 'should overwrite `linkHref` attribute of selected text only, ' +
 				'when selection start inside text with `linkHref` attribute',
 			() => {
-				setData( document, 'f<$text linkHref="other url">o[o</$text>ba]r' );
+				setData( model, 'f<$text linkHref="other url">o[o</$text>ba]r' );
 
 				expect( command.value ).to.equal( 'other url' );
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal( 'f<$text linkHref="other url">o</$text>[<$text linkHref="url">oba</$text>]r' );
+				expect( getData( model ) ).to.equal( 'f<$text linkHref="other url">o</$text>[<$text linkHref="url">oba</$text>]r' );
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should overwrite `linkHref` attribute of selected text only, when selection end inside text with `linkHref` ' +
 				'attribute', () => {
-				setData( document, 'f[o<$text linkHref="other url">ob]a</$text>r' );
+				setData( model, 'f[o<$text linkHref="other url">ob]a</$text>r' );
 
 				expect( command.value ).to.be.undefined;
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal( 'f[<$text linkHref="url">oob</$text>]<$text linkHref="other url">a</$text>r' );
+				expect( getData( model ) ).to.equal( 'f[<$text linkHref="url">oob</$text>]<$text linkHref="other url">a</$text>r' );
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should set `linkHref` attribute to selected text when text is split by $block element', () => {
-				setData( document, '<p>f[oo</p><p>ba]r</p>' );
+				setData( model, '<p>f[oo</p><p>ba]r</p>' );
 
 				expect( command.value ).to.be.undefined;
 
 				command.execute( 'url' );
 
-				expect( getData( document ) )
+				expect( getData( model ) )
 					.to.equal( '<p>f[<$text linkHref="url">oo</$text></p><p><$text linkHref="url">ba</$text>]r</p>' );
 				expect( command.value ).to.equal( 'url' );
 			} );
 
 			it( 'should set `linkHref` attribute only to allowed elements and omit disallowed', () => {
-				// Disallow text in img.
-				document.schema.registerItem( 'img', '$inline' );
+				model.schema.register( 'img', { allowWhere: '$text' } );
 
-				setData( document, '<p>f[oo<img></img>ba]r</p>' );
+				setData( model, '<p>f[oo<img></img>ba]r</p>' );
 
 				expect( command.value ).to.be.undefined;
 
 				command.execute( 'url' );
 
-				expect( getData( document ) )
+				expect( getData( model ) )
 					.to.equal( '<p>f[<$text linkHref="url">oo</$text><img></img><$text linkHref="url">ba</$text>]r</p>' );
 				expect( command.value ).to.equal( 'url' );
 			} );
@@ -206,28 +209,53 @@ describe( 'LinkCommand', () => {
 
 		describe( 'collapsed selection', () => {
 			it( 'should insert text with `linkHref` attribute, text data equal to href and select new link', () => {
-				setData( document, 'foo[]bar' );
+				setData( model, 'foo[]bar' );
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal( 'foo[<$text linkHref="url">url</$text>]bar' );
+				expect( getData( model ) ).to.equal( 'foo[<$text linkHref="url">url</$text>]bar' );
+			} );
+
+			it( 'should insert text with `linkHref` attribute, and selection attributes', () => {
+				setData( model, '<$text bold="true">foo[]bar</$text>', {
+					selectionAttributes: { bold: true }
+				} );
+
+				command.execute( 'url' );
+
+				expect( getData( model ) ).to.equal(
+					'<$text bold="true">foo</$text>[<$text bold="true" linkHref="url">url</$text>]<$text bold="true">bar</$text>'
+				);
 			} );
 
 			it( 'should update `linkHref` attribute and select whole link when selection is inside text with `linkHref` attribute', () => {
-				setData( document, '<$text linkHref="other url">foo[]bar</$text>' );
+				setData( model, '<$text linkHref="other url">foo[]bar</$text>' );
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal( '[<$text linkHref="url">foobar</$text>]' );
+				expect( getData( model ) ).to.equal( '[<$text linkHref="url">foobar</$text>]' );
 			} );
 
 			it( 'should not insert text with `linkHref` attribute when is not allowed in parent', () => {
-				document.schema.disallow( { name: '$text', attributes: 'linkHref', inside: 'p' } );
-				setData( document, '<p>foo[]bar</p>' );
+				model.schema.addAttributeCheck( ( ctx, attributeName ) => {
+					if ( ctx.endsWith( 'p $text' ) && attributeName == 'linkHref' ) {
+						return false;
+					}
+				} );
+
+				setData( model, '<p>foo[]bar</p>' );
 
 				command.execute( 'url' );
 
-				expect( getData( document ) ).to.equal( '<p>foo[]bar</p>' );
+				expect( getData( model ) ).to.equal( '<p>foo[]bar</p>' );
+			} );
+
+			it( 'should not insert text node if link is empty', () => {
+				setData( model, '<p>foo[]bar</p>' );
+
+				command.execute( '' );
+
+				expect( getData( model ) ).to.equal( '<p>foo[]bar</p>' );
 			} );
 		} );
 	} );

+ 266 - 0
packages/ckeditor5-link/tests/linkediting.js

@@ -0,0 +1,266 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import LinkEditing from '../src/linkediting';
+import LinkCommand from '../src/linkcommand';
+import UnlinkCommand from '../src/unlinkcommand';
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { isLinkElement } from '../src/utils';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+import { downcastAttributeToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+
+/* global document */
+
+describe( 'LinkEditing', () => {
+	let editor, model, view;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, LinkEditing, Enter ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( LinkEditing ) ).to.be.instanceOf( LinkEditing );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( model.schema.checkAttribute( [ '$block', '$text' ], 'linkHref' ) ).to.be.true;
+		expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'linkHref' ) ).to.be.true;
+
+		expect( model.schema.checkAttribute( [ '$block' ], 'linkHref' ) ).to.be.false;
+	} );
+
+	it( 'should bind two-step caret movement to `linkHref` attribute', () => {
+		// Let's check only the minimum to not duplicated `bindTwoStepCaretToAttribute()` tests.
+		// Testing minimum is better then testing using spies that might give false positive results.
+
+		// Put selection before the link element.
+		setModelData( editor.model, '<paragraph>foo[]<$text linkHref="url">b</$text>ar</paragraph>' );
+
+		// The selection's gravity is not overridden because selection land here not as a result of `keydown`.
+		expect( editor.model.document.selection.isGravityOverridden ).to.false;
+
+		// So let's simulate `keydown` event.
+		editor.editing.view.document.fire( 'keydown', {
+			keyCode: keyCodes.arrowright,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		expect( editor.model.document.selection.isGravityOverridden ).to.true;
+	} );
+
+	describe( 'command', () => {
+		it( 'should register link command', () => {
+			const command = editor.commands.get( 'link' );
+
+			expect( command ).to.be.instanceOf( LinkCommand );
+		} );
+
+		it( 'should register unlink command', () => {
+			const command = editor.commands.get( 'unlink' );
+
+			expect( command ).to.be.instanceOf( UnlinkCommand );
+		} );
+	} );
+
+	describe( 'data pipeline conversions', () => {
+		it( 'should convert `<a href="url">` to `linkHref="url"` attribute', () => {
+			editor.setData( '<p><a href="url">foo</a>bar</p>' );
+
+			expect( getModelData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph><$text linkHref="url">foo</$text>bar</paragraph>' );
+
+			expect( editor.getData() ).to.equal( '<p><a href="url">foo</a>bar</p>' );
+		} );
+
+		it( 'should be integrated with autoparagraphing', () => {
+			editor.setData( '<a href="url">foo</a>bar' );
+
+			expect( getModelData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph><$text linkHref="url">foo</$text>bar</paragraph>' );
+
+			expect( editor.getData() ).to.equal( '<p><a href="url">foo</a>bar</p>' );
+		} );
+
+		// https://github.com/ckeditor/ckeditor5/issues/500
+		it( 'should not pick up `<a name="foo">`', () => {
+			editor.setData( '<p><a name="foo">foo</a>bar</p>' );
+
+			expect( getModelData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph>foobar</paragraph>' );
+		} );
+
+		// CKEditor 4 does. And CKEditor 5's balloon allows creating such links.
+		it( 'should pick up `<a href="">`', () => {
+			editor.setData( '<p><a href="">foo</a>bar</p>' );
+
+			expect( getModelData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph><$text linkHref="">foo</$text>bar</paragraph>' );
+
+			expect( editor.getData() ).to.equal( '<p><a href="">foo</a>bar</p>' );
+		} );
+	} );
+
+	describe( 'editing pipeline conversion', () => {
+		it( 'should convert attribute', () => {
+			setModelData( model, '<paragraph><$text linkHref="url">foo</$text>bar</paragraph>' );
+
+			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<p><a href="url">foo</a>bar</p>' );
+		} );
+
+		it( 'should convert to link element instance', () => {
+			setModelData( model, '<paragraph><$text linkHref="url">foo</$text>bar</paragraph>' );
+
+			const element = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 0 );
+			expect( isLinkElement( element ) ).to.be.true;
+		} );
+
+		// https://github.com/ckeditor/ckeditor5-link/issues/121
+		it( 'should should set priority for `linkHref` higher than all other attribute elements', () => {
+			model.schema.extend( '$text', { allowAttributes: 'foo' } );
+
+			editor.conversion.for( 'downcast' ).add( downcastAttributeToElement( { model: 'foo', view: 'f' } ) );
+
+			setModelData( model,
+				'<paragraph>' +
+					'<$text linkHref="url">a</$text><$text foo="true" linkHref="url">b</$text><$text linkHref="url">c</$text>' +
+				'</paragraph>' );
+
+			expect( editor.getData() ).to.equal( '<p><a href="url">a<f>b</f>c</a></p>' );
+		} );
+	} );
+
+	describe( 'highlight link boundaries', () => {
+		it( 'should create marker in model when selection is inside a link', () => {
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.false;
+
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">b{}ar</$text> baz</paragraph>'
+			);
+
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.true;
+			const marker = model.markers.get( 'linkBoundaries' );
+			expect( marker.getStart().path ).to.deep.equal( [ 0, 4 ] );
+			expect( marker.getEnd().path ).to.deep.equal( [ 0, 7 ] );
+		} );
+
+		it( 'should convert link boundaries marker to proper view', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">b{}ar</$text> baz</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-link_selected"><a href="url">b{}ar</a></span> baz</p>'
+			);
+		} );
+
+		it( 'should work whenever selection has linkHref attribute - link start', () => {
+			setModelData( model,
+				'<paragraph>foo {}<$text linkHref="url">bar</$text> baz</paragraph>'
+			);
+
+			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.false;
+
+			model.change( writer => {
+				writer.setSelectionAttribute( 'linkHref', 'url' );
+			} );
+
+			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.true;
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.true;
+			const marker = model.markers.get( 'linkBoundaries' );
+			expect( marker.getStart().path ).to.deep.equal( [ 0, 4 ] );
+			expect( marker.getEnd().path ).to.deep.equal( [ 0, 7 ] );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-link_selected"><a href="url">{}bar</a></span> baz</p>'
+			);
+		} );
+
+		it( 'should work whenever selection has linkHref attribute - link end', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">bar</$text>{} baz</paragraph>'
+			);
+
+			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.true;
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.true;
+			const marker = model.markers.get( 'linkBoundaries' );
+			expect( marker.getStart().path ).to.deep.equal( [ 0, 4 ] );
+			expect( marker.getEnd().path ).to.deep.equal( [ 0, 7 ] );
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-link_selected"><a href="url">bar{}</a></span> baz</p>'
+			);
+		} );
+
+		it( 'should render highlight correctly after splitting the link', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+			);
+
+			editor.execute( 'enter' );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo <$text linkHref="url">li</$text></paragraph>' +
+				'<paragraph><$text linkHref="url">[]nk</$text> baz</paragraph>'
+			);
+
+			expect( model.document.selection.hasAttribute( 'linkHref' ) ).to.be.true;
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.true;
+			const marker = model.markers.get( 'linkBoundaries' );
+			expect( marker.getStart().path ).to.deep.equal( [ 1, 0 ] );
+			expect( marker.getEnd().path ).to.deep.equal( [ 1, 2 ] );
+		} );
+
+		it( 'should remove marker when selection is moved out from the link', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-link_selected"><a href="url">li{}nk</a></span> baz</p>'
+			);
+
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.true;
+			model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 0 ) );
+
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.false;
+			expect( getViewData( view ) ).to.equal(
+				'<p>{}foo <a href="url">link</a> baz</p>'
+			);
+		} );
+
+		it( 'should work correctly when selection is moved inside link', () => {
+			setModelData( model,
+				'<paragraph>foo <$text linkHref="url">li{}nk</$text> baz</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-link_selected"><a href="url">li{}nk</a></span> baz</p>'
+			);
+
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.true;
+			model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 5 ) );
+
+			expect( model.markers.has( 'linkBoundaries' ) ).to.be.true;
+			expect( getViewData( view ) ).to.equal(
+				'<p>foo <span class="ck-link_selected"><a href="url">l{}ink</a></span> baz</p>'
+			);
+		} );
+	} );
+} );

+ 0 - 13
packages/ckeditor5-link/tests/linkelement.js

@@ -1,13 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import LinkElement from '../src/linkelement';
-import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement';
-
-describe( 'LinkElement', () => {
-	it( 'should extend AttributeElement', () => {
-		expect( new LinkElement( 'a' ) ).to.instanceof( AttributeElement );
-	} );
-} );

+ 0 - 126
packages/ckeditor5-link/tests/linkengine.js

@@ -1,126 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import LinkEngine from '../src/linkengine';
-import LinkCommand from '../src/linkcommand';
-import LinkElement from '../src/linkelement';
-import UnlinkCommand from '../src/unlinkcommand';
-
-import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-
-import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
-
-describe( 'LinkEngine', () => {
-	let editor, doc;
-
-	beforeEach( () => {
-		return VirtualTestEditor
-			.create( {
-				plugins: [ Paragraph, LinkEngine ]
-			} )
-			.then( newEditor => {
-				editor = newEditor;
-
-				doc = editor.document;
-			} );
-	} );
-
-	it( 'should be loaded', () => {
-		expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine );
-	} );
-
-	it( 'should set proper schema rules', () => {
-		expect( doc.schema.check( { name: '$inline', attributes: 'linkHref', inside: '$root' } ) ).to.be.false;
-		expect( doc.schema.check( { name: '$inline', attributes: 'linkHref', inside: '$block' } ) ).to.be.true;
-		expect( doc.schema.check( { name: '$inline', attributes: 'linkHref', inside: '$clipboardHolder' } ) ).to.be.true;
-	} );
-
-	describe( 'command', () => {
-		it( 'should register link command', () => {
-			const command = editor.commands.get( 'link' );
-
-			expect( command ).to.be.instanceOf( LinkCommand );
-		} );
-
-		it( 'should register unlink command', () => {
-			const command = editor.commands.get( 'unlink' );
-
-			expect( command ).to.be.instanceOf( UnlinkCommand );
-		} );
-	} );
-
-	describe( 'data pipeline conversions', () => {
-		it( 'should convert `<a href="url">` to `linkHref="url"` attribute', () => {
-			editor.setData( '<p><a href="url">foo</a>bar</p>' );
-
-			expect( getModelData( doc, { withoutSelection: true } ) )
-				.to.equal( '<paragraph><$text linkHref="url">foo</$text>bar</paragraph>' );
-
-			expect( editor.getData() ).to.equal( '<p><a href="url">foo</a>bar</p>' );
-		} );
-
-		it( 'should be integrated with autoparagraphing', () => {
-			// Incorrect results because autoparagraphing works incorrectly (issue in paragraph).
-			// https://github.com/ckeditor/ckeditor5-paragraph/issues/10
-
-			editor.setData( '<a href="url">foo</a>bar' );
-
-			expect( getModelData( doc, { withoutSelection: true } ) ).to.equal( '<paragraph>foobar</paragraph>' );
-
-			expect( editor.getData() ).to.equal( '<p>foobar</p>' );
-		} );
-
-		// https://github.com/ckeditor/ckeditor5/issues/500
-		it( 'should not pick up `<a name="foo">`', () => {
-			editor.setData( '<p><a name="foo">foo</a>bar</p>' );
-
-			expect( getModelData( doc, { withoutSelection: true } ) )
-				.to.equal( '<paragraph>foobar</paragraph>' );
-		} );
-
-		// CKEditor 4 does. And CKEditor 5's balloon allows creating such links.
-		it( 'should pick up `<a href="">`', () => {
-			editor.setData( '<p><a href="">foo</a>bar</p>' );
-
-			expect( getModelData( doc, { withoutSelection: true } ) )
-				.to.equal( '<paragraph><$text linkHref="">foo</$text>bar</paragraph>' );
-
-			expect( editor.getData() ).to.equal( '<p><a href="">foo</a>bar</p>' );
-		} );
-	} );
-
-	describe( 'editing pipeline conversion', () => {
-		it( 'should convert attribute', () => {
-			setModelData( doc, '<paragraph><$text linkHref="url">foo</$text>bar</paragraph>' );
-
-			expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '<p><a href="url">foo</a>bar</p>' );
-		} );
-
-		it( 'should convert to `LinkElement` instance', () => {
-			setModelData( doc, '<paragraph><$text linkHref="url">foo</$text>bar</paragraph>' );
-
-			expect( editor.editing.view.getRoot().getChild( 0 ).getChild( 0 ) ).to.be.instanceof( LinkElement );
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/121
-		it( 'should should set priority for `linkHref` higher than all other attribute elements', () => {
-			editor.document.schema.allow( { name: '$inline', attributes: [ 'foo' ], inside: '$block' } );
-
-			buildModelConverter().for( editor.data.modelToView )
-				.fromAttribute( 'foo' )
-				.toElement( 'f' );
-
-			setModelData( doc,
-				'<paragraph>' +
-					'<$text linkHref="url">a</$text><$text foo="true" linkHref="url">b</$text><$text linkHref="url">c</$text>' +
-				'</paragraph>' );
-
-			expect( editor.getData() ).to.equal( '<p><a href="url">a<f>b</f>c</a></p>' );
-		} );
-	} );
-} );

+ 771 - 0
packages/ckeditor5-link/tests/linkui.js

@@ -0,0 +1,771 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, Event */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import LinkEditing from '../src/linkediting';
+import LinkUI from '../src/linkui';
+import LinkFormView from '../src/ui/linkformview';
+import LinkActionsView from '../src/ui/linkactionsview';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import Range from '@ckeditor/ckeditor5-engine/src/view/range';
+import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
+
+testUtils.createSinonSandbox();
+
+describe( 'LinkUI', () => {
+	let editor, linkUIFeature, linkButton, balloon, formView, actionsView, editorElement;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ LinkEditing, LinkUI, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				linkUIFeature = editor.plugins.get( LinkUI );
+				linkButton = editor.ui.componentFactory.create( 'link' );
+				balloon = editor.plugins.get( ContextualBalloon );
+				formView = linkUIFeature.formView;
+				actionsView = linkUIFeature.actionsView;
+
+				// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
+				testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} );
+				testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} );
+
+				formView.render();
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should load ContextualBalloon', () => {
+		expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon );
+	} );
+
+	describe( 'init', () => {
+		it( 'should register click observer', () => {
+			expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver );
+		} );
+
+		it( 'should create #actionsView', () => {
+			expect( actionsView ).to.be.instanceOf( LinkActionsView );
+		} );
+
+		it( 'should create #formView', () => {
+			expect( formView ).to.be.instanceOf( LinkFormView );
+		} );
+
+		describe( 'link toolbar button', () => {
+			it( 'should be registered', () => {
+				expect( linkButton ).to.be.instanceOf( ButtonView );
+			} );
+
+			it( 'should be bound to the link command', () => {
+				const command = editor.commands.get( 'link' );
+
+				command.isEnabled = true;
+				expect( linkButton.isEnabled ).to.be.true;
+
+				command.isEnabled = false;
+				expect( linkButton.isEnabled ).to.be.false;
+			} );
+
+			it( 'should call #_showUI upon #execute', () => {
+				const spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} );
+
+				linkButton.fire( 'execute' );
+				sinon.assert.calledWithExactly( spy );
+			} );
+		} );
+	} );
+
+	describe( '_showUI()', () => {
+		let balloonAddSpy;
+
+		beforeEach( () => {
+			balloonAddSpy = testUtils.sinon.spy( balloon, 'add' );
+			editor.editing.view.document.isFocused = true;
+		} );
+
+		it( 'should not work if the link command is disabled', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			editor.commands.get( 'link' ).isEnabled = false;
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.be.null;
+		} );
+
+		it( 'should not throw if the UI is already visible', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+
+			linkUIFeature._showUI();
+
+			expect( () => {
+				linkUIFeature._showUI();
+			} ).to.not.throw();
+		} );
+
+		it( 'should add #formView to the balloon and attach the balloon to the selection when text fragment is selected', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.equal( formView );
+			sinon.assert.calledWithExactly( balloonAddSpy, {
+				view: formView,
+				position: {
+					target: selectedRange
+				}
+			} );
+		} );
+
+		it( 'should add #formView to the balloon and attach the balloon to the selection when selection is collapsed', () => {
+			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.equal( formView );
+			sinon.assert.calledWithExactly( balloonAddSpy, {
+				view: formView,
+				position: {
+					target: selectedRange
+				}
+			} );
+		} );
+
+		it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' +
+			'that link',
+		() => {
+			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+			const linkElement = editor.editing.view.getDomRoot().querySelector( 'a' );
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.equal( actionsView );
+			sinon.assert.calledWithExactly( balloonAddSpy, {
+				view: actionsView,
+				position: {
+					target: linkElement
+				}
+			} );
+		} );
+
+		it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+
+			linkUIFeature._showUI();
+
+			editor.commands.get( 'link' ).isEnabled = true;
+			editor.commands.get( 'unlink' ).isEnabled = true;
+
+			expect( formView.urlInputView.isReadOnly ).to.be.false;
+			expect( formView.saveButtonView.isEnabled ).to.be.true;
+			expect( formView.cancelButtonView.isEnabled ).to.be.true;
+
+			expect( actionsView.unlinkButtonView.isEnabled ).to.be.true;
+			expect( actionsView.editButtonView.isEnabled ).to.be.true;
+
+			editor.commands.get( 'link' ).isEnabled = false;
+			editor.commands.get( 'unlink' ).isEnabled = false;
+
+			expect( formView.urlInputView.isReadOnly ).to.be.true;
+			expect( formView.saveButtonView.isEnabled ).to.be.false;
+			expect( formView.cancelButtonView.isEnabled ).to.be.true;
+
+			expect( actionsView.unlinkButtonView.isEnabled ).to.be.false;
+			expect( actionsView.editButtonView.isEnabled ).to.be.false;
+		} );
+
+		// https://github.com/ckeditor/ckeditor5-link/issues/78
+		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (selected link)', () => {
+			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+
+			// Mock some leftover value **in DOM**, e.g. after previous editing.
+			formView.urlInputView.inputView.element.value = 'leftover';
+
+			linkUIFeature._showUI();
+			actionsView.fire( 'edit' );
+
+			expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' );
+		} );
+
+		// https://github.com/ckeditor/ckeditor5-link/issues/123
+		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => {
+			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+
+			linkUIFeature._showUI();
+			expect( formView.urlInputView.inputView.element.value ).to.equal( '' );
+		} );
+
+		describe( 'response to view#change', () => {
+			let view, viewDocument;
+
+			beforeEach( () => {
+				view = editor.editing.view;
+				viewDocument = view.document;
+			} );
+
+			it( 'should not duplicate #change listeners', () => {
+				setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+
+				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+
+				linkUIFeature._showUI();
+				view.render();
+				linkUIFeature._hideUI();
+
+				linkUIFeature._showUI();
+				view.render();
+				sinon.assert.calledTwice( spy );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'updates the position of the panel – editing a link, then the selection remains in the link', () => {
+				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+
+				linkUIFeature._showUI();
+				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+
+				expect( getViewData( view ) ).to.equal(
+					'<p><span class="ck-link_selected"><a href="url">f{}oo</a></span></p>'
+				);
+
+				const root = viewDocument.getRoot();
+				const linkElement = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
+				const text = linkElement.getChild( 0 );
+
+				// Move selection to foo[].
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true );
+				} );
+
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, {
+					target: view.domConverter.mapViewToDom( linkElement )
+				} );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'updates the position of the panel – creating a new link, then the selection moved', () => {
+				setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+
+				linkUIFeature._showUI();
+				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+
+				// Fires #render.
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 );
+
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true );
+				} );
+
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, {
+					target: editorElement.ownerDocument.getSelection().getRangeAt( 0 )
+				} );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'hides of the panel – editing a link, then the selection moved out of the link', () => {
+				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text>bar</paragraph>' );
+
+				linkUIFeature._showUI();
+
+				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+				const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 0 ).getChild( 1 );
+
+				// Move selection to b[]ar.
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 1 ), true );
+				} );
+
+				sinon.assert.calledOnce( spyHide );
+				sinon.assert.notCalled( spyUpdate );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'hides the panel – editing a link, then the selection expands', () => {
+				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+
+				linkUIFeature._showUI();
+
+				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+				const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+				expect( getViewData( view ) ).to.equal( '<p><span class="ck-link_selected"><a href="url">f{}oo</a></span></p>' );
+
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 );
+
+				// Move selection to f[o]o.
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
+				} );
+
+				sinon.assert.calledOnce( spyHide );
+				sinon.assert.notCalled( spyUpdate );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'hides the panel – creating a new link, then the selection moved to another parent', () => {
+				setModelData( editor.model, '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
+
+				linkUIFeature._showUI();
+
+				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+				const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 1 ).getChild( 0 );
+
+				// Move selection to f[o]o.
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
+				} );
+
+				sinon.assert.calledOnce( spyHide );
+				sinon.assert.notCalled( spyUpdate );
+			} );
+		} );
+	} );
+
+	describe( '_hideUI()', () => {
+		beforeEach( () => {
+			linkUIFeature._showUI();
+		} );
+
+		it( 'should remove the UI from the balloon', () => {
+			expect( balloon.hasView( formView ) ).to.be.true;
+			expect( balloon.hasView( actionsView ) ).to.be.true;
+
+			linkUIFeature._hideUI();
+
+			expect( balloon.hasView( formView ) ).to.be.false;
+			expect( balloon.hasView( actionsView ) ).to.be.false;
+		} );
+
+		it( 'should focus the `editable` by default', () => {
+			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+			linkUIFeature._hideUI();
+
+			// First call is from _removeFormView.
+			sinon.assert.calledTwice( spy );
+		} );
+
+		it( 'should not throw an error when views are not in the `balloon`', () => {
+			linkUIFeature._hideUI();
+
+			expect( () => {
+				linkUIFeature._hideUI();
+			} ).to.not.throw();
+		} );
+
+		it( 'should clear #render listener from the ViewDocument', () => {
+			const spy = sinon.spy();
+
+			linkUIFeature.listenTo( editor.editing.view, 'render', spy );
+			linkUIFeature._hideUI();
+			editor.editing.view.change( () => {} );
+
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'keyboard support', () => {
+		it( 'should show the UI on Ctrl+K keystroke', () => {
+			const spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} );
+			const command = editor.commands.get( 'link' );
+
+			command.isEnabled = false;
+
+			editor.keystrokes.press( {
+				keyCode: keyCodes.k,
+				ctrlKey: true,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+			sinon.assert.notCalled( spy );
+
+			command.isEnabled = true;
+
+			editor.keystrokes.press( {
+				keyCode: keyCodes.k,
+				ctrlKey: true,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+			sinon.assert.calledWithExactly( spy );
+		} );
+
+		it( 'should prevent default action on Ctrl+K keystroke', () => {
+			const preventDefaultSpy = sinon.spy();
+			const stopPropagationSpy = sinon.spy();
+
+			editor.keystrokes.press( {
+				keyCode: keyCodes.k,
+				ctrlKey: true,
+				preventDefault: preventDefaultSpy,
+				stopPropagation: stopPropagationSpy
+			} );
+
+			sinon.assert.calledOnce( preventDefaultSpy );
+			sinon.assert.calledOnce( stopPropagationSpy );
+		} );
+
+		it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => {
+			const keyEvtData = {
+				keyCode: keyCodes.tab,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			const normalPriorityTabCallbackSpy = sinon.spy();
+			const highestPriorityTabCallbackSpy = sinon.spy();
+			editor.keystrokes.set( 'Tab', normalPriorityTabCallbackSpy );
+			editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } );
+
+			// Balloon is invisible, form not focused.
+			actionsView.focusTracker.isFocused = false;
+
+			const spy = sinon.spy( actionsView, 'focus' );
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.notCalled( keyEvtData.preventDefault );
+			sinon.assert.notCalled( keyEvtData.stopPropagation );
+			sinon.assert.notCalled( spy );
+			sinon.assert.calledOnce( normalPriorityTabCallbackSpy );
+			sinon.assert.calledOnce( highestPriorityTabCallbackSpy );
+
+			// Balloon is visible, form focused.
+			linkUIFeature._showUI();
+			testUtils.sinon.stub( linkUIFeature, '_areActionsVisible' ).value( true );
+
+			actionsView.focusTracker.isFocused = true;
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.notCalled( keyEvtData.preventDefault );
+			sinon.assert.notCalled( keyEvtData.stopPropagation );
+			sinon.assert.notCalled( spy );
+			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
+			sinon.assert.calledTwice( highestPriorityTabCallbackSpy );
+
+			// Balloon is still visible, form not focused.
+			actionsView.focusTracker.isFocused = false;
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.calledOnce( keyEvtData.preventDefault );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
+			sinon.assert.calledThrice( highestPriorityTabCallbackSpy );
+		} );
+
+		it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+			const keyEvtData = {
+				keyCode: keyCodes.esc,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			// Balloon is visible.
+			linkUIFeature._showUI();
+			editor.keystrokes.press( keyEvtData );
+
+			sinon.assert.calledWithExactly( spy );
+		} );
+
+		it( 'should not hide the UI after Esc key press (from editor) when UI is open but is not visible', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+			const keyEvtData = {
+				keyCode: keyCodes.esc,
+				preventDefault: () => {},
+				stopPropagation: () => {}
+			};
+
+			const viewMock = {
+				ready: true,
+				render: () => {},
+				destroy: () => {}
+			};
+
+			linkUIFeature._showUI();
+
+			// Some view precedes the link UI in the balloon.
+			balloon.add( { view: viewMock } );
+			editor.keystrokes.press( keyEvtData );
+
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'mouse support', () => {
+		it( 'should hide the UI and not focus editable upon clicking outside the UI', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+			linkUIFeature._showUI( true );
+			document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+			sinon.assert.calledWithExactly( spy );
+		} );
+
+		it( 'should not hide the UI upon clicking inside the the UI', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+			linkUIFeature._showUI( true );
+			balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+			sinon.assert.notCalled( spy );
+		} );
+
+		describe( 'clicking on editable', () => {
+			let observer, spy;
+
+			beforeEach( () => {
+				observer = editor.editing.view.getObserver( ClickObserver );
+				editor.model.schema.extend( '$text', { allowIn: '$root' } );
+
+				spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} );
+			} );
+
+			it( 'should show the UI when collapsed selection is inside link element', () => {
+				setModelData( editor.model, '<$text linkHref="url">fo[]o</$text>' );
+
+				observer.fire( 'click', { target: document.body } );
+				sinon.assert.calledWithExactly( spy );
+			} );
+
+			it( 'should show the UI when selection exclusively encloses a link element (#1)', () => {
+				setModelData( editor.model, '[<$text linkHref="url">foo</$text>]' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.calledWithExactly( spy );
+			} );
+
+			it( 'should show the UI when selection exclusively encloses a link element (#2)', () => {
+				setModelData( editor.model, '<$text linkHref="url">[foo]</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.calledWithExactly( spy );
+			} );
+
+			it( 'should do nothing when selection is not inside link element', () => {
+				setModelData( editor.model, '[]' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => {
+				setModelData( editor.model, '<$text linkHref="url">f[o]o</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => {
+				setModelData( editor.model, '<$text linkHref="url">[fo]o</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => {
+				setModelData( editor.model, '<$text linkHref="url">f[oo]</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => {
+				setModelData( editor.model, 'ba[r<$text linkHref="url">foo]</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => {
+				setModelData( editor.model, 'ba[r<$text linkHref="url">foo</$text>]' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+		} );
+	} );
+
+	describe( 'actions view', () => {
+		let focusEditableSpy;
+
+		beforeEach( () => {
+			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+		} );
+
+		it( 'should mark the editor UI as focused when the #actionsView is focused', () => {
+			linkUIFeature._showUI();
+			linkUIFeature._removeFormView();
+
+			expect( balloon.visibleView ).to.equal( actionsView );
+
+			editor.ui.focusTracker.isFocused = false;
+			actionsView.element.dispatchEvent( new Event( 'focus' ) );
+
+			expect( editor.ui.focusTracker.isFocused ).to.be.true;
+		} );
+
+		describe( 'binding', () => {
+			it( 'should show the #formView on #edit event and select the URL input field', () => {
+				linkUIFeature._showUI();
+				linkUIFeature._removeFormView();
+
+				const selectSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
+				actionsView.fire( 'edit' );
+
+				expect( balloon.visibleView ).to.equal( formView );
+				sinon.assert.calledOnce( selectSpy );
+			} );
+
+			it( 'should execute unlink command on actionsView#unlink event', () => {
+				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+				actionsView.fire( 'unlink' );
+
+				expect( executeSpy.calledOnce ).to.be.true;
+				expect( executeSpy.calledWithExactly( 'unlink' ) ).to.be.true;
+			} );
+
+			it( 'should hide and focus editable on actionsView#unlink event', () => {
+				linkUIFeature._showUI();
+				linkUIFeature._removeFormView();
+
+				// Removing the form would call the focus spy.
+				focusEditableSpy.resetHistory();
+				actionsView.fire( 'unlink' );
+
+				expect( balloon.visibleView ).to.be.null;
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+
+			it( 'should hide after Esc key press', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				linkUIFeature._showUI();
+				linkUIFeature._removeFormView();
+
+				// Removing the form would call the focus spy.
+				focusEditableSpy.resetHistory();
+
+				actionsView.keystrokes.press( keyEvtData );
+				expect( balloon.visibleView ).to.equal( null );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( 'link form view', () => {
+		let focusEditableSpy;
+
+		beforeEach( () => {
+			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+		} );
+
+		it( 'should mark the editor UI as focused when the #formView is focused', () => {
+			linkUIFeature._showUI();
+			expect( balloon.visibleView ).to.equal( formView );
+
+			editor.ui.focusTracker.isFocused = false;
+			formView.element.dispatchEvent( new Event( 'focus' ) );
+
+			expect( editor.ui.focusTracker.isFocused ).to.be.true;
+		} );
+
+		describe( 'binding', () => {
+			beforeEach( () => {
+				setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			} );
+
+			it( 'should bind formView.urlInputView#value to link command value', () => {
+				const command = editor.commands.get( 'link' );
+
+				expect( formView.urlInputView.value ).to.undefined;
+
+				command.value = 'http://cksource.com';
+				expect( formView.urlInputView.value ).to.equal( 'http://cksource.com' );
+			} );
+
+			it( 'should execute link command on formView#submit event', () => {
+				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+				formView.urlInputView.value = 'http://ckeditor.com';
+				expect( formView.urlInputView.inputView.element.value ).to.equal( 'http://ckeditor.com' );
+
+				formView.urlInputView.inputView.element.value = 'http://cksource.com';
+				formView.fire( 'submit' );
+
+				expect( executeSpy.calledOnce ).to.be.true;
+				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true;
+			} );
+
+			it( 'should hide and reveal the #actionsView on formView#submit event', () => {
+				linkUIFeature._showUI();
+				formView.fire( 'submit' );
+
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+
+			it( 'should hide and reveal the #actionsView on formView#cancel event', () => {
+				linkUIFeature._showUI();
+				formView.fire( 'cancel' );
+
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+
+			it( 'should hide after Esc key press', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				linkUIFeature._showUI();
+
+				formView.keystrokes.press( keyEvtData );
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
+} );

+ 1 - 1
packages/ckeditor5-link/tests/manual/link.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 

+ 3 - 0
packages/ckeditor5-link/tests/manual/linkhighlight.html

@@ -0,0 +1,3 @@
+<div id="editor">
+	<p>foo <a href="http://ckeditor.com">linked text</a> baz</p>
+</div>

+ 25 - 0
packages/ckeditor5-link/tests/manual/linkhighlight.js

@@ -0,0 +1,25 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console:false, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Link from '../../src/link';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Link, Typing, Paragraph, Undo, Enter ],
+		toolbar: [ 'link', 'undo', 'redo' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 8 - 0
packages/ckeditor5-link/tests/manual/linkhighlight.md

@@ -0,0 +1,8 @@
+## Link highlight
+
+1. Put selection inside "linked text" - whole linked text should be highlighted.
+1. Move selection inside linked text, it should stay highlighted.
+1. Check if it works correctly with two-step caret movement when entering and leaving linked text.
+1. Use enter to split linked text, check if links are highlighted separately.
+
+NOTE: No additional undo steps should be added when preparing steps 1-3.

+ 10 - 14
packages/ckeditor5-link/tests/manual/tickets/113/1.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -8,8 +8,6 @@
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 
-import Element from '@ckeditor/ckeditor5-engine/src/model/element';
-import Text from '@ckeditor/ckeditor5-engine/src/model/text';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 
@@ -52,18 +50,17 @@ function wait( delay ) {
 }
 
 function startExternalInsert( editor ) {
-	const document = editor.document;
-	const bath = document.batch( 'transparent' );
+	const model = editor.model;
 
 	function type( path, text ) {
 		return new Promise( resolve => {
-			let position = new Position( document.getRoot(), path );
+			let position = new Position( model.document.getRoot(), path );
 			let index = 0;
 
 			function typing() {
 				wait( 40 ).then( () => {
-					document.enqueueChanges( () => {
-						bath.insert( position, new Text( text[ index ] ) );
+					model.enqueueChange( 'transparent', writer => {
+						writer.insertText( text[ index ], position );
 						position = position.getShiftedBy( 1 );
 
 						const nextLetter = text[ ++index ];
@@ -84,8 +81,8 @@ function startExternalInsert( editor ) {
 
 	function insertNewLine( path ) {
 		return wait( 200 ).then( () => {
-			document.enqueueChanges( () => {
-				bath.insert( new Position( document.getRoot(), path ), new Element( 'paragraph' ) );
+			model.enqueueChange( 'transparent', writer => {
+				writer.insertElement( 'paragraph', new Position( model.document.getRoot(), path ) );
 			} );
 
 			return Promise.resolve();
@@ -105,12 +102,11 @@ function startExternalInsert( editor ) {
 }
 
 function startExternalDelete( editor ) {
-	const document = editor.document;
-	const bath = document.batch( 'transparent' );
+	const model = editor.model;
 
 	wait( 3000 ).then( () => {
-		document.enqueueChanges( () => {
-			bath.remove( Range.createFromPositionAndShift( new Position( document.getRoot(), [ 1 ] ), 1 ) );
+		model.enqueueChange( 'transparent', writer => {
+			writer.remove( Range.createFromPositionAndShift( new Position( model.document.getRoot(), [ 1 ] ), 1 ) );
 		} );
 	} );
 }

+ 199 - 0
packages/ckeditor5-link/tests/ui/linkactionsview.js

@@ -0,0 +1,199 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import LinkActionsView from '../../src/ui/linkactionsview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+testUtils.createSinonSandbox();
+
+describe( 'LinkActionsView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new LinkActionsView( { t: val => val } );
+		view.render();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should create element from template', () => {
+			expect( view.element.classList.contains( 'ck-link-actions' ) ).to.true;
+			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
+		} );
+
+		it( 'should create child views', () => {
+			expect( view.previewButtonView ).to.be.instanceOf( View );
+			expect( view.unlinkButtonView ).to.be.instanceOf( View );
+			expect( view.editButtonView ).to.be.instanceOf( View );
+
+			expect( view._unboundChildren.get( 0 ) ).to.equal( view.previewButtonView );
+			expect( view._unboundChildren.get( 1 ) ).to.equal( view.editButtonView );
+			expect( view._unboundChildren.get( 2 ) ).to.equal( view.unlinkButtonView );
+		} );
+
+		it( 'should create #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'should create #keystrokes instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
+		it( 'should create #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+		} );
+
+		it( 'should create #_focusables view collection', () => {
+			expect( view._focusables ).to.be.instanceOf( ViewCollection );
+		} );
+
+		it( 'should fire `edit` event on editButtonView#execute', () => {
+			const spy = sinon.spy();
+
+			view.on( 'edit', spy );
+
+			view.editButtonView.fire( 'execute' );
+
+			expect( spy.calledOnce ).to.true;
+		} );
+
+		it( 'should fire `unlink` event on unlinkButtonView#execute', () => {
+			const spy = sinon.spy();
+
+			view.on( 'unlink', spy );
+
+			view.unlinkButtonView.fire( 'execute' );
+
+			expect( spy.calledOnce ).to.true;
+		} );
+
+		describe( 'preview button view', () => {
+			it( 'is an anchor', () => {
+				expect( view.previewButtonView.element.tagName.toLowerCase() ).to.equal( 'a' );
+			} );
+
+			it( 'has a CSS class', () => {
+				expect( view.previewButtonView.element.classList.contains( 'ck-link-actions__preview' ) ).to.be.true;
+			} );
+
+			it( 'has a target attribute', () => {
+				expect( view.previewButtonView.element.getAttribute( 'target' ) ).to.equal( '_blank' );
+			} );
+
+			describe( '<button> bindings', () => {
+				it( 'binds href DOM attribute to view#href', () => {
+					expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.be.null;
+
+					view.href = 'foo';
+
+					expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( 'foo' );
+				} );
+
+				it( 'binds #isEnabled to view#href', () => {
+					expect( view.previewButtonView.isEnabled ).to.be.false;
+
+					view.href = 'foo';
+
+					expect( view.previewButtonView.isEnabled ).to.be.true;
+				} );
+			} );
+		} );
+
+		describe( 'template', () => {
+			it( 'has child views', () => {
+				expect( view.template.children[ 0 ] ).to.equal( view.previewButtonView );
+				expect( view.template.children[ 1 ] ).to.equal( view.editButtonView );
+				expect( view.template.children[ 2 ] ).to.equal( view.unlinkButtonView );
+			} );
+		} );
+	} );
+
+	describe( 'render()', () => {
+		it( 'should register child views in #_focusables', () => {
+			expect( view._focusables.map( f => f ) ).to.have.members( [
+				view.previewButtonView,
+				view.editButtonView,
+				view.unlinkButtonView
+			] );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new LinkActionsView( { t: () => {} } );
+			view.render();
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.previewButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.editButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.unlinkButtonView.element );
+		} );
+
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new LinkActionsView( { t: () => {} } );
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			view.render();
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, view.element );
+		} );
+
+		describe( 'activates keyboard navigation for the toolbar', () => {
+			it( 'so "tab" focuses the next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the preview button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.previewButtonView.element;
+
+				const spy = sinon.spy( view.editButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "shift + tab" focuses the previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					shiftKey: true,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the edit button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.editButtonView.element;
+
+				const spy = sinon.spy( view.previewButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the #previewButtonView', () => {
+			const spy = sinon.spy( view.previewButtonView, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+} );

+ 4 - 25
packages/ckeditor5-link/tests/ui/linkformview.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -34,12 +34,10 @@ describe( 'LinkFormView', () => {
 			expect( view.urlInputView ).to.be.instanceOf( View );
 			expect( view.saveButtonView ).to.be.instanceOf( View );
 			expect( view.cancelButtonView ).to.be.instanceOf( View );
-			expect( view.unlinkButtonView ).to.be.instanceOf( View );
 
 			expect( view._unboundChildren.get( 0 ) ).to.equal( view.urlInputView );
 			expect( view._unboundChildren.get( 1 ) ).to.equal( view.saveButtonView );
 			expect( view._unboundChildren.get( 2 ) ).to.equal( view.cancelButtonView );
-			expect( view._unboundChildren.get( 3 ) ).to.equal( view.unlinkButtonView );
 		} );
 
 		it( 'should create #focusTracker instance', () => {
@@ -68,16 +66,6 @@ describe( 'LinkFormView', () => {
 			expect( spy.calledOnce ).to.true;
 		} );
 
-		it( 'should fire `unlink` event on unlinkButtonView#execute', () => {
-			const spy = sinon.spy();
-
-			view.on( 'unlink', spy );
-
-			view.unlinkButtonView.fire( 'execute' );
-
-			expect( spy.calledOnce ).to.true;
-		} );
-
 		describe( 'url input view', () => {
 			it( 'has placeholder', () => {
 				expect( view.urlInputView.inputView.placeholder ).to.equal( 'https://example.com' );
@@ -89,16 +77,9 @@ describe( 'LinkFormView', () => {
 				expect( view.template.children[ 0 ] ).to.equal( view.urlInputView );
 			} );
 
-			it( 'has form actions container', () => {
-				expect( view.template.children[ 1 ].attributes.class ).to.have.members( [ 'ck-link-form__actions' ] );
-			} );
-
-			it( 'has form action views', () => {
-				const actions = view.template.children[ 1 ].children;
-
-				expect( actions[ 0 ] ).to.equal( view.saveButtonView );
-				expect( actions[ 1 ] ).to.equal( view.cancelButtonView );
-				expect( actions[ 2 ] ).to.equal( view.unlinkButtonView );
+			it( 'has button views', () => {
+				expect( view.template.children[ 1 ] ).to.equal( view.saveButtonView );
+				expect( view.template.children[ 2 ] ).to.equal( view.cancelButtonView );
 			} );
 		} );
 	} );
@@ -109,7 +90,6 @@ describe( 'LinkFormView', () => {
 				view.urlInputView,
 				view.saveButtonView,
 				view.cancelButtonView,
-				view.unlinkButtonView
 			] );
 		} );
 
@@ -122,7 +102,6 @@ describe( 'LinkFormView', () => {
 			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element );
 			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element );
 			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element );
-			sinon.assert.calledWithExactly( spy.getCall( 3 ), view.unlinkButtonView.element );
 		} );
 
 		it( 'starts listening for #keystrokes coming from #element', () => {

+ 52 - 40
packages/ckeditor5-link/tests/unlinkcommand.js

@@ -1,5 +1,5 @@
 /**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  * For licensing, see LICENSE.md.
  */
 
@@ -11,21 +11,22 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 testUtils.createSinonSandbox();
 
 describe( 'UnlinkCommand', () => {
-	let editor, document, command;
+	let editor, model, document, command;
 
 	beforeEach( () => {
 		return ModelTestEditor.create()
 			.then( newEditor => {
 				editor = newEditor;
-				document = editor.document;
+				model = editor.model;
+				document = model.document;
 				command = new UnlinkCommand( editor );
 
-				// Allow text in $root.
-				document.schema.allow( { name: '$text', inside: '$root' } );
+				model.schema.extend( '$text', {
+					allowIn: '$root',
+					allowAttributes: 'linkHref'
+				} );
 
-				// Allow text with `linkHref` attribute in paragraph.
-				document.schema.registerItem( 'p', '$block' );
-				document.schema.allow( { name: '$text', attributes: 'linkHref', inside: '$root' } );
+				model.schema.register( 'p', { inheritAllFrom: '$block' } );
 			} );
 	} );
 
@@ -35,16 +36,16 @@ describe( 'UnlinkCommand', () => {
 
 	describe( 'isEnabled', () => {
 		it( 'should be true when selection has `linkHref` attribute', () => {
-			document.enqueueChanges( () => {
-				document.selection.setAttribute( 'linkHref', 'value' );
+			model.change( writer => {
+				writer.setSelectionAttribute( 'linkHref', 'value' );
 			} );
 
 			expect( command.isEnabled ).to.true;
 		} );
 
 		it( 'should be false when selection doesn\'t have `linkHref` attribute', () => {
-			document.enqueueChanges( () => {
-				document.selection.removeAttribute( 'linkHref' );
+			model.change( writer => {
+				writer.removeSelectionAttribute( 'linkHref' );
 			} );
 
 			expect( command.isEnabled ).to.false;
@@ -54,35 +55,46 @@ describe( 'UnlinkCommand', () => {
 	describe( 'execute()', () => {
 		describe( 'non-collapsed selection', () => {
 			it( 'should remove `linkHref` attribute from selected text', () => {
-				setData( document, '<$text linkHref="url">f[ooba]r</$text>' );
+				setData( model, '<$text linkHref="url">f[ooba]r</$text>' );
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal( '<$text linkHref="url">f</$text>[ooba]<$text linkHref="url">r</$text>' );
+				expect( getData( model ) ).to.equal( '<$text linkHref="url">f</$text>[ooba]<$text linkHref="url">r</$text>' );
 			} );
 
 			it( 'should remove `linkHref` attribute from selected text and do not modified other attributes', () => {
-				setData( document, '<$text bold="true" linkHref="url">f[ooba]r</$text>' );
+				setData( model, '<$text bold="true" linkHref="url">f[ooba]r</$text>' );
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal(
-					'<$text bold="true" linkHref="url">f</$text>' +
-					'[<$text bold="true">ooba</$text>]' +
-					'<$text bold="true" linkHref="url">r</$text>'
-				);
+				const assertAll = () => {
+					expect( getData( model ) ).to.equal(
+						'<$text bold="true" linkHref="url">f</$text>' +
+						'[<$text bold="true">ooba</$text>]' +
+						'<$text bold="true" linkHref="url">r</$text>'
+					);
+				};
+
+				const assertEdge = () => {
+					expect( getData( model ) ).to.equal(
+						'<$text bold="true" linkHref="url">f</$text>' +
+						'[<$text bold="true">ooba]<$text linkHref="url">r</$text></$text>'
+					);
+				};
+
+				testUtils.checkAssertions( assertAll, assertEdge );
 			} );
 
 			it( 'should remove `linkHref` attribute from selected text when attributes have different value', () => {
-				setData( document, '[<$text linkHref="url">foo</$text><$text linkHref="other url">bar</$text>]' );
+				setData( model, '[<$text linkHref="url">foo</$text><$text linkHref="other url">bar</$text>]' );
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal( '[foobar]' );
+				expect( getData( model ) ).to.equal( '[foobar]' );
 			} );
 
 			it( 'should remove `linkHref` attribute from selection', () => {
-				setData( document, '<$text linkHref="url">f[ooba]r</$text>' );
+				setData( model, '<$text linkHref="url">f[ooba]r</$text>' );
 
 				command.execute();
 
@@ -92,17 +104,17 @@ describe( 'UnlinkCommand', () => {
 
 		describe( 'collapsed selection', () => {
 			it( 'should remove `linkHref` attribute from selection siblings with the same attribute value', () => {
-				setData( document, '<$text linkHref="url">foo[]bar</$text>' );
+				setData( model, '<$text linkHref="url">foo[]bar</$text>' );
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal( 'foo[]bar' );
+				expect( getData( model ) ).to.equal( 'foo[]bar' );
 			} );
 
 			it( 'should remove `linkHref` attribute from selection siblings with the same attribute value and do not modify ' +
 				'other attributes', () => {
 				setData(
-					document,
+					model,
 					'<$text linkHref="other url">fo</$text>' +
 					'<$text linkHref="url">o[]b</$text>' +
 					'<$text linkHref="other url">ar</$text>'
@@ -110,7 +122,7 @@ describe( 'UnlinkCommand', () => {
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal(
+				expect( getData( model ) ).to.equal(
 					'<$text linkHref="other url">fo</$text>' +
 					'o[]b' +
 					'<$text linkHref="other url">ar</$text>'
@@ -120,7 +132,7 @@ describe( 'UnlinkCommand', () => {
 			it( 'should do nothing with nodes with the same `linkHref` value when there is a node with different value `linkHref` ' +
 				'attribute between', () => {
 				setData(
-					document,
+					model,
 					'<$text linkHref="same url">f</$text>' +
 					'<$text linkHref="other url">o</$text>' +
 					'<$text linkHref="same url">o[]b</$text>' +
@@ -130,7 +142,7 @@ describe( 'UnlinkCommand', () => {
 
 				command.execute();
 
-				expect( getData( document ) )
+				expect( getData( model ) )
 					.to.equal(
 						'<$text linkHref="same url">f</$text>' +
 						'<$text linkHref="other url">o</$text>' +
@@ -144,7 +156,7 @@ describe( 'UnlinkCommand', () => {
 				'and do nothing with other attributes',
 			() => {
 				setData(
-					document,
+					model,
 					'<$text linkHref="url">f</$text>' +
 					'<$text bold="true" linkHref="url">o</$text>' +
 					'<$text linkHref="url">o[]b</$text>' +
@@ -154,7 +166,7 @@ describe( 'UnlinkCommand', () => {
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal(
+				expect( getData( model ) ).to.equal(
 					'f' +
 					'<$text bold="true">o</$text>' +
 					'o[]b' +
@@ -165,7 +177,7 @@ describe( 'UnlinkCommand', () => {
 
 			it( 'should remove `linkHref` attribute from selection siblings only in the same parent as selection parent', () => {
 				setData(
-					document,
+					model,
 					'<p><$text linkHref="url">bar</$text></p>' +
 					'<p><$text linkHref="url">fo[]o</$text></p>' +
 					'<p><$text linkHref="url">bar</$text></p>'
@@ -173,7 +185,7 @@ describe( 'UnlinkCommand', () => {
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal(
+				expect( getData( model ) ).to.equal(
 					'<p><$text linkHref="url">bar</$text></p>' +
 					'<p>fo[]o</p>' +
 					'<p><$text linkHref="url">bar</$text></p>'
@@ -181,33 +193,33 @@ describe( 'UnlinkCommand', () => {
 			} );
 
 			it( 'should remove `linkHref` attribute from selection siblings when selection is at the end of link', () => {
-				setData( document, '<$text linkHref="url">foobar</$text>[]' );
+				setData( model, '<$text linkHref="url">foobar</$text>[]' );
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal( 'foobar[]' );
+				expect( getData( model ) ).to.equal( 'foobar[]' );
 			} );
 
 			it( 'should remove `linkHref` attribute from selection siblings when selection is at the beginning of link', () => {
-				setData( document, '[]<$text linkHref="url">foobar</$text>' );
+				setData( model, '[]<$text linkHref="url">foobar</$text>' );
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal( '[]foobar' );
+				expect( getData( model ) ).to.equal( '[]foobar' );
 			} );
 
 			it( 'should remove `linkHref` attribute from selection siblings on the left side when selection is between two elements with ' +
 				'different `linkHref` attributes',
 			() => {
-				setData( document, '<$text linkHref="url">foo</$text>[]<$text linkHref="other url">bar</$text>' );
+				setData( model, '<$text linkHref="url">foo</$text>[]<$text linkHref="other url">bar</$text>' );
 
 				command.execute();
 
-				expect( getData( document ) ).to.equal( 'foo[]<$text linkHref="other url">bar</$text>' );
+				expect( getData( model ) ).to.equal( 'foo[]<$text linkHref="other url">bar</$text>' );
 			} );
 
 			it( 'should remove `linkHref` attribute from selection', () => {
-				setData( document, '<$text linkHref="url">foo[]bar</$text>' );
+				setData( model, '<$text linkHref="url">foo[]bar</$text>' );
 
 				command.execute();
 

+ 45 - 0
packages/ckeditor5-link/tests/utils.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document';
+import ViewWriter from '@ckeditor/ckeditor5-engine/src/view/writer';
+import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement';
+import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
+import Text from '@ckeditor/ckeditor5-engine/src/view/text';
+
+import { createLinkElement, isLinkElement } from '../src/utils';
+
+describe( 'utils', () => {
+	describe( 'isLinkElement', () => {
+		it( 'should return true for elements created by createLinkElement', () => {
+			const element = createLinkElement( 'http://ckeditor.com', new ViewWriter( new ViewDocument() ) );
+
+			expect( isLinkElement( element ) ).to.be.true;
+		} );
+
+		it( 'should return false for other AttributeElements', () => {
+			expect( isLinkElement( new AttributeElement( 'a' ) ) ).to.be.false;
+		} );
+
+		it( 'should return false for ContainerElements', () => {
+			expect( isLinkElement( new ContainerElement( 'p' ) ) ).to.be.false;
+		} );
+
+		it( 'should return false for text nodes', () => {
+			expect( isLinkElement( new Text( 'foo' ) ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'createLinkElement', () => {
+		it( 'should create link AttributeElement', () => {
+			const element = createLinkElement( 'http://cksource.com', new ViewWriter( new ViewDocument() ) );
+
+			expect( isLinkElement( element ) ).to.be.true;
+			expect( element.priority ).to.equal( 5 );
+			expect( element.getAttribute( 'href' ) ).to.equal( 'http://cksource.com' );
+			expect( element.name ).to.equal( 'a' );
+		} );
+	} );
+} );

+ 0 - 47
packages/ckeditor5-link/theme/components/linkform.scss

@@ -1,47 +0,0 @@
-// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
-// For licensing, see LICENSE.md or http://ckeditor.com/license
-
-@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_spacing';
-
-.ck-link {
-	&-form {
-		padding: ck-spacing( 'large' );
-		overflow: hidden;
-
-		&:focus {
-			// https://github.com/ckeditor/ckeditor5-link/issues/90
-			outline: none;
-		}
-
-		.ck-input-text {
-			// https://github.com/ckeditor/ckeditor5-link/issues/145
-			width: 100%;
-		}
-
-		.ck-label {
-			margin-bottom: ck-spacing( 'tiny' );
-		}
-
-		&__actions {
-			clear: both;
-			padding-top: ck-spacing( 'large' );
-
-			.ck-button {
-				float: right;
-
-				// "Save" and "Cancel" buttons.
-				& + .ck-button {
-					margin-right: ck-spacing( 'medium' );
-				}
-
-				// The "Unlink" button.
-				&:last-child {
-					float: left;
-
-					// https://github.com/ckeditor/ckeditor5-link/issues/145
-					margin-right: 4 * ck-spacing( 'medium' );
-				}
-			}
-		}
-	}
-}

+ 1 - 1
packages/ckeditor5-link/theme/icons/link.svg

@@ -1 +1 @@
-<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#222" fill-rule="evenodd"><path d="M14.2 10.956l1.227-1.227a3.995 3.995 0 0 0-.002-5.654 4 4 0 0 0-5.654-.002L7.698 6.145a3.995 3.995 0 0 0 .003 5.654c.39.39.84.682 1.32.878l-.305-.307.638-.638a2.99 2.99 0 0 1-.946-.64 2.995 2.995 0 0 1-.003-4.24l2.073-2.072a3 3 0 0 1 4.242 4.242l-1.226 1.227.707.707z"/><path d="M10.166 7.405c.41.192.795.457 1.133.796a3.995 3.995 0 0 1 .003 5.654l-2.073 2.072a4 4 0 0 1-5.654-.002 3.995 3.995 0 0 1-.002-5.654l1.362-1.363.707.707-1.362 1.363a3 3 0 0 0 4.243 4.243l2.072-2.073a2.995 2.995 0 0 0-.003-4.24 2.987 2.987 0 0 0-1.196-.733l.77-.77z"/></g></svg>
+<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.077 15l.991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955c.02-.095.06-.189.12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z" fill="#000" fill-rule="evenodd"/></svg>

+ 1 - 1
packages/ckeditor5-link/theme/icons/unlink.svg

@@ -1 +1 @@
-<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#222" fill-rule="evenodd"><path d="M14.2 10.956l1.227-1.227a3.995 3.995 0 0 0-.002-5.654 4 4 0 0 0-5.654-.002L7.698 6.145a3.995 3.995 0 0 0 .003 5.654c.39.39.84.682 1.32.878l-.305-.307.638-.638a2.99 2.99 0 0 1-.946-.64 2.995 2.995 0 0 1-.003-4.24l2.073-2.072a3 3 0 0 1 4.242 4.242l-1.226 1.227.707.707z"/><path d="M14.906 16.092l2.557-2.557-.722-.721-2.556 2.556-2.345-2.345-.722.721 2.345 2.346-2.556 2.556.721.722 2.557-2.557 2.768 2.768.721-.722-2.768-2.767zm-2.513-3.999A4.067 4.067 0 0 0 10.148 7.5l-.794.794c.448.142.869.39 1.223.745a3.054 3.054 0 0 1 .758 3.055h1.058zm-2.204 3.104l-1.049 1.05c-1.609 1.608-4.208 1.62-5.8.028-1.595-1.595-1.584-4.188.028-5.8l1.406-1.406.721.722-1.405 1.405c-1.21 1.21-1.216 3.154-.02 4.35 1.192 1.192 3.142 1.186 4.349-.021l1.77-1.771v1.443z"/></g></svg>
+<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#000" fill-rule="evenodd"><path d="M11.077 15l.991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955c.02-.095.06-.189.12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184zM16.927 17.695l-1.414 1.414a.75.75 0 1 1-1.06-1.06l1.414-1.415-1.415-1.414a.75.75 0 0 1 1.061-1.06l1.414 1.414 1.414-1.415a.75.75 0 0 1 1.061 1.061l-1.414 1.414 1.414 1.415a.75.75 0 0 1-1.06 1.06l-1.415-1.414z"/></g></svg>

+ 10 - 0
packages/ckeditor5-link/theme/link.css

@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/*
+ * Note: This file should contain the wireframe styles only. But since there are no such styles,
+ * it acts as a message to the builder telling that it should look for the corresponding styles
+ * **in the theme** when compiling the editor.
+ */

+ 14 - 0
packages/ckeditor5-link/theme/linkactions.css

@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck-link-actions {
+	& .ck-link-actions__preview {
+		display: inline-block;
+
+		& .ck-button__label {
+			overflow: hidden;
+		}
+	}
+}

+ 14 - 0
packages/ckeditor5-link/theme/linkform.css

@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck-link-form {
+	& .ck-labeled-input {
+		display: inline-block;
+	}
+
+	& .ck-label {
+		display: none;
+	}
+}

+ 0 - 4
packages/ckeditor5-link/theme/theme.scss

@@ -1,4 +0,0 @@
-// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
-// For licensing, see LICENSE.md or http://ckeditor.com/license
-
-@import './components/linkform';