From 699d7b14ace6a2bf150b2bf821f62163f23db0da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 13 Mar 2021 16:05:40 -0600 Subject: [PATCH] Concept: Fork @ckeditor/ckeditor5-media-embed to comply with custom element standard See also: - 5f7b5543d058768252b3a42d398338425abb2682 - https://github.com/ckeditor/ckeditor5/issues/2737 - https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define - https://github.com/tony/oembed-component - https://www.npmjs.com/package/react --- .../CHANGELOG.md | 129 +++ .../CONTRIBUTING.md | 4 + .../ckeditor5-media-embed-eduflow/LICENSE.md | 17 + .../ckeditor5-media-embed-eduflow/README.md | 20 + .../features/build-media-source.html | 0 .../_snippets/features/build-media-source.js | 13 + .../docs/_snippets/features/media-embed.html | 37 + .../docs/_snippets/features/media-embed.js | 29 + .../docs/api/media-embed.md | 34 + .../img/features-media-embed-ckeditor.png | Bin 0 -> 100647 bytes .../docs/features/media-embed.md | 450 ++++++++ .../lang/contexts.json | 10 + .../lang/translations/ar.po | 49 + .../lang/translations/az.po | 49 + .../lang/translations/bg.po | 49 + .../lang/translations/cs.po | 49 + .../lang/translations/da.po | 49 + .../lang/translations/de.po | 49 + .../lang/translations/en-au.po | 49 + .../lang/translations/en-gb.po | 49 + .../lang/translations/en.po | 49 + .../lang/translations/es.po | 49 + .../lang/translations/et.po | 49 + .../lang/translations/fa.po | 49 + .../lang/translations/fi.po | 49 + .../lang/translations/fr.po | 49 + .../lang/translations/gl.po | 49 + .../lang/translations/hi.po | 49 + .../lang/translations/hr.po | 49 + .../lang/translations/hu.po | 49 + .../lang/translations/id.po | 49 + .../lang/translations/it.po | 49 + .../lang/translations/ja.po | 49 + .../lang/translations/ko.po | 49 + .../lang/translations/ku.po | 49 + .../lang/translations/lt.po | 49 + .../lang/translations/lv.po | 49 + .../lang/translations/ne.po | 49 + .../lang/translations/nl.po | 49 + .../lang/translations/no.po | 49 + .../lang/translations/pl.po | 49 + .../lang/translations/pt-br.po | 49 + .../lang/translations/ro.po | 49 + .../lang/translations/ru.po | 49 + .../lang/translations/sk.po | 49 + .../lang/translations/sq.po | 49 + .../lang/translations/sr-latn.po | 49 + .../lang/translations/sr.po | 49 + .../lang/translations/sv.po | 49 + .../lang/translations/tk.po | 49 + .../lang/translations/tr.po | 49 + .../lang/translations/uk.po | 49 + .../lang/translations/vi.po | 49 + .../lang/translations/zh-cn.po | 49 + .../lang/translations/zh.po | 49 + .../package.json | 59 ++ .../src/automediaembed.js | 179 ++++ .../src/converters.js | 59 ++ .../src/index.js | 22 + .../src/mediaembed.js | 264 +++++ .../src/mediaembedcommand.js | 92 ++ .../src/mediaembedediting.js | 249 +++++ .../src/mediaembedtoolbar.js | 61 ++ .../src/mediaembedui.js | 138 +++ .../src/mediaregistry.js | 335 ++++++ .../src/ui/mediaformview.js | 341 ++++++ .../src/utils.js | 119 +++ .../tests/automediaembed.js | 643 ++++++++++++ .../tests/insertmediacommand.js | 148 +++ .../tests/integration.js | 65 ++ .../tests/manual/mediaembed.html | 58 + .../tests/manual/mediaembed.js | 29 + .../tests/manual/mediaembed.md | 54 + .../tests/manual/semanticmediaembed.html | 58 + .../tests/manual/semanticmediaembed.js | 24 + .../tests/manual/semanticmediaembed.md | 47 + .../tests/mediaembed.js | 59 ++ .../tests/mediaembedediting.js | 990 ++++++++++++++++++ .../tests/mediaembedtoolbar.js | 274 +++++ .../tests/mediaembedui.js | 263 +++++ .../tests/mediaregistry.js | 125 +++ .../tests/ui/mediaformview.js | 319 ++++++ .../theme/icons/media-placeholder.svg | 1 + .../theme/icons/media.svg | 1 + .../theme/icons/media/twitter.svg | 20 + .../theme/mediaembed.css | 21 + .../theme/mediaembedediting.css | 53 + .../theme/mediaform.css | 33 + .../webpack.config.js | 17 + 89 files changed, 8070 insertions(+) create mode 100644 packages/ckeditor5-media-embed-eduflow/CHANGELOG.md create mode 100644 packages/ckeditor5-media-embed-eduflow/CONTRIBUTING.md create mode 100644 packages/ckeditor5-media-embed-eduflow/LICENSE.md create mode 100644 packages/ckeditor5-media-embed-eduflow/README.md create mode 100644 packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/build-media-source.html create mode 100644 packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/build-media-source.js create mode 100644 packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.html create mode 100644 packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.js create mode 100644 packages/ckeditor5-media-embed-eduflow/docs/api/media-embed.md create mode 100644 packages/ckeditor5-media-embed-eduflow/docs/assets/img/features-media-embed-ckeditor.png create mode 100644 packages/ckeditor5-media-embed-eduflow/docs/features/media-embed.md create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/contexts.json create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/ar.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/az.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/bg.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/cs.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/da.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/de.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/en-au.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/en-gb.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/en.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/es.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/et.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/fa.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/fi.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/fr.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/gl.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/hi.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/hr.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/hu.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/id.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/it.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/ja.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/ko.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/ku.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/lt.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/lv.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/ne.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/nl.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/no.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/pl.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/pt-br.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/ro.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/ru.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/sk.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/sq.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/sr-latn.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/sr.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/sv.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/tk.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/tr.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/uk.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/vi.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/zh-cn.po create mode 100644 packages/ckeditor5-media-embed-eduflow/lang/translations/zh.po create mode 100644 packages/ckeditor5-media-embed-eduflow/package.json create mode 100644 packages/ckeditor5-media-embed-eduflow/src/automediaembed.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/converters.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/index.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/mediaembed.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/mediaembedcommand.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/mediaembedediting.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/mediaembedtoolbar.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/mediaembedui.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/mediaregistry.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/ui/mediaformview.js create mode 100644 packages/ckeditor5-media-embed-eduflow/src/utils.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/automediaembed.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/insertmediacommand.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/integration.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.html create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.md create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.html create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.md create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/mediaembed.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/mediaembedediting.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/mediaembedtoolbar.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/mediaembedui.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/mediaregistry.js create mode 100644 packages/ckeditor5-media-embed-eduflow/tests/ui/mediaformview.js create mode 100644 packages/ckeditor5-media-embed-eduflow/theme/icons/media-placeholder.svg create mode 100644 packages/ckeditor5-media-embed-eduflow/theme/icons/media.svg create mode 100755 packages/ckeditor5-media-embed-eduflow/theme/icons/media/twitter.svg create mode 100644 packages/ckeditor5-media-embed-eduflow/theme/mediaembed.css create mode 100644 packages/ckeditor5-media-embed-eduflow/theme/mediaembedediting.css create mode 100644 packages/ckeditor5-media-embed-eduflow/theme/mediaform.css create mode 100644 packages/ckeditor5-media-embed-eduflow/webpack.config.js diff --git a/packages/ckeditor5-media-embed-eduflow/CHANGELOG.md b/packages/ckeditor5-media-embed-eduflow/CHANGELOG.md new file mode 100644 index 00000000000..47ef77bee4d --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/CHANGELOG.md @@ -0,0 +1,129 @@ +Changelog +========= + +All changes in the package are documented in the main repository. See: https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md. + +Changes for the past releases are available below. + +## [19.0.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v18.0.0...v19.0.0) (2020-04-29) + +### Other changes + +* Replaced `LabeledInputView` with `LabeledFieldView`. See [ckeditor/ckeditor5#6110](https://github.com/ckeditor/ckeditor5/issues/6110). ([bc00c8b](https://github.com/ckeditor/ckeditor5-media-embed/commit/bc00c8b)) +* Updated translations. ([8ac4d13](https://github.com/ckeditor/ckeditor5-media-embed/commit/8ac4d13)) + + +## [18.0.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v17.0.0...v18.0.0) (2020-03-19) + +Internal changes only (updated dependencies, documentation, etc.). + + +## [17.0.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v16.0.0...v17.0.0) (2020-02-19) + +### Other changes + +* Updated translations. ([7c9dca4](https://github.com/ckeditor/ckeditor5-media-embed/commit/7c9dca4)) + + +## [16.0.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v15.0.0...v16.0.0) (2019-12-04) + +### Other changes + +* Updated translations. ([47b693f](https://github.com/ckeditor/ckeditor5-media-embed/commit/47b693f)) + + +## [15.0.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v11.1.4...v15.0.0) (2019-10-23) + +### Other changes + +* Updated translations. ([dd2b7aa](https://github.com/ckeditor/ckeditor5-media-embed/commit/dd2b7aa)) ([f3ef359](https://github.com/ckeditor/ckeditor5-media-embed/commit/f3ef359)) + + +## [11.1.4](https://github.com/ckeditor/ckeditor5-media-embed/compare/v11.1.3...v11.1.4) (2019-08-26) + +### Other changes + +* The issue tracker for this package was moved to https://github.com/ckeditor/ckeditor5/issues. See [ckeditor/ckeditor5#1988](https://github.com/ckeditor/ckeditor5/issues/1988). ([c139f62](https://github.com/ckeditor/ckeditor5-media-embed/commit/c139f62)) +* The media widget toolbar should have a proper `aria-label` attribute (see [ckeditor/ckeditor5#1404](https://github.com/ckeditor/ckeditor5/issues/1404)). ([7a95064](https://github.com/ckeditor/ckeditor5-media-embed/commit/7a95064)) +* Updated translations. ([bc7f4eb](https://github.com/ckeditor/ckeditor5-media-embed/commit/bc7f4eb)) + + +## [11.1.3](https://github.com/ckeditor/ckeditor5-media-embed/compare/v11.1.2...v11.1.3) (2019-07-10) + +Internal changes only (updated dependencies, documentation, etc.). + + +## [11.1.2](https://github.com/ckeditor/ckeditor5-media-embed/compare/v11.1.1...v11.1.2) (2019-07-04) + +### Bug fixes + +* Clicking a media preview link should not open a new browser tab unless the media is already selected. Closes [#18](https://github.com/ckeditor/ckeditor5-media-embed/issues/18). ([12bd564](https://github.com/ckeditor/ckeditor5-media-embed/commit/12bd564) + +### Other changes + +* Updated translations. ([dee6c82](https://github.com/ckeditor/ckeditor5-media-embed/commit/dee6c82)) ([404ae2f](https://github.com/ckeditor/ckeditor5-media-embed/commit/404ae2f)) + + +## [11.1.1](https://github.com/ckeditor/ckeditor5-media-embed/compare/v11.1.0...v11.1.1) (2019-06-05) + +### Other changes + +* Updated translations. ([075d13f](https://github.com/ckeditor/ckeditor5-media-embed/commit/075d13f)) + + +## [11.1.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v11.0.0...v11.1.0) (2019-04-10) + +### Features + +* Added support for mobile YouTube URLs in the default providers configuration. Closes [#82](https://github.com/ckeditor/ckeditor5-media-embed/issues/82). ([9e5cf21](https://github.com/ckeditor/ckeditor5-media-embed/commit/9e5cf21)) + +### Other changes + +* Optimized icons. ([05c3ba0](https://github.com/ckeditor/ckeditor5-media-embed/commit/05c3ba0)) +* Updated translations. ([53e6137](https://github.com/ckeditor/ckeditor5-media-embed/commit/53e6137)) + + +## [11.0.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v10.1.0...v11.0.0) (2019-02-28) + +### Bug fixes + +* Added `min-width` to `.ck-content .media` to allow integration with tables. Closes [#44](https://github.com/ckeditor/ckeditor5-media-embed/issues/44). ([01129fe](https://github.com/ckeditor/ckeditor5-media-embed/commit/01129fe)) +* Media embed figures should have `display: block` in the content styles to prevent Bootstrap from changing their appearance. Closes [ckeditor/ckeditor5#1373](https://github.com/ckeditor/ckeditor5/issues/1373). ([71b2933](https://github.com/ckeditor/ckeditor5-media-embed/commit/71b2933)) +* The `AutoMediaEmbed` feature should insert media in place of a pasted link. Closes [#36](https://github.com/ckeditor/ckeditor5-media-embed/issues/36). Closes [#49](https://github.com/ckeditor/ckeditor5-media-embed/issues/49). ([5f79878](https://github.com/ckeditor/ckeditor5-media-embed/commit/5f79878)) + +### Other changes + +* Aligned to the new `WidgetToolbarRepository` API. Replaced the `isMediaWidgetSelected()` utility with `getSelectedMediaViewWidget()`. Renamed `getSelectedMediaElement()` to `getSelectedMediaModelWidget()`. (see [ckeditor/ckeditor5-widget#60](https://github.com/ckeditor/ckeditor5-widget/issues/60)). ([dc89e45](https://github.com/ckeditor/ckeditor5-media-embed/commit/dc89e45)) +* The help text under the media URL input should be displayed when it's empty. The quick insertion tip should pop out when the user started typing in the input (see [#5](https://github.com/ckeditor/ckeditor5-media-embed/issues/5)). ([55396b5](https://github.com/ckeditor/ckeditor5-media-embed/commit/55396b5)) +* Updated translations. ([a07783b](https://github.com/ckeditor/ckeditor5-media-embed/commit/a07783b)) ([f7942b5](https://github.com/ckeditor/ckeditor5-media-embed/commit/f7942b5)) ([5315b1a](https://github.com/ckeditor/ckeditor5-media-embed/commit/5315b1a)) + +### BREAKING CHANGES + +* Upgraded minimal versions of Node to `8.0.0` and npm to `5.7.1`. See: [ckeditor/ckeditor5#1507](https://github.com/ckeditor/ckeditor5/issues/1507). ([612ea3c](https://github.com/ckeditor/ckeditor5-cloud-services/commit/612ea3c)) +* The `isMediaWidgetSelected()` utility has been replaced by `getSelectedMediaViewWidget()` and returns an editing `View` element instead of a `Boolean`. +* The `getSelectedMediaElement()` utility has been renamed to `getSelectedMediaModelWidget()`. + + +## [10.1.0](https://github.com/ckeditor/ckeditor5-media-embed/compare/v10.0.0...v10.1.0) (2018-12-05) + +### Features + +* Implemented a tip in the form that helps users discover the auto embedding. Closes [#35](https://github.com/ckeditor/ckeditor5-media-embed/issues/35). ([ebdec7e](https://github.com/ckeditor/ckeditor5-media-embed/commit/ebdec7e)) +* Improved responsiveness of the media form view in narrow viewports (see [ckeditor/ckeditor5#416](https://github.com/ckeditor/ckeditor5/issues/416)). ([c753463](https://github.com/ckeditor/ckeditor5-media-embed/commit/c753463)) + +### Bug fixes + +* Floated content and media widgets should not overlap. Closes [#53](https://github.com/ckeditor/ckeditor5-media-embed/issues/53). ([d3aa6e8](https://github.com/ckeditor/ckeditor5-media-embed/commit/d3aa6e8)) +* Made the media interactive when the editor is in the read-only mode (just like links). Closes [#58](https://github.com/ckeditor/ckeditor5-media-embed/issues/58). ([09c387a](https://github.com/ckeditor/ckeditor5-media-embed/commit/09c387a)) +* The `AutoMediaEmbed` should not upcast the pasted URL if a media element is disallowed at the current selection. Closes [#47](https://github.com/ckeditor/ckeditor5-media-embed/issues/47). ([47092c6](https://github.com/ckeditor/ckeditor5-media-embed/commit/47092c6)) + +### Other changes + +* Improved SVG icons size. See [ckeditor/ckeditor5-theme-lark#206](https://github.com/ckeditor/ckeditor5-theme-lark/issues/206). ([b95fc42](https://github.com/ckeditor/ckeditor5-media-embed/commit/b95fc42)) +* Moved widget spacing styles from `@ckeditor/ckeditor5-theme-lark` to the feature content styles sheet (see [ckeditor/ckeditor5-theme-lark#209](https://github.com/ckeditor/ckeditor5-theme-lark/issues/209)). ([501a567](https://github.com/ckeditor/ckeditor5-media-embed/commit/501a567)) +* Updated translations. ([58614b0](https://github.com/ckeditor/ckeditor5-media-embed/commit/58614b0)) ([120912c](https://github.com/ckeditor/ckeditor5-media-embed/commit/120912c)) ([e1b4206](https://github.com/ckeditor/ckeditor5-media-embed/commit/e1b4206)) ([c376e7f](https://github.com/ckeditor/ckeditor5-media-embed/commit/c376e7f)) + + +## [10.0.0](https://github.com/ckeditor/ckeditor5-media-embed/tree/v10.0.0) (2018-10-08) + +Initial implementation of the media embed feature. diff --git a/packages/ckeditor5-media-embed-eduflow/CONTRIBUTING.md b/packages/ckeditor5-media-embed-eduflow/CONTRIBUTING.md new file mode 100644 index 00000000000..95e8a028382 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/CONTRIBUTING.md @@ -0,0 +1,4 @@ +Contributing +======================================== + +See the [official contributors' guide to CKEditor 5](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html) to learn more. diff --git a/packages/ckeditor5-media-embed-eduflow/LICENSE.md b/packages/ckeditor5-media-embed-eduflow/LICENSE.md new file mode 100644 index 00000000000..d78c10794fd --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/LICENSE.md @@ -0,0 +1,17 @@ +Software License Agreement +========================== + +**CKEditor 5 Media Embed Feature** – https://github.com/ckeditor/ckeditor5-media-embed
+Copyright (c) 2003-2021, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved. + +Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). + +Sources of Intellectual Property Included in CKEditor +----------------------------------------------------- + +Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission. + +Trademarks +---------- + +**CKEditor** is a trademark of [CKSource](http://cksource.com) Frederico Knabben. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders. diff --git a/packages/ckeditor5-media-embed-eduflow/README.md b/packages/ckeditor5-media-embed-eduflow/README.md new file mode 100644 index 00000000000..b25e11560ca --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/README.md @@ -0,0 +1,20 @@ +CKEditor 5 media embed feature +======================================== + +[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-media-embed.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-media-embed) +[![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-media-embed/status.svg)](https://david-dm.org/ckeditor/ckeditor5-media-embed) +[![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-media-embed/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-media-embed?type=dev) + +This package implements the media embed feature for CKEditor 5. You can use it to insert embeddable media such as YouTube or Vimeo videos and tweets into your rich text content. + +## Demo + +Check out the [demo in the Media embed feature](https://ckeditor.com/docs/ckeditor5/latest/features/media-embed.html#demo) guide. + +## Documentation + +See the [`@ckeditor/ckeditor5-media-embed` package](https://ckeditor.com/docs/ckeditor5/latest/api/media-embed.html) page in [CKEditor 5 documentation](https://ckeditor.com/docs/ckeditor5/latest/). + +## License + +Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). For full details about the license, please check the `LICENSE.md` file or [https://ckeditor.com/legal/ckeditor-oss-license](https://ckeditor.com/legal/ckeditor-oss-license). diff --git a/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/build-media-source.html b/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/build-media-source.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/build-media-source.js b/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/build-media-source.js new file mode 100644 index 00000000000..d4d4f9d8c50 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/build-media-source.js @@ -0,0 +1,13 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window */ + +import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor'; +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed'; + +ClassicEditor.builtinPlugins.push( MediaEmbed ); + +window.ClassicEditor = ClassicEditor; diff --git a/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.html b/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.html new file mode 100644 index 00000000000..9703c44c4dc --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.html @@ -0,0 +1,37 @@ +
+

YouTube

+ +
+ +
+ +

Vimeo

+ +
+ +
+ +

Twitter

+ +
+ +
+ +

Google Maps

+ +
+ +
+
+ + diff --git a/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.js b/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.js new file mode 100644 index 00000000000..b2df061f40b --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/docs/_snippets/features/media-embed.js @@ -0,0 +1,29 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals ClassicEditor, console, window, document */ + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +ClassicEditor + .create( document.querySelector( '#snippet-media-embed' ), { + cloudServices: CS_CONFIG, + toolbar: { + viewportTopOffset: window.getViewportTopOffsetConfig() + } + } ) + .then( editor => { + window.editor = editor; + + window.attachTourBalloon( { + target: window.findToolbarItem( editor.ui.view.toolbar, + item => item.buttonView && item.buttonView.label && item.buttonView.label === 'Insert media' ), + text: 'Click to embed media.', + editor + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-media-embed-eduflow/docs/api/media-embed.md b/packages/ckeditor5-media-embed-eduflow/docs/api/media-embed.md new file mode 100644 index 00000000000..f61bdf604e0 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/docs/api/media-embed.md @@ -0,0 +1,34 @@ +--- +category: api-reference +--- + +# Media embed feature for CKEditor 5 + +[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-media-embed.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-media-embed) + +This package implements the media embed feature for CKEditor 5. You can use it to insert embeddable media such as YouTube or Vimeo videos and tweets into your rich-text content. + +## Demo + +Check out the {@link features/media-embed#demo demo in the Media embed feature} guide. + +## Documentation + +See the {@link features/media-embed Media embed feature} guide and the {@link module:media-embed/mediaembed~MediaEmbed} plugin documentation. + +## Installation + +```bash +npm install --save @ckeditor/ckeditor5-media-embed +``` + +## Contribute + +The source code of this package is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-media-embed. + +## External links + +* [`@ckeditor/ckeditor5-media-embed` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-media-embed) +* [`ckeditor/ckeditor5-media-embed` on GitHub](https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-media-embed) +* [Issue tracker](https://github.com/ckeditor/ckeditor5/issues) +* [Changelog](https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md) diff --git a/packages/ckeditor5-media-embed-eduflow/docs/assets/img/features-media-embed-ckeditor.png b/packages/ckeditor5-media-embed-eduflow/docs/assets/img/features-media-embed-ckeditor.png new file mode 100644 index 0000000000000000000000000000000000000000..c68e0d5e351877940260a360a14233faee166aac GIT binary patch literal 100647 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 PK`uFIrLREAAHV(=P{1NY literal 0 HcmV?d00001 diff --git a/packages/ckeditor5-media-embed-eduflow/docs/features/media-embed.md b/packages/ckeditor5-media-embed-eduflow/docs/features/media-embed.md new file mode 100644 index 00000000000..931c5442e7b --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/docs/features/media-embed.md @@ -0,0 +1,450 @@ +--- +category: features +--- + +{@snippet features/build-media-source} + +# Media embed + +The {@link module:media-embed/mediaembed~MediaEmbed} feature brings support for inserting embeddable media such as YouTube or Vimeo videos and tweets into your rich text content. + +## Demo + +You can use the "Insert media" button in the toolbar to embed media like in the following examples. You can also paste the media URL directly into the editor content and it will be [automatically embedded](#automatic-media-embed-on-paste). + +* +* +* + +{@snippet features/media-embed} + +## Installation + + + This feature is enabled by default in all builds. The installation instructions are for developers interested in building their own, custom editor. + + +To add this feature to your editor, install the [`@ckeditor/ckeditor5-media-embed`](https://www.npmjs.com/package/@ckeditor/ckeditor5-media-embed) package: + +```bash +npm install --save @ckeditor/ckeditor5-media-embed +``` + +Then add `MediaEmbed` to your plugin list and {@link module:media-embed/mediaembed~MediaEmbedConfig configure} the feature (if needed): + +```js +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ MediaEmbed, ... ], + toolbar: [ 'mediaEmbed', ... ] + mediaEmbed: { + // configuration... + } + } ) + .then( ... ) + .catch( ... ); +``` + + + Depending on how you will configure this feature, you may need to use services like [Iframely](https://iframely.com/) or [Embedly](https://embed.ly/) to display content of embedded media on your target website. Read more about [displaying embedded media](#displaying-embedded-media-on-your-website). + + +## Previewable and non-previewable media + +When the media embed feature is asked to embed a specific media element via its URL it needs to make a decision how the media will be displayed in the editor. + +### Previewable media + +If, for instance, the URL to embed is `https://www.youtube.com/watch?v=H08tGjXNHO4`, the feature is able to predict that it needs to produce the following HTML to show this YouTube video: + +```html +

+ +
+``` + +Yes, it is quite complex, but this is the cost of creating responsive content for today's web. The crucial part, though, is the iframe's `src` which the media embed feature can predict based on the given video URL and the aspect ratio (which affects `padding-bottom`). + +Thanks to the ability to hardcode this URL to HTML transformation, the media embed feature is able to show previews of YouTube, Dailymotion and Vimeo videos as well as Spotify widgets without requesting any external service. + +### Non-previewable media + +Unfortunately, to show previews of media such as tweets, Instagram photos or Facebook posts, the editor would need to retrieve the content of these from an external service. Some of these media providers expose [oEmbed endpoints](https://oembed.com/) but not all and those endpoint responses often require further processing to be embeddable. Most importantly, though, the media embed feature is often not able to request those services due to [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy). + +The above limitations can be overcome with the help of proxy services like Iframely or Embedly. However, the media embed feature [does not support asynchronous preview providers](https://github.com/ckeditor/ckeditor5-media-embed/issues/16) yet. Therefore, to still allow embedding tweets or Instagram photos, we chose to: + +1. Show a placeholder of the embedded media in the editor (see e.g. how a tweet is presented in the [demo](#demo) above). +2. Produce a [semantic `` tag](#semantic-data-output-default) in the data output from the editor. This output makes it possible to later use proxy services to [display the content of these media on your website](#displaying-embedded-media-on-your-website). + +## Configuration + +### Data output format + +The data output format of the feature can be configured using the {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} option. + + + This option does not change how the media are displayed inside the editor — the previewable ones will still be displayed with previews. It only affects the output data (see below). + + +#### Semantic data output (default) + +By default, the media embed feature outputs semantic `` tags for previewable and non-previewable media. That being so, it works best when the application processes (expands) the media on the server side or [directly in the front–end](#displaying-embedded-media-on-your-website), preserving the versatile database representation: + +```html +
+ +
+``` + +#### Including previews in data + +Optionally, by setting `mediaEmbed.previewsInData` to `true` you can configure the media embed feature to output media in the same way they look in the editor. So if the media element is "previewable", the media preview (HTML) is saved to the database: + +```html +
+
+ +
+
+``` + +Currently, the preview is only available for content providers for which CKEditor 5 can predict the `' + + * '' + * } + * + * @typedef {Object} module:media-embed/mediaembed~MediaEmbedProvider + * @property {String} name The name of the provider. Used e.g. when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders removing providers}. + * @property {RegExp|Array.} url The `RegExp` object (or array of objects) defining the URL of the media. + * If any URL matches the `RegExp`, it becomes the media in the editor model, as defined by the provider. The result + * of matching (output of `String.prototype.match()`) is passed to the `html` rendering function of the media. + * + * **Note:** You do not need to include the protocol (`http://`, `https://`) and `www` subdomain in your `RegExps`, + * they are stripped from the URLs before matching anyway. + * @property {Function} [html] (optional) The rendering function of the media. The function receives the entire matching + * array from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated + * preview of the media identified by a certain ID or a hash. When not defined, the media embed feature + * will use a generic media representation in the view and output data. + * Note that when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is `true`, the rendering function **will always** be used for the media in the editor data output. + */ + +/** + * The configuration of the {@link module:media-embed/mediaembed~MediaEmbed} feature. + * + * Read more in {@link module:media-embed/mediaembed~MediaEmbedConfig}. + * + * @member {module:media-embed/mediaembed~MediaEmbedConfig} module:core/editor/editorconfig~EditorConfig#mediaEmbed + */ + +/** + * The configuration of the media embed features. + * + * Read more about {@glink features/media-embed#configuration configuring the media embed feature}. + * + * ClassicEditor + * .create( editorElement, { + * mediaEmbed: ... // Media embed feature options. + * } ) + * .then( ... ) + * .catch( ... ); + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + * + * @interface MediaEmbedConfig + */ + +/** + * The default media providers supported by the editor. + * + * The names of providers with rendering functions (previews): + * + * * "dailymotion", + * * "spotify", + * * "youtube", + * * "vimeo" + * + * The names of providers without rendering functions: + * + * * "instagram", + * * "twitter", + * * "googleMaps", + * * "flickr", + * * "facebook" + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more about + * different kinds of media and media providers. + * + * **Note**: The default media provider configuration may not support all possible media URLs, + * only the most common are included. + * + * Media without rendering functions are always represented in the data using the "semantic" markup. See + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} to + * learn more about possible data outputs. + * + * The priority of media providers corresponds to the order of configuration. The first provider + * to match the URL is always used, even if there are other providers that support a particular URL. + * The URL is never matched against the remaining providers. + * + * To discard **all** default media providers, simply override this configuration with your own + * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}: + * + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * providers: [ + * { + * name: 'myProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * + * You can take inspiration from the default configuration of this feature which you can find in: + * https://github.com/ckeditor/ckeditor5-media-embed/blob/master/src/mediaembedediting.js + * + * To **extend** the list of default providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * To **remove** certain providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#providers + */ + +/** + * The additional media providers supported by the editor. This configuration helps extend the default + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers}. + * + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * extraProviders: [ + * { + * name: 'extraProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#extraProviders + */ + +/** + * The list of media providers that should not be used despite being available in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`} + * + * mediaEmbed: { + * removeProviders: [ 'youtube', 'twitter' ] + * } + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#removeProviders + */ + +/** + * Controls the data format produced by the feature. + * + * When `false` (default), the feature produces "semantic" data, i.e. it does not include the preview of + * the media, just the `` tag with the `url` attribute: + * + *
+ * + *
+ * + * When `true`, the media is represented in the output in the same way it looks in the editor, + * i.e. the media preview is saved to the database: + * + *
+ *
+ * + *
+ *
+ * + * **Note:** Media without preview are always represented in the data using the "semantic" markup + * regardless of the value of the `previewsInData`. Learn more about different kinds of media + * in the {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} + * configuration description. + * + * @member {Boolean} [module:media-embed/mediaembed~MediaEmbedConfig#previewsInData=false] + */ diff --git a/packages/ckeditor5-media-embed-eduflow/src/mediaembedcommand.js b/packages/ckeditor5-media-embed-eduflow/src/mediaembedcommand.js new file mode 100644 index 00000000000..1d1dddce793 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/src/mediaembedcommand.js @@ -0,0 +1,92 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { findOptimalInsertionPosition, checkSelectionOnObject } from 'ckeditor5/src/widget'; +import { getSelectedMediaModelWidget, insertMedia } from './utils'; + +/** + * The insert media command. + * + * The command is registered by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} as `'mediaEmbed'`. + * + * To insert media at the current selection, execute the command and specify the URL: + * + * editor.execute( 'mediaEmbed', 'http://url.to.the/media' ); + * + * @extends module:core/command~Command + */ +export default class MediaEmbedCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const model = this.editor.model; + const selection = model.document.selection; + const schema = model.schema; + const selectedMedia = getSelectedMediaModelWidget( selection ); + + this.value = selectedMedia ? selectedMedia.getAttribute( 'url' ) : null; + + this.isEnabled = isMediaSelected( selection ) || + isAllowedInParent( selection, model ) && + !checkSelectionOnObject( selection, schema ); + } + + /** + * Executes the command, which either: + * + * * updates the URL of the selected media, + * * inserts the new media into the editor and puts the selection around it. + * + * @fires execute + * @param {String} url The URL of the media. + */ + execute( url ) { + const model = this.editor.model; + const selection = model.document.selection; + const selectedMedia = getSelectedMediaModelWidget( selection ); + + if ( selectedMedia ) { + model.change( writer => { + writer.setAttribute( 'url', url, selectedMedia ); + } ); + } else { + const insertPosition = findOptimalInsertionPosition( selection, model ); + + insertMedia( model, url, insertPosition ); + } + } +} + +// Checks if the table is allowed in the parent. +// +// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection +// @param {module:engine/model/schema~Schema} schema +// @returns {Boolean} +function isAllowedInParent( selection, model ) { + const insertPosition = findOptimalInsertionPosition( selection, model ); + let parent = insertPosition.parent; + + // The model.insertContent() will remove empty parent (unless it is a $root or a limit). + if ( parent.isEmpty && !model.schema.isLimit( parent ) ) { + parent = parent.parent; + } + + return model.schema.checkChild( parent, 'media' ); +} + +// Checks if the media object is selected. +// +// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection +// @returns {Boolean} +function isMediaSelected( selection ) { + const element = selection.getSelectedElement(); + return !!element && element.name === 'media'; +} diff --git a/packages/ckeditor5-media-embed-eduflow/src/mediaembedediting.js b/packages/ckeditor5-media-embed-eduflow/src/mediaembedediting.js new file mode 100644 index 00000000000..7cdcd100081 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/src/mediaembedediting.js @@ -0,0 +1,249 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedediting + */ + +import { Plugin } from 'ckeditor5/src/core'; + +import { modelToViewUrlAttributeConverter } from './converters'; +import MediaEmbedCommand from './mediaembedcommand'; +import MediaRegistry from './mediaregistry'; +import { toMediaWidget, createMediaFigureElement } from './utils'; + +import '../theme/mediaembedediting.css'; + +/** + * The media embed editing feature. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedEditing extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedEditing'; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + editor.config.define( 'mediaEmbed', { + providers: [ + { + name: 'dailymotion', + url: /^dailymotion\.com\/video\/(\w+)/, + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'spotify', + url: [ + /^open\.spotify\.com\/(artist\/\w+)/, + /^open\.spotify\.com\/(album\/\w+)/, + /^open\.spotify\.com\/(track\/\w+)/ + ], + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'youtube', + url: [ + /^(?:m\.)?youtube\.com\/watch\?v=([\w-]+)/, + /^(?:m\.)?youtube\.com\/v\/([\w-]+)/, + /^youtube\.com\/embed\/([\w-]+)/, + /^youtu\.be\/([\w-]+)/ + ], + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'vimeo', + url: [ + /^vimeo\.com\/(\d+)/, + /^vimeo\.com\/[^/]+\/[^/]+\/video\/(\d+)/, + /^vimeo\.com\/album\/[^/]+\/video\/(\d+)/, + /^vimeo\.com\/channels\/[^/]+\/(\d+)/, + /^vimeo\.com\/groups\/[^/]+\/videos\/(\d+)/, + /^vimeo\.com\/ondemand\/[^/]+\/(\d+)/, + /^player\.vimeo\.com\/video\/(\d+)/ + ], + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'instagram', + url: /^instagram\.com\/p\/(\w+)/ + }, + { + name: 'twitter', + url: /^twitter\.com/ + }, + { + name: 'googleMaps', + url: /^google\.com\/maps/ + }, + { + name: 'flickr', + url: /^flickr\.com/ + }, + { + name: 'facebook', + url: /^facebook\.com/ + } + ] + } ); + + /** + * The media registry managing the media providers in the editor. + * + * @member {module:media-embed/mediaregistry~MediaRegistry} #registry + */ + this.registry = new MediaRegistry( editor.locale, editor.config.get( 'mediaEmbed' ) ); + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const schema = editor.model.schema; + const t = editor.t; + const conversion = editor.conversion; + const renderMediaPreview = editor.config.get( 'mediaEmbed.previewsInData' ); + const registry = this.registry; + + editor.commands.add( 'mediaEmbed', new MediaEmbedCommand( editor ) ); + + // Configure the schema. + schema.register( 'media', { + isObject: true, + isBlock: true, + allowWhere: '$block', + allowAttributes: [ 'url' ] + } ); + + // Model -> Data + conversion.for( 'dataDowncast' ).elementToElement( { + model: 'media', + view: ( modelElement, { writer } ) => { + const url = modelElement.getAttribute( 'url' ); + + return createMediaFigureElement( writer, registry, url, { + renderMediaPreview: url && renderMediaPreview + } ); + } + } ); + + // Model -> Data (url -> data-oembed-url) + conversion.for( 'dataDowncast' ).add( + modelToViewUrlAttributeConverter( registry, { + renderMediaPreview + } ) ); + + // Model -> View (element) + conversion.for( 'editingDowncast' ).elementToElement( { + model: 'media', + view: ( modelElement, { writer } ) => { + const url = modelElement.getAttribute( 'url' ); + const figure = createMediaFigureElement( writer, registry, url, { + renderForEditingView: true + } ); + + return toMediaWidget( figure, writer, t( 'media widget' ) ); + } + } ); + + // Model -> View (url -> data-oembed-url) + conversion.for( 'editingDowncast' ).add( + modelToViewUrlAttributeConverter( registry, { + renderForEditingView: true + } ) ); + + // View -> Model (data-oembed-url -> url) + conversion.for( 'upcast' ) + // Upcast semantic media. + .elementToElement( { + view: { + name: 'oembed', + attributes: { + url: true + } + }, + model: ( viewMedia, { writer } ) => { + const url = viewMedia.getAttribute( 'url' ); + + if ( registry.hasMedia( url ) ) { + return writer.createElement( 'media', { url } ); + } + } + } ) + // Upcast non-semantic media. + .elementToElement( { + view: { + name: 'div', + attributes: { + 'data-oembed-url': true + } + }, + model: ( viewMedia, { writer } ) => { + const url = viewMedia.getAttribute( 'data-oembed-url' ); + + if ( registry.hasMedia( url ) ) { + return writer.createElement( 'media', { url } ); + } + } + } ); + } +} diff --git a/packages/ckeditor5-media-embed-eduflow/src/mediaembedtoolbar.js b/packages/ckeditor5-media-embed-eduflow/src/mediaembedtoolbar.js new file mode 100644 index 00000000000..7f6b4fbb6be --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/src/mediaembedtoolbar.js @@ -0,0 +1,61 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedtoolbar + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { WidgetToolbarRepository } from 'ckeditor5/src/widget'; + +import { getSelectedMediaViewWidget } from './utils'; + +/** + * The media embed toolbar plugin. It creates a toolbar for media embed that shows up when the media element is selected. + * + * Instances of toolbar components (e.g. buttons) are created based on the + * {@link module:media-embed/mediaembed~MediaEmbedConfig#toolbar `media.toolbar` configuration option}. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedToolbar extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ WidgetToolbarRepository ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedToolbar'; + } + + /** + * @inheritDoc + */ + afterInit() { + const editor = this.editor; + const t = editor.t; + const widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository ); + + widgetToolbarRepository.register( 'mediaEmbed', { + ariaLabel: t( 'Media toolbar' ), + items: editor.config.get( 'mediaEmbed.toolbar' ) || [], + getRelatedElement: getSelectedMediaViewWidget + } ); + } +} + +/** + * Items to be placed in the media embed toolbar. + * This option requires adding {@link module:media-embed/mediaembedtoolbar~MediaEmbedToolbar} to the plugin list. + * + * Read more about configuring toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#toolbar + */ diff --git a/packages/ckeditor5-media-embed-eduflow/src/mediaembedui.js b/packages/ckeditor5-media-embed-eduflow/src/mediaembedui.js new file mode 100644 index 00000000000..53c9110e7d6 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/src/mediaembedui.js @@ -0,0 +1,138 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedui + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { createDropdown } from 'ckeditor5/src/ui'; + +import MediaFormView from './ui/mediaformview'; +import MediaEmbedEditing from './mediaembedediting'; +import mediaIcon from '../theme/icons/media.svg'; + +/** + * The media embed UI plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedUI extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ MediaEmbedEditing ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedUI'; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const command = editor.commands.get( 'mediaEmbed' ); + const registry = editor.plugins.get( MediaEmbedEditing ).registry; + + editor.ui.componentFactory.add( 'mediaEmbed', locale => { + const dropdown = createDropdown( locale ); + + const mediaForm = new MediaFormView( getFormValidators( editor.t, registry ), editor.locale ); + + this._setUpDropdown( dropdown, mediaForm, command, editor ); + this._setUpForm( dropdown, mediaForm, command ); + + return dropdown; + } ); + } + + /** + * @private + * @param {module:ui/dropdown/dropdownview~DropdownView} dropdown + * @param {module:ui/view~View} form + * @param {module:media-embed/mediaembedcommand~MediaEmbedCommand} command + */ + _setUpDropdown( dropdown, form, command ) { + const editor = this.editor; + const t = editor.t; + const button = dropdown.buttonView; + + dropdown.bind( 'isEnabled' ).to( command ); + dropdown.panelView.children.add( form ); + + button.set( { + label: t( 'Insert media' ), + icon: mediaIcon, + tooltip: true + } ); + + // Note: Use the low priority to make sure the following listener starts working after the + // default action of the drop-down is executed (i.e. the panel showed up). Otherwise, the + // invisible form/input cannot be focused/selected. + button.on( 'open', () => { + form.disableCssTransitions(); + + // 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 (`urlInputView#fieldView#value` stays + // unaltered) and re-opened it without changing the value of the media command (e.g. because they + // didn't change the selection), they would see the old value instead of the actual value of the + // command. + form.url = command.value || ''; + form.urlInputView.fieldView.select(); + form.focus(); + form.enableCssTransitions(); + }, { priority: 'low' } ); + + dropdown.on( 'submit', () => { + if ( form.isValid() ) { + editor.execute( 'mediaEmbed', form.url ); + closeUI(); + } + } ); + + dropdown.on( 'change:isOpen', () => form.resetFormStatus() ); + dropdown.on( 'cancel', () => closeUI() ); + + function closeUI() { + editor.editing.view.focus(); + dropdown.isOpen = false; + } + } + + /** + * @private + * @param {module:ui/dropdown/dropdownview~DropdownView} dropdown + * @param {module:ui/view~View} form + * @param {module:media-embed/mediaembedcommand~MediaEmbedCommand} command + */ + _setUpForm( dropdown, form, command ) { + form.delegate( 'submit', 'cancel' ).to( dropdown ); + form.urlInputView.bind( 'value' ).to( command, 'value' ); + + // Form elements should be read-only when corresponding commands are disabled. + form.urlInputView.bind( 'isReadOnly' ).to( command, 'isEnabled', value => !value ); + } +} + +function getFormValidators( t, registry ) { + return [ + form => { + if ( !form.url.length ) { + return t( 'The URL must not be empty.' ); + } + }, + form => { + if ( !registry.hasMedia( form.url ) ) { + return t( 'This media URL is not supported.' ); + } + } + ]; +} diff --git a/packages/ckeditor5-media-embed-eduflow/src/mediaregistry.js b/packages/ckeditor5-media-embed-eduflow/src/mediaregistry.js new file mode 100644 index 00000000000..9576a118bfb --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/src/mediaregistry.js @@ -0,0 +1,335 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaregistry + */ + +import { TooltipView, IconView, Template } from 'ckeditor5/src/ui'; +import { logWarning, toArray } from 'ckeditor5/src/utils'; + +import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg'; + +const mediaPlaceholderIconViewBox = '0 0 64 42'; + +/** + * A bridge between the raw media content provider definitions and the editor view content. + * + * It helps translating media URLs to corresponding {@link module:engine/view/element~Element view elements}. + * + * Mostly used by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} plugin. + */ +export default class MediaRegistry { + /** + * Creates an instance of the {@link module:media-embed/mediaregistry~MediaRegistry} class. + * + * @param {module:utils/locale~Locale} locale The localization services instance. + * @param {module:media-embed/mediaembed~MediaEmbedConfig} config The configuration of the media embed feature. + */ + constructor( locale, config ) { + const providers = config.providers; + const extraProviders = config.extraProviders || []; + const removedProviders = new Set( config.removeProviders ); + const providerDefinitions = providers + .concat( extraProviders ) + .filter( provider => { + const name = provider.name; + + if ( !name ) { + /** + * One of the providers (or extra providers) specified in the media embed configuration + * has no name and will not be used by the editor. In order to get this media + * provider working, double check your editor configuration. + * + * @error media-embed-no-provider-name + */ + logWarning( 'media-embed-no-provider-name', { provider } ); + + return false; + } + + return !removedProviders.has( name ); + } ); + + /** + * The {@link module:utils/locale~Locale} instance. + * + * @member {module:utils/locale~Locale} + */ + this.locale = locale; + + /** + * The media provider definitions available for the registry. Usually corresponding with the + * {@link module:media-embed/mediaembed~MediaEmbedConfig media configuration}. + * + * @member {Array} + */ + this.providerDefinitions = providerDefinitions; + } + + /** + * Checks whether the passed URL is representing a certain media type allowed in the editor. + * + * @param {String} url The URL to be checked + * @returns {Boolean} + */ + hasMedia( url ) { + return !!this._getMedia( url ); + } + + /** + * For the given media URL string and options, it returns the {@link module:engine/view/element~Element view element} + * representing that media. + * + * **Note:** If no URL is specified, an empty view element is returned. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {String} url The URL to be translated into a view element. + * @param {Object} options + * @param {String} [options.renderMediaPreview] + * @param {String} [options.renderForEditingView] + * @returns {module:engine/view/element~Element} + */ + getMediaViewElement( writer, url, options ) { + return this._getMedia( url ).getViewElement( writer, options ); + } + + /** + * Returns a `Media` instance for the given URL. + * + * @protected + * @param {String} url The URL of the media. + * @returns {module:media-embed/mediaregistry~Media|null} The `Media` instance or `null` when there is none. + */ + _getMedia( url ) { + if ( !url ) { + return new Media( this.locale ); + } + + url = url.trim(); + + for ( const definition of this.providerDefinitions ) { + const previewRenderer = definition.html; + const pattern = toArray( definition.url ); + + for ( const subPattern of pattern ) { + const match = this._getUrlMatches( url, subPattern ); + + if ( match ) { + return new Media( this.locale, url, match, previewRenderer ); + } + } + } + + return null; + } + + /** + * Tries to match `url` to `pattern`. + * + * @private + * @param {String} url The URL of the media. + * @param {RegExp} pattern The pattern that should accept the media URL. + * @returns {Array|null} + */ + _getUrlMatches( url, pattern ) { + // 1. Try to match without stripping the protocol and "www" subdomain. + let match = url.match( pattern ); + + if ( match ) { + return match; + } + + // 2. Try to match after stripping the protocol. + let rawUrl = url.replace( /^https?:\/\//, '' ); + match = rawUrl.match( pattern ); + + if ( match ) { + return match; + } + + // 3. Try to match after stripping the "www" subdomain. + rawUrl = rawUrl.replace( /^www\./, '' ); + match = rawUrl.match( pattern ); + + if ( match ) { + return match; + } + + return null; + } +} + +/** + * Represents media defined by the provider configuration. + * + * It can be rendered to the {@link module:engine/view/element~Element view element} and used in the editing or data pipeline. + * + * @private + */ +class Media { + constructor( locale, url, match, previewRenderer ) { + /** + * The URL this Media instance represents. + * + * @member {String} + */ + this.url = this._getValidUrl( url ); + + /** + * Shorthand for {@link module:utils/locale~Locale#t}. + * + * @see module:utils/locale~Locale#t + * @method + */ + this._t = locale.t; + + /** + * The output of the `RegExp.match` which validated the {@link #url} of this media. + * + * @member {Object} + */ + this._match = match; + + /** + * The function returning the HTML string preview of this media. + * + * @member {Function} + */ + this._previewRenderer = previewRenderer; + } + + /** + * Returns the view element representation of the media. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {Object} options + * @param {String} [options.renderMediaPreview] + * @param {String} [options.renderForEditingView] + * @returns {module:engine/view/element~Element} + */ + getViewElement( writer, options ) { + const attributes = {}; + let viewElement; + + if ( options.renderForEditingView || ( options.renderMediaPreview && this.url && this._previewRenderer ) ) { + if ( this.url ) { + attributes[ 'data-oembed-url' ] = this.url; + attributes[ 'src' ] = this.url; + } + + if ( options.renderForEditingView ) { + attributes.class = 'ck-media__wrapper'; + } + + const mediaHtml = this._getPreviewHtml( options ); + + viewElement = writer.createRawElement( 'div', attributes, function( domElement ) { + domElement.innerHTML = mediaHtml; + } ); + } else { + if ( this.url ) { + attributes.url = this.url; + } + + viewElement = writer.createEmptyElement( 'o-embed', attributes ); + } + + writer.setCustomProperty( 'media-content', true, viewElement ); + + return viewElement; + } + + /** + * Returns the HTML string of the media content preview. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {Object} options + * @param {String} [options.renderForEditingView] + * @returns {String} + */ + _getPreviewHtml( options ) { + if ( this._previewRenderer ) { + return this._previewRenderer( this._match ); + } else { + // The placeholder only makes sense for editing view and media which have URLs. + // Placeholder is never displayed in data and URL-less media have no content. + if ( this.url && options.renderForEditingView ) { + return this._getPlaceholderHtml(); + } + + return ''; + } + } + + /** + * Returns the placeholder HTML when the media has no content preview. + * + * @returns {String} + */ + _getPlaceholderHtml() { + const tooltip = new TooltipView(); + const icon = new IconView(); + + tooltip.text = this._t( 'Open media in new tab' ); + icon.content = mediaPlaceholderIcon; + icon.viewBox = mediaPlaceholderIconViewBox; + + const placeholder = new Template( { + tag: 'div', + attributes: { + class: 'ck ck-reset_all ck-media__placeholder' + }, + children: [ + { + tag: 'div', + attributes: { + class: 'ck-media__placeholder__icon' + }, + children: [ icon ] + }, + { + tag: 'a', + attributes: { + class: 'ck-media__placeholder__url', + target: '_blank', + rel: 'noopener noreferrer', + href: this.url + }, + children: [ + { + tag: 'span', + attributes: { + class: 'ck-media__placeholder__url__text' + }, + children: [ this.url ] + }, + tooltip + ] + } + ] + } ).render(); + + return placeholder.outerHTML; + } + + /** + * Returns the full URL to the specified media. + * + * @param {String} url The URL of the media. + * @returns {String|null} + */ + _getValidUrl( url ) { + if ( !url ) { + return null; + } + + if ( url.match( /^https?/ ) ) { + return url; + } + + return 'https://' + url; + } +} diff --git a/packages/ckeditor5-media-embed-eduflow/src/ui/mediaformview.js b/packages/ckeditor5-media-embed-eduflow/src/ui/mediaformview.js new file mode 100644 index 00000000000..56313a492db --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/src/ui/mediaformview.js @@ -0,0 +1,341 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/ui/mediaformview + */ + +import { + ButtonView, + FocusCycler, + LabeledFieldView, + View, + ViewCollection, + createLabeledInputText, + injectCssTransitionDisabler, + submitHandler +} from 'ckeditor5/src/ui'; +import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils'; +import { icons } from 'ckeditor5/src/core'; + +// See: #8833. +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +import '../../theme/mediaform.css'; + +/** + * The media form view controller class. + * + * See {@link module:media-embed/ui/mediaformview~MediaFormView}. + * + * @extends module:ui/view~View + */ +export default class MediaFormView extends View { + /** + * @param {Array.} validators Form validators used by {@link #isValid}. + * @param {module:utils/locale~Locale} [locale] The localization services instance. + */ + constructor( validators, locale ) { + super( locale ); + + const t = locale.t; + + /** + * Tracks information about the DOM focus in the form. + * + * @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 value of the URL input. + * + * @member {String} #mediaURLInputValue + * @observable + */ + this.set( 'mediaURLInputValue', '' ); + + /** + * The URL input view. + * + * @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView} + */ + this.urlInputView = this._createUrlInput(); + + /** + * The Save button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); + this.saveButtonView.type = 'submit'; + this.saveButtonView.bind( 'isEnabled' ).to( this, 'mediaURLInputValue', value => !!value ); + + /** + * The Cancel button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); + + /** + * A collection of views that can be focused in the form. + * + * @readonly + * @protected + * @member {module:ui/viewcollection~ViewCollection} + */ + this._focusables = new ViewCollection(); + + /** + * Helps cycling over {@link #_focusables} in the form. + * + * @readonly + * @protected + * @member {module:ui/focuscycler~FocusCycler} + */ + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + /** + * An array of form validators used by {@link #isValid}. + * + * @readonly + * @protected + * @member {Array.} + */ + this._validators = validators; + + this.setTemplate( { + tag: 'form', + + attributes: { + class: [ + 'ck', + 'ck-media-form', + 'ck-responsive-form' + ], + + tabindex: '-1' + }, + + children: [ + this.urlInputView, + this.saveButtonView, + this.cancelButtonView + ] + } ); + + injectCssTransitionDisabler( this ); + + /** + * The default info text for the {@link #urlInputView}. + * + * @private + * @member {String} #_urlInputViewInfoDefault + */ + + /** + * The info text with an additional tip for the {@link #urlInputView}, + * displayed when the input has some value. + * + * @private + * @member {String} #_urlInputViewInfoTip + */ + } + + /** + * @inheritDoc + */ + render() { + super.render(); + + submitHandler( { + view: this + } ); + + const childViews = [ + this.urlInputView, + this.saveButtonView, + this.cancelButtonView + ]; + + 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 ); + + const stopPropagation = data => data.stopPropagation(); + + // Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's + // keystroke handler would take over the key management in the URL input. We need to prevent + // this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible. + this.keystrokes.set( 'arrowright', stopPropagation ); + this.keystrokes.set( 'arrowleft', stopPropagation ); + this.keystrokes.set( 'arrowup', stopPropagation ); + this.keystrokes.set( 'arrowdown', stopPropagation ); + + // Intercept the `selectstart` event, which is blocked by default because of the default behavior + // of the DropdownView#panelView. + // TODO: blocking `selectstart` in the #panelView should be configurable per–drop–down instance. + this.listenTo( this.urlInputView.element, 'selectstart', ( evt, domEvt ) => { + domEvt.stopPropagation(); + }, { priority: 'high' } ); + } + + /** + * Focuses the fist {@link #_focusables} in the form. + */ + focus() { + this._focusCycler.focusFirst(); + } + + /** + * The native DOM `value` of the {@link #urlInputView} element. + * + * **Note**: Do not confuse it with the {@link module:ui/inputtext/inputtextview~InputTextView#value} + * which works one way only and may not represent the actual state of the component in the DOM. + * + * @type {String} + */ + get url() { + return this.urlInputView.fieldView.element.value.trim(); + } + + set url( url ) { + this.urlInputView.fieldView.element.value = url.trim(); + } + + /** + * Validates the form and returns `false` when some fields are invalid. + * + * @returns {Boolean} + */ + isValid() { + this.resetFormStatus(); + + for ( const validator of this._validators ) { + const errorText = validator( this ); + + // One error per field is enough. + if ( errorText ) { + // Apply updated error. + this.urlInputView.errorText = errorText; + + return false; + } + } + + return true; + } + + /** + * Cleans up the supplementary error and information text of the {@link #urlInputView} + * bringing them back to the state when the form has been displayed for the first time. + * + * See {@link #isValid}. + */ + resetFormStatus() { + this.urlInputView.errorText = null; + this.urlInputView.infoText = this._urlInputViewInfoDefault; + } + + /** + * Creates a labeled input view. + * + * @private + * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView} Labeled input view instance. + */ + _createUrlInput() { + const t = this.locale.t; + + const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); + const inputField = labeledInput.fieldView; + + this._urlInputViewInfoDefault = t( 'Paste the media URL in the input.' ); + this._urlInputViewInfoTip = t( 'Tip: Paste the URL into the content to embed faster.' ); + + labeledInput.label = t( 'Media URL' ); + labeledInput.infoText = this._urlInputViewInfoDefault; + + inputField.on( 'input', () => { + // Display the tip text only when there is some value. Otherwise fall back to the default info text. + labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault; + this.mediaURLInputValue = inputField.element.value.trim(); + } ); + + return labeledInput; + } + + /** + * Creates a button view. + * + * @private + * @param {String} label The button label. + * @param {String} icon The button icon. + * @param {String} className The additional button CSS class name. + * @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, className, eventName ) { + const button = new ButtonView( this.locale ); + + button.set( { + label, + icon, + tooltip: true + } ); + + button.extendTemplate( { + attributes: { + class: className + } + } ); + + if ( eventName ) { + button.delegate( 'execute' ).to( this, eventName ); + } + + return button; + } +} + +/** + * Fired when the form view is submitted (when one of the children triggered the submit event), + * e.g. click on {@link #saveButtonView}. + * + * @event submit + */ + +/** + * Fired when the form view is canceled, e.g. by a click on {@link #cancelButtonView}. + * + * @event cancel + */ diff --git a/packages/ckeditor5-media-embed-eduflow/src/utils.js b/packages/ckeditor5-media-embed-eduflow/src/utils.js new file mode 100644 index 00000000000..f2b20b76c06 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/src/utils.js @@ -0,0 +1,119 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/utils + */ + +import { isWidget, toWidget } from 'ckeditor5/src/widget'; + +/** + * Converts a given {@link module:engine/view/element~Element} to a media embed widget: + * * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the media widget element. + * * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator. + * + * @param {module:engine/view/element~Element} viewElement + * @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer. + * @param {String} label The element's label. + * @returns {module:engine/view/element~Element} + */ +export function toMediaWidget( viewElement, writer, label ) { + writer.setCustomProperty( 'media', true, viewElement ); + + return toWidget( viewElement, writer, { label } ); +} + +/** + * Returns a media widget editing view element if one is selected. + * + * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection + * @returns {module:engine/view/element~Element|null} + */ +export function getSelectedMediaViewWidget( selection ) { + const viewElement = selection.getSelectedElement(); + + if ( viewElement && isMediaWidget( viewElement ) ) { + return viewElement; + } + + return null; +} + +/** + * Checks if a given view element is a media widget. + * + * @param {module:engine/view/element~Element} viewElement + * @returns {Boolean} + */ +export function isMediaWidget( viewElement ) { + return !!viewElement.getCustomProperty( 'media' ) && isWidget( viewElement ); +} + +/** + * Creates a view element representing the media. Either a "semantic" one for the data pipeline: + * + *
+ * + *
+ * + * or a "non-semantic" (for the editing view pipeline): + * + *
+ *
[ non-semantic media preview for "foo" ]
+ *
+ * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer + * @param {module:media-embed/mediaregistry~MediaRegistry} registry + * @param {String} url + * @param {Object} options + * @param {String} [options.useSemanticWrapper] + * @param {String} [options.renderForEditingView] + * @returns {module:engine/view/containerelement~ContainerElement} + */ +export function createMediaFigureElement( writer, registry, url, options ) { + const figure = writer.createContainerElement( 'figure', { class: 'media' } ); + + writer.insert( writer.createPositionAt( figure, 0 ), registry.getMediaViewElement( writer, url, options ) ); + + return figure; +} + +/** + * Returns a selected media element in the model, if any. + * + * @param {module:engine/model/selection~Selection} selection + * @returns {module:engine/model/element~Element|null} + */ +export function getSelectedMediaModelWidget( selection ) { + const selectedElement = selection.getSelectedElement(); + + if ( selectedElement && selectedElement.is( 'element', 'media' ) ) { + return selectedElement; + } + + return null; +} + +/** + * Creates a media element and inserts it into the model. + * + * **Note**: This method will use {@link module:engine/model/model~Model#insertContent `model.insertContent()`} logic of inserting content + * if no `insertPosition` is passed. + * + * @param {module:engine/model/model~Model} model + * @param {String} url An URL of an embeddable media. + * @param {module:engine/model/position~Position} [insertPosition] Position to insert the media. If not specified, + * the default behavior of {@link module:engine/model/model~Model#insertContent `model.insertContent()`} will + * be applied. + */ +export function insertMedia( model, url, insertPosition ) { + model.change( writer => { + const mediaElement = writer.createElement( 'media', { url } ); + + model.insertContent( mediaElement, insertPosition ); + + writer.setSelection( mediaElement, 'on' ); + } ); +} diff --git a/packages/ckeditor5-media-embed-eduflow/tests/automediaembed.js b/packages/ckeditor5-media-embed-eduflow/tests/automediaembed.js new file mode 100644 index 00000000000..4345949a9f9 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/automediaembed.js @@ -0,0 +1,643 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global setTimeout */ + +import MediaEmbed from '../src/mediaembed'; +import AutoMediaEmbed from '../src/automediaembed'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import Undo from '@ckeditor/ckeditor5-undo/src/undo'; +import Typing from '@ckeditor/ckeditor5-typing/src/typing'; +import Image from '@ckeditor/ckeditor5-image/src/image'; +import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'AutoMediaEmbed - integration', () => { + let editorElement, editor; + + beforeEach( () => { + editorElement = global.document.createElement( 'div' ); + global.document.body.appendChild( editorElement ); + + return ClassicTestEditor + .create( editorElement, { + plugins: [ Typing, Paragraph, Link, Image, ImageCaption, MediaEmbed, AutoMediaEmbed ] + } ) + .then( newEditor => { + editor = newEditor; + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should load Clipboard plugin', () => { + expect( editor.plugins.get( Clipboard ) ).to.instanceOf( Clipboard ); + } ); + + it( 'should load Undo plugin', () => { + expect( editor.plugins.get( Undo ) ).to.instanceOf( Undo ); + } ); + + it( 'has proper name', () => { + expect( AutoMediaEmbed.pluginName ).to.equal( 'AutoMediaEmbed' ); + } ); + + describe( 'use fake timers', () => { + let clock; + + beforeEach( () => { + clock = sinon.useFakeTimers(); + } ); + + afterEach( () => { + clock.restore(); + } ); + + it( 'replaces pasted text with media element after 100ms', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'can undo auto-embeding', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + + clock.tick( 100 ); + + editor.commands.execute( 'undo' ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + } ); + + it( 'works for a full URL (https + "www" sub-domain)', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for a full URL (https without "www" sub-domain)', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for a full URL (http + "www" sub-domain)', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'http://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for a full URL (http without "www" sub-domain)', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'http://youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for a URL without protocol (with "www" sub-domain)', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for a URL without protocol (without "www" sub-domain)', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works fine if a media has no preview', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://twitter.com/ckeditor/status/1035181110140063749' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for URL that was pasted as a link', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for URL that contains some inline styles', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for not collapsed selection inside single element', () => { + setData( editor.model, '[Foo]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'works for not collapsed selection over a few elements', () => { + setData( editor.model, 'Fo[oBa]r' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'Fo[]r' + ); + } ); + + it( 'inserts media in-place (collapsed selection)', () => { + setData( editor.model, 'Foo []Bar' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'Foo ' + + '[]' + + 'Bar' + ); + } ); + + it( 'inserts media in-place (non-collapsed selection)', () => { + setData( editor.model, 'Foo [Bar] Baz' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'Foo ' + + '[]' + + ' Baz' + ); + } ); + + it( 'does nothing if a URL is invalid', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://youtube.com' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'https://youtube.com[]' + ); + } ); + + it( 'does nothing if pasted two links as text', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4 https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4 https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + } ); + + it( 'does nothing if pasted text contains a valid URL', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'Foo bar https://www.youtube.com/watch?v=H08tGjXNHO4 bar foo.' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'Foo bar https://www.youtube.com/watch?v=H08tGjXNHO4 bar foo.[]' + ); + } ); + + it( 'does nothing if pasted more than single node', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, + 'https://www.youtube.com/watch?v=H08tGjXNHO4 ' + + 'https://www.youtube.com/watch?v=H08tGjXNHO4' + ); + + clock.tick( 100 ); + + expect( getData( editor.model, { withoutSelection: true } ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4 ' + + '<$text linkHref="https://www.youtube.com/watch?v=H08tGjXNHO4">https://www.youtube.com/watch?v=H08tGjXNHO4' + + '' + ); + } ); + + it( 'does nothing if pasted a paragraph with the url', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, '

https://www.youtube.com/watch?v=H08tGjXNHO4

' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + } ); + + it( 'does nothing if pasted a block of content that looks like a URL', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, '

https://

youtube.com/watch?

v=H08tGjXNHO4

' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'https://' + + 'youtube.com/watch?' + + 'v=H08tGjXNHO4[]' + ); + } ); + + it( 'does nothing if a URL is invalid (space inside URL)', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'youtube.com/watch?v=H08tGjXNHO4&param=foo bar' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'youtube.com/watch?v=H08tGjXNHO4¶m=foo bar[]' + ); + } ); + + // #47 + it( 'does not transform a valid URL into a media if the element cannot be placed in the current position', () => { + setData( editor.model, 'Foo.[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'Foo.https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + } ); + + it( 'replaces a URL in media if pasted a link when other media element was selected', () => { + setData( + editor.model, + '[]' + ); + + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + + it( 'inserts a new media element if pasted a link when other media element was selected in correct place', () => { + setData( + editor.model, + 'Foo. <$text linkHref="https://cksource.com">Bar' + + '[]' + + 'Bar.' + ); + + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + 'Foo. <$text linkHref="https://cksource.com">Bar' + + '[]' + + 'Bar.' + ); + } ); + + it( 'does nothing if URL match to media but it was removed', () => { + return ClassicTestEditor + .create( editorElement, { + plugins: [ MediaEmbed, AutoMediaEmbed, Paragraph ], + mediaEmbed: { + removeProviders: [ 'youtube' ] + } + } ) + .then( newEditor => { + setData( newEditor.model, '[]' ); + pasteHtml( newEditor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + clock.tick( 100 ); + + expect( getData( newEditor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + + editorElement.remove(); + + return newEditor.destroy(); + } ); + } ); + + it( 'works for URL with %-symbols', () => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'http://youtube.com/watch?v=H08tGjXNHO4%2' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + } ); + } ); + + describe( 'use real timers', () => { + const characters = Array( 10 ).fill( 1 ).map( ( x, i ) => String.fromCharCode( 65 + i ) ); + + it( 'undo breaks the auto-media embed feature (undo was done before auto-media embed)', done => { + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + + setTimeout( () => { + editor.commands.execute( 'undo' ); + + expect( getData( editor.model ) ).to.equal( + '[]' + ); + + done(); + } ); + } ); + + // Checking whether paste+typing calls the auto-media handler once. + it( 'pasting handler should be executed once', done => { + const autoMediaEmbedPlugin = editor.plugins.get( AutoMediaEmbed ); + const autoMediaHandler = autoMediaEmbedPlugin._embedMediaBetweenPositions; + let counter = 0; + + autoMediaEmbedPlugin._embedMediaBetweenPositions = function( ...args ) { + counter += 1; + + return autoMediaHandler.apply( this, args ); + }; + + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + simulateTyping( 'Foo. Bar.' ); + + setTimeout( () => { + autoMediaEmbedPlugin._embedMediaBetweenPositions = autoMediaHandler; + + expect( counter ).to.equal( 1 ); + + done(); + }, 100 ); + } ); + + it( 'typing before pasted link during collaboration should not blow up', done => { + setData( editor.model, '[]' ); + + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + for ( let i = 0; i < 10; ++i ) { + const rootEl = editor.model.document.getRoot(); + + setTimeout( () => { + editor.model.enqueueChange( 'transparent', writer => { + writer.insertText( characters[ i ], writer.createPositionFromPath( rootEl, [ 0, i ] ) ); + } ); + }, i * 5 ); + } + + setTimeout( () => { + expect( getData( editor.model ) ).to.equal( + '[]ABCDEFGHIJ' + ); + + done(); + }, 100 ); + } ); + + it( 'typing after pasted link during collaboration should not blow up', done => { + setData( editor.model, '[]' ); + + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + for ( let i = 0; i < 10; ++i ) { + setTimeout( () => { + editor.model.enqueueChange( 'transparent', writer => { + writer.insertText( characters[ i ], editor.model.document.selection.getFirstPosition() ); + } ); + }, i * 5 ); + } + + setTimeout( () => { + expect( getData( editor.model ) ).to.equal( + '[]ABCDEFGHIJ' + ); + + done(); + }, 100 ); + } ); + + it( 'should insert the media element even if parent element where the URL was pasted has been deleted', done => { + setData( editor.model, 'Foo.Bar.[]' ); + + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + editor.model.enqueueChange( 'transparent', writer => { + writer.remove( writer.createRangeOn( editor.model.document.getRoot().getChild( 1 ) ) ); + } ); + + setTimeout( () => { + expect( getData( editor.model ) ).to.equal( + 'Foo.[]' + ); + + done(); + }, 100 ); + } ); + + it( 'should insert the media element even if new element appeared above the pasted URL', done => { + setData( editor.model, 'Foo.Bar.[]' ); + + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + editor.model.enqueueChange( 'transparent', writer => { + const paragraph = writer.createElement( 'paragraph' ); + writer.insert( paragraph, writer.createPositionAfter( editor.model.document.getRoot().getChild( 0 ) ) ); + writer.setSelection( paragraph, 'in' ); + } ); + + for ( let i = 0; i < 10; ++i ) { + setTimeout( () => { + editor.model.enqueueChange( 'transparent', writer => { + writer.insertText( characters[ i ], editor.model.document.selection.getFirstPosition() ); + } ); + }, i * 5 ); + } + + setTimeout( () => { + expect( getData( editor.model ) ).to.equal( + 'Foo.' + + 'ABCDEFGHIJ' + + 'Bar.' + + '[]' + ); + + done(); + }, 100 ); + } ); + + it( 'should insert the media element even if new element appeared below the pasted URL', done => { + setData( editor.model, 'Foo.Bar.[]' ); + + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + editor.model.enqueueChange( 'transparent', writer => { + const paragraph = writer.createElement( 'paragraph' ); + writer.insert( paragraph, writer.createPositionAfter( editor.model.document.getRoot().getChild( 1 ) ) ); + writer.setSelection( paragraph, 'in' ); + } ); + + for ( let i = 0; i < 10; ++i ) { + setTimeout( () => { + editor.model.enqueueChange( 'transparent', writer => { + writer.insertText( characters[ i ], editor.model.document.selection.getFirstPosition() ); + } ); + }, i * 5 ); + } + + setTimeout( () => { + expect( getData( editor.model ) ).to.equal( + 'Foo.' + + 'Bar.' + + '[]' + + 'ABCDEFGHIJ' + ); + + done(); + }, 100 ); + } ); + } ); + + it( 'should detach LiveRange', async () => { + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Typing, Paragraph, Link, Image, ImageCaption, Table, MediaEmbed, AutoMediaEmbed ] + } ); + + setData( + editor.model, + '' + + '' + + '[foo]' + + '[bar]' + + '' + + '
' + ); + + pasteHtml( editor, '
onetwo
' ); + + expect( getData( editor.model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + 'one' + + 'two' + + '' + + '
' + ); + + expect( () => { + editor.setData( '' ); + } ).not.to.throw(); + + await editor.destroy(); + } ); + + function simulateTyping( text ) { + // While typing, every character is an atomic change. + text.split( '' ).forEach( character => { + editor.execute( 'input', { + text: character + } ); + } ); + } + + function pasteHtml( editor, html ) { + editor.editing.view.document.fire( 'paste', { + dataTransfer: createDataTransfer( { 'text/html': html } ), + stopPropagation() {}, + preventDefault() {} + } ); + } + + function createDataTransfer( data ) { + return { + getData( type ) { + return data[ type ]; + } + }; + } +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/insertmediacommand.js b/packages/ckeditor5-media-embed-eduflow/tests/insertmediacommand.js new file mode 100644 index 00000000000..bcdc2f4f20c --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/insertmediacommand.js @@ -0,0 +1,148 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import MediaEmbedEditing from '../src/mediaembedediting'; +import MediaEmbedCommand from '../src/mediaembedcommand'; + +describe( 'MediaEmbedCommand', () => { + let editor, model, command; + + beforeEach( () => { + return ModelTestEditor + .create( { + plugins: [ MediaEmbedEditing ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new MediaEmbedCommand( editor ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if in a root', () => { + setData( model, '[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if in a paragraph (collapsed)', () => { + setData( model, '

foo[]

' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if in a paragraph (not collapsed)', () => { + setData( model, '

[foo]

' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if a media is selected', () => { + setData( model, '[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if a media is selected in a table cell', () => { + model.schema.register( 'table', { allowIn: '$root', isLimit: true, isObject: true, isBlock: true } ); + model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } ); + model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } ); + model.schema.extend( 'media', { allowIn: 'tableCell' } ); + + setData( model, '[]
' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if in a table cell', () => { + model.schema.register( 'table', { allowIn: '$root', isLimit: true, isObject: true, isBlock: true } ); + model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } ); + model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } ); + model.schema.extend( '$block', { allowIn: 'tableCell' } ); + + setData( model, '

foo[]

' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when the selection directly in a block', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.extend( '$text', { allowIn: 'block' } ); + + setData( model, 'foo[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when the selection in a limit element', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.register( 'limit', { allowIn: 'block', isLimit: true } ); + model.schema.extend( '$text', { allowIn: 'limit' } ); + + setData( model, 'foo[]' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if a non-object element is selected', () => { + model.schema.register( 'element', { allowIn: '$root', isSelectable: true } ); + + setData( model, '[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if a non-media object is selected', () => { + model.schema.register( 'image', { isObject: true, isBlock: true, allowWhere: '$block' } ); + + setData( model, '[]' ); + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be null when no media is selected (paragraph)', () => { + setData( model, '

foo[]

' ); + expect( command.value ).to.be.null; + } ); + + it( 'should equal the url of the selected media', () => { + setData( model, '[]' ); + expect( command.value ).to.equal( 'http://ckeditor.com' ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should create a single batch', () => { + setData( model, '

foo[]

' ); + + const spy = sinon.spy(); + + model.document.on( 'change', spy ); + + command.execute( 'http://ckeditor.com' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should insert a media in an empty root and select it', () => { + setData( model, '[]' ); + + command.execute( 'http://ckeditor.com' ); + + expect( getData( model ) ).to.equal( '[]' ); + } ); + + it( 'should update media url', () => { + setData( model, '[]' ); + + command.execute( 'http://cksource.com' ); + + expect( getData( model ) ).to.equal( '[]' ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/integration.js b/packages/ckeditor5-media-embed-eduflow/tests/integration.js new file mode 100644 index 00000000000..627a58fd447 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/integration.js @@ -0,0 +1,65 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import MediaEmbed from '../src/mediaembed'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import { enablePlaceholder } from '@ckeditor/ckeditor5-engine/src/view/placeholder'; + +describe( 'MediaEmbed integration', () => { + let element, clock; + + beforeEach( () => { + clock = sinon.useFakeTimers(); + element = document.createElement( 'div' ); + document.body.appendChild( element ); + } ); + + afterEach( () => { + element.remove(); + clock.restore(); + } ); + + describe( 'with the placeholder feature', () => { + // https://github.com/ckeditor/ckeditor5/issues/1684 + it( 'should make the placeholder CSS class disappear when pasting a new media into an empty editing root', async () => { + const editor = await ClassicTestEditor.create( element, { + plugins: [ MediaEmbed, Paragraph ] + } ); + + enablePlaceholder( { + view: editor.editing.view, + element: editor.editing.view.document.getRoot(), + text: 'foo', + isDirectHost: false + } ); + + editor.editing.view.document.fire( 'paste', { + dataTransfer: { + getData() { + return 'https://www.youtube.com/watch?v=H08tGjXNHO4'; + } + }, + stopPropagation() {}, + preventDefault() {} + } ); + + clock.tick( 100 ); + + expect( getViewData( editor.editing.view ) ).to.equal( + '[
' + + '
' + + '
' + + '
]' + ); + + await editor.destroy(); + } ); + } ); +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.html b/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.html new file mode 100644 index 00000000000..de6d7faeeff --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.html @@ -0,0 +1,58 @@ + + +

Example URLs

+
    +
  • +
  • +
+ +

Test editor

+
+

Media with previews

+ +
+
+
+ +
+
+
+ +
+
+
+ +

Generic media

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
diff --git a/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.js b/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.js new file mode 100644 index 00000000000..37fb03a2e05 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.js @@ -0,0 +1,29 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import MediaEmbed from '../../src/mediaembed'; +import MediaEmbedToolbar from '../../src/mediaembedtoolbar'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ ArticlePluginSet, MediaEmbed, MediaEmbedToolbar ], + toolbar: [ + 'heading', '|', 'mediaEmbed', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'link', 'undo', 'redo' + ], + mediaEmbed: { + previewsInData: true, + toolbar: [ 'blockQuote' ] + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.md b/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.md new file mode 100644 index 00000000000..b721a4132e8 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/manual/mediaembed.md @@ -0,0 +1,54 @@ +## Media Embed + +### Embed the media + +1. Put an URL in the clipboard, +1. Use the button in the dropdown or paste the URL directly in the editor, +1. Insert the media, +1. Check if media was inserted and is selected. + +### Update the media + +1. Put an URL in the clipboard, +1. Select some media, +1. Use the button in the dropdown, +1. Update the URL, +1. Check if media was updated and remains selected. + +### Data output + +1. Call `editor.getData()`, +1. Media with previews should include their previews. Preview–less media should be represented using only the `` tag. + +### URL validation + +#### Invalid + +1. In the previous scenarios try using a non–media URL, +1. The error should be displayed next to the URL field, +1. Nothing should be inserted/updated. + +#### Empty + +1. In the previous scenarios try using an empty URL, +1. The error should be displayed next to the URL field, +1. Nothing should be inserted/updated. + +### Copy&Paste + +1. Play with media copy&paste. +1. It should work just like images or any other widget. + +### Open media in new tab + +1. Locate any generic media in the content, +1. Hover the URL in the content (the tooltip should show up), +1. Click the URL, +1. A new browser tab should open with the media URL. + +### Media embed toolbar + +1. Click the media, +1. The block quote button should be visible, +1. Click the block quote button, +1. The block quote should be applied to the media. diff --git a/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.html b/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.html new file mode 100644 index 00000000000..e9d6efcbd67 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.html @@ -0,0 +1,58 @@ + + +

Example URLs

+
    +
  • +
  • +
+ +

Test editor

+
+

Media with previews

+ +
+ +
+ +
+ +
+ +
+ +
+ +

Generic media

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
diff --git a/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.js b/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.js new file mode 100644 index 00000000000..4b6705afabc --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.js @@ -0,0 +1,24 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import MediaEmbed from '../../src/mediaembed'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ ArticlePluginSet, MediaEmbed ], + toolbar: [ + 'heading', '|', 'mediaEmbed', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'link', 'undo', 'redo' + ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.md b/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.md new file mode 100644 index 00000000000..0ddf1c4d107 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/manual/semanticmediaembed.md @@ -0,0 +1,47 @@ +## Media Embed + +### Embed the media + +1. Put an URL in the clipboard, +1. Use the button in the dropdown or paste the URL directly in the editor, +1. Insert the media, +1. Check if media was inserted and is selected. + +### Update the media + +1. Put an URL in the clipboard, +1. Select some media, +1. Use the button in the dropdown, +1. Update the URL, +1. Check if media was updated and remains selected. + +### Data output + +1. Call `editor.getData()`, +1. All the media should be represented without previews, using only the `` tag. + +### URL validation + +#### Invalid + +1. In the previous scenarios try using a non–media URL, +1. The error should be displayed next to the URL field, +1. Nothing should be inserted/updated. + +#### Empty + +1. In the previous scenarios try using an empty URL, +1. The error should be displayed next to the URL field, +1. Nothing should be inserted/updated. + +### Copy&Paste + +1. Play with media copy&paste. +1. It should work just like images or any other widget. + +### Open media in new tab + +1. Locate any generic media in the content, +1. Hover the URL in the content (the tooltip should show up), +1. Click the URL, +1. A new browser tab should open with the media URL. diff --git a/packages/ckeditor5-media-embed-eduflow/tests/mediaembed.js b/packages/ckeditor5-media-embed-eduflow/tests/mediaembed.js new file mode 100644 index 00000000000..0f9b171300a --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/mediaembed.js @@ -0,0 +1,59 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import MediaEmbed from '../src/mediaembed'; +import MediaEmbedEditing from '../src/mediaembedediting'; +import MediaEmbedUI from '../src/mediaembedui'; +import AutoMediaEmbed from '../src/automediaembed'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + +describe( 'MediaEmbed', () => { + let editorElement, editor; + + beforeEach( () => { + editorElement = global.document.createElement( 'div' ); + global.document.body.appendChild( editorElement ); + + return ClassicTestEditor + .create( editorElement, { + plugins: [ MediaEmbed ] + } ) + .then( newEditor => { + editor = newEditor; + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( MediaEmbed ) ).to.instanceOf( MediaEmbed ); + } ); + + it( 'should load MediaEmbedEditing plugin', () => { + expect( editor.plugins.get( MediaEmbedEditing ) ).to.instanceOf( MediaEmbedEditing ); + } ); + + it( 'should load Widget plugin', () => { + expect( editor.plugins.get( Widget ) ).to.instanceOf( Widget ); + } ); + + it( 'should load MediaEmbedUI plugin', () => { + expect( editor.plugins.get( MediaEmbedUI ) ).to.instanceOf( MediaEmbedUI ); + } ); + + it( 'should load AutoMediaEmbed plugin', () => { + expect( editor.plugins.get( AutoMediaEmbed ) ).to.instanceOf( AutoMediaEmbed ); + } ); + + it( 'has proper name', () => { + expect( MediaEmbed.pluginName ).to.equal( 'MediaEmbed' ); + } ); +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/mediaembedediting.js b/packages/ckeditor5-media-embed-eduflow/tests/mediaembedediting.js new file mode 100644 index 00000000000..f4c0667ce6f --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/mediaembedediting.js @@ -0,0 +1,990 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import MediaEmbedEditing from '../src/mediaembedediting'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml'; + +describe( 'MediaEmbedEditing', () => { + let editor, model, doc, view; + + const testProviders = { + A: { + name: 'A', + url: /^foo\.com\/(\w+)/, + html: match => `A, id=${ match[ 1 ] }` + }, + B: { + name: 'B', + url: /^bar\.com\/(\w+)/, + html: match => `B, id=${ match[ 1 ] }` + }, + C: { + name: 'C', + url: /^\w+\.com\/(\w+)/, + html: match => `C, id=${ match[ 1 ] }` + }, + + extraA: { + name: 'extraA', + url: /^foo\.com\/(\w+)/, + html: match => `extraA, id=${ match[ 1 ] }` + }, + extraB: { + name: 'extraB', + url: /^\w+\.com\/(\w+)/, + html: match => `extraB, id=${ match[ 1 ] }` + }, + + previewLess: { + name: 'preview-less', + url: /^https:\/\/preview-less/ + }, + allowEverything: { + name: 'allow-everything', + url: /(.*)/, + html: match => `allow-everything, id=${ match[ 1 ] }` + } + }; + + afterEach( () => { + sinon.restore(); + } ); + + it( 'should be named', () => { + expect( MediaEmbedEditing.pluginName ).to.equal( 'MediaEmbedEditing' ); + } ); + + describe( 'constructor()', () => { + describe( 'configuration', () => { + describe( '#providers', () => { + it( 'should warn when provider has no name', () => { + const consoleWarnStub = sinon.stub( console, 'warn' ); + const provider = { + url: /.*/ + }; + + return createTestEditor( { + providers: [ provider ] + } ).then( () => { + expect( consoleWarnStub.calledOnce ).to.equal( true ); + expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name/ ); + expect( consoleWarnStub.firstCall.args[ 1 ].provider ).to.deep.equal( provider ); + } ); + } ); + + it( 'can override all providers', () => { + return createTestEditor( { + providers: [] + } ).then( editor => { + editor.setData( '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( '' ); + } ); + } ); + + it( 'upcast media according to the order', () => { + return createTestEditor( { + providers: [ + testProviders.A, + testProviders.B, + testProviders.C + ] + } ).then( editor => { + editor.setData( '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'A, id=123' + + '
' + + '
' + ); + + editor.setData( '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'B, id=123' + + '
' + + '
' + ); + + editor.setData( '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'C, id=123' + + '
' + + '
' + ); + } ); + } ); + + describe( 'default value', () => { + beforeEach( () => { + return createTestEditor() + .then( newEditor => { + editor = newEditor; + view = editor.editing.view; + } ); + } ); + + describe( 'with preview', () => { + it( 'upcasts the URL (dailymotion)', () => { + testMediaUpcast( [ + 'https://www.dailymotion.com/video/foo', + 'www.dailymotion.com/video/foo', + 'dailymotion.com/video/foo' + ], + '
' + + '' + + '
' ); + } ); + + describe( 'spotify', () => { + it( 'upcasts the URL (artist)', () => { + testMediaUpcast( [ + 'https://www.open.spotify.com/artist/foo', + 'www.open.spotify.com/artist/foo', + 'open.spotify.com/artist/foo' + ], + '
' + + '' + + '
' ); + } ); + + it( 'upcasts the URL (album)', () => { + testMediaUpcast( [ + 'https://www.open.spotify.com/album/foo', + 'www.open.spotify.com/album/foo', + 'open.spotify.com/album/foo' + ], + '
' + + '' + + '
' ); + } ); + + it( 'upcasts the URL (track)', () => { + testMediaUpcast( [ + 'https://www.open.spotify.com/track/foo', + 'www.open.spotify.com/track/foo', + 'open.spotify.com/track/foo' + ], + '
' + + '' + + '
' ); + } ); + } ); + + it( 'upcasts the URL (youtube)', () => { + testMediaUpcast( [ + 'https://www.youtube.com/watch?v=foo', + 'www.youtube.com/watch?v=foo', + 'youtube.com/watch?v=foo', + 'https://m.youtube.com/watch?v=foo', + 'm.youtube.com/watch?v=foo', + + 'https://www.youtube.com/v/foo', + 'www.youtube.com/v/foo', + 'youtube.com/v/foo', + 'https://m.youtube.com/v/foo', + 'm.youtube.com/v/foo', + + 'https://www.youtube.com/embed/foo', + 'www.youtube.com/embed/foo', + 'youtube.com/embed/foo', + + 'https://youtu.be/foo', + 'youtu.be/foo' + ], + '
' + + '' + + '
' ); + } ); + + // See: https://github.com/ckeditor/ckeditor5-media-embed/issues/26 + it( 'upcasts the URL that contains a dash (youtube)', () => { + testMediaUpcast( [ + 'https://www.youtube.com/watch?v=euqbMkM-QQk' + ], + '
' + + '' + + '
' ); + } ); + + it( 'upcasts the URL (vimeo)', () => { + testMediaUpcast( [ + 'https://www.vimeo.com/1234', + 'www.vimeo.com/1234', + 'vimeo.com/1234', + + 'https://www.vimeo.com/foo/foo/video/1234', + 'www.vimeo.com/foo/foo/video/1234', + 'vimeo.com/foo/foo/video/1234', + + 'https://www.vimeo.com/album/foo/video/1234', + 'www.vimeo.com/album/foo/video/1234', + 'vimeo.com/album/foo/video/1234', + + 'https://www.vimeo.com/channels/foo/1234', + 'www.vimeo.com/channels/foo/1234', + 'vimeo.com/channels/foo/1234', + + 'https://www.vimeo.com/groups/foo/videos/1234', + 'www.vimeo.com/groups/foo/videos/1234', + 'vimeo.com/groups/foo/videos/1234', + + 'https://www.vimeo.com/ondemand/foo/1234', + 'www.vimeo.com/ondemand/foo/1234', + 'vimeo.com/ondemand/foo/1234', + + 'https://www.player.vimeo.com/video/1234', + 'www.player.vimeo.com/video/1234', + 'player.vimeo.com/video/1234' + ], + '
' + + '' + + '
' ); + } ); + } ); + + describe( 'preview-less', () => { + it( 'upcasts the URL (instagram)', () => { + testMediaUpcast( [ + 'https://www.instagram.com/p/foo', + 'www.instagram.com/p/foo', + 'instagram.com/p/foo' + ] ); + } ); + + it( 'upcasts the URL (twitter)', () => { + testMediaUpcast( [ + 'https://www.twitter.com/foo/bar', + 'www.twitter.com/foo/bar', + 'twitter.com/foo/bar' + ] ); + } ); + + it( 'upcasts the URL (google maps)', () => { + testMediaUpcast( [ + 'https://www.google.com/maps/foo', + 'www.google.com/maps/foo', + 'google.com/maps/foo' + ] ); + } ); + + it( 'upcasts the URL (flickr)', () => { + testMediaUpcast( [ + 'https://www.flickr.com/foo/bar', + 'www.flickr.com/foo/bar', + 'flickr.com/foo/bar' + ] ); + } ); + + it( 'upcasts the URL (facebook)', () => { + testMediaUpcast( [ + 'https://www.facebook.com/foo/bar', + 'www.facebook.com/foo/bar', + 'facebook.com/foo/bar' + ] ); + } ); + } ); + } ); + } ); + + describe( '#extraProviders', () => { + it( 'should warn when provider has no name', () => { + const consoleWarnStub = sinon.stub( console, 'warn' ); + const provider = { + url: /.*/ + }; + + return createTestEditor( { + extraProviders: [ provider ] + } ).then( () => { + expect( consoleWarnStub.calledOnce ).to.equal( true ); + expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name/ ); + expect( consoleWarnStub.firstCall.args[ 1 ].provider ).to.deep.equal( provider ); + } ); + } ); + + it( 'extend #providers but with the lower priority', () => { + return createTestEditor( { + providers: [ + testProviders.A + ], + extraProviders: [ + testProviders.extraA, + testProviders.extraB + ] + } ).then( editor => { + editor.setData( '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'A, id=123' + + '
' + + '
' + ); + + editor.setData( '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'extraB, id=123' + + '
' + + '
' + ); + } ); + } ); + } ); + + describe( '#removeProviders', () => { + it( 'removes #providers', () => { + return createTestEditor( { + providers: [ + testProviders.A, + testProviders.B + ], + removeProviders: [ 'A' ] + } ).then( editor => { + editor.setData( + '
' + + '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'B, id=123' + + '
' + + '
' + ); + } ); + } ); + + it( 'removes #extraProviders', () => { + return createTestEditor( { + providers: [], + extraProviders: [ + testProviders.A, + testProviders.B + ], + removeProviders: [ 'A' ] + } ).then( editor => { + editor.setData( + '
' + + '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'B, id=123' + + '
' + + '
' + ); + } ); + } ); + + it( 'removes even when the name of the provider repeats', () => { + return createTestEditor( { + providers: [ + testProviders.A, + testProviders.A + ], + extraProviders: [ + testProviders.A, + testProviders.A, + testProviders.B + ], + removeProviders: [ 'A' ] + } ).then( editor => { + editor.setData( + '
' + + '
' ); + + expect( getViewData( editor.editing.view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'B, id=123' + + '
' + + '
' + ); + } ); + } ); + } ); + } ); + } ); + + describe( 'init()', () => { + const providerDefinitions = [ + testProviders.previewLess, + testProviders.allowEverything + ]; + + it( 'should be loaded', () => { + return createTestEditor() + .then( newEditor => { + expect( newEditor.plugins.get( MediaEmbedEditing ) ).to.be.instanceOf( MediaEmbedEditing ); + } ); + } ); + + it( 'should set proper schema rules', () => { + return createTestEditor() + .then( newEditor => { + model = newEditor.model; + + expect( model.schema.checkChild( [ '$root' ], 'media' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'media' ], 'url' ) ).to.be.true; + + expect( model.schema.isObject( 'media' ) ).to.be.true; + + expect( model.schema.checkChild( [ '$root', 'media' ], 'media' ) ).to.be.false; + expect( model.schema.checkChild( [ '$root', 'media' ], '$text' ) ).to.be.false; + expect( model.schema.checkChild( [ '$root', '$block' ], 'image' ) ).to.be.false; + } ); + } ); + + describe( 'conversion in the data pipeline', () => { + describe( 'previewsInData=false', () => { + beforeEach( () => { + return createTestEditor( { + providers: providerDefinitions + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + view = editor.editing.view; + } ); + } ); + + describe( 'model to view', () => { + it( 'should convert', () => { + setModelData( model, '' ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '
' ); + } ); + + it( 'should convert (no url)', () => { + setModelData( model, '' ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '
' ); + } ); + + it( 'should convert (preview-less media)', () => { + setModelData( model, '' ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '
' ); + } ); + } ); + + describe( 'view to model', () => { + it( 'should convert media figure', () => { + editor.setData( '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if there is no media class', () => { + editor.setData( '
My quote
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if there is no oembed wrapper inside #1', () => { + editor.setData( '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if there is no oembed wrapper inside #2', () => { + editor.setData( '
test
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert when the wrapper has no data-oembed-url attribute', () => { + editor.setData( '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert in the wrong context', () => { + model.schema.register( 'blockquote', { inheritAllFrom: '$block' } ); + model.schema.addChildCheck( ( ctx, childDef ) => { + if ( ctx.endsWith( '$root' ) && childDef.name == 'media' ) { + return false; + } + } ); + + editor.conversion.elementToElement( { model: 'blockquote', view: 'blockquote' } ); + + editor.setData( + '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '
' ); + } ); + + it( 'should not convert if the oembed wrapper is already consumed', () => { + editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => { + const img = data.viewItem.getChild( 0 ); + conversionApi.consumable.consume( img, { name: true } ); + }, { priority: 'high' } ); + + editor.setData( '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if the figure is already consumed', () => { + editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { name: true, class: 'image' } ); + }, { priority: 'high' } ); + + editor.setData( '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should discard the contents of the media', () => { + editor.setData( '
foo bar
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert unknown media', () => { + return createTestEditor( { + providers: [ + testProviders.A + ] + } ) + .then( newEditor => { + newEditor.setData( + '
' + + '
' ); + + expect( getModelData( newEditor.model, { withoutSelection: true } ) ) + .to.equal( '' ); + + return newEditor.destroy(); + } ); + } ); + } ); + } ); + + describe( 'previewsInData=true', () => { + beforeEach( () => { + return createTestEditor( { + providers: providerDefinitions, + previewsInData: true + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + view = editor.editing.view; + } ); + } ); + + describe( 'conversion in the data pipeline', () => { + describe( 'model to view', () => { + it( 'should convert', () => { + setModelData( model, '' ); + + expect( editor.getData() ).to.equal( + '
' + + '
' + + 'allow-everything, id=https://ckeditor.com' + + '
' + + '
' ); + } ); + + it( 'should convert (no url)', () => { + setModelData( model, '' ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '' + + '
' ); + } ); + + it( 'should convert (preview-less media)', () => { + setModelData( model, '' ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '
' ); + } ); + } ); + + describe( 'view to model', () => { + it( 'should convert media figure', () => { + editor.setData( + '
' + + '
' + + 'allow-everything, id=https://cksource.com>' + + '
' + + '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if there is no media class', () => { + editor.setData( '
My quote
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if there is no oembed wrapper inside #1', () => { + editor.setData( '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if there is no oembed wrapper inside #2', () => { + editor.setData( '
test
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert in the wrong context', () => { + model.schema.register( 'div', { inheritAllFrom: '$block' } ); + model.schema.addChildCheck( ( ctx, childDef ) => { + if ( ctx.endsWith( '$root' ) && childDef.name == 'media' ) { + return false; + } + } ); + + editor.conversion.elementToElement( { model: 'div', view: 'div' } ); + + editor.setData( + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '
' ); + } ); + + it( 'should not convert if the oembed wrapper is already consumed', () => { + editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => { + const img = data.viewItem.getChild( 0 ); + conversionApi.consumable.consume( img, { name: true } ); + }, { priority: 'high' } ); + + editor.setData( + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert if the figure is already consumed', () => { + editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { name: true, class: 'image' } ); + }, { priority: 'high' } ); + + editor.setData( '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should discard the contents of the media', () => { + editor.setData( + '
' + + '
' + + 'foo bar baz' + + '
' + + '
' ); + + expect( getModelData( model, { withoutSelection: true } ) ) + .to.equal( '' ); + } ); + + it( 'should not convert unknown media', () => { + return createTestEditor( { + providers: [ + testProviders.A + ] + } ) + .then( newEditor => { + newEditor.setData( + '
' + + '
' + + '
' + + '
' + + '
' + + '
' ); + + expect( getModelData( newEditor.model, { withoutSelection: true } ) ) + .to.equal( '' ); + + return newEditor.destroy(); + } ); + } ); + } ); + } ); + } ); + } ); + + describe( 'conversion in the editing pipeline', () => { + describe( 'previewsInData=false', () => { + beforeEach( () => { + return createTestEditor( { + providers: providerDefinitions + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + view = editor.editing.view; + } ); + } ); + + test(); + } ); + + describe( 'previewsInData=true', () => { + beforeEach( () => { + return createTestEditor( { + providers: providerDefinitions, + previewsInData: true + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + doc = model.document; + view = editor.editing.view; + } ); + } ); + + test(); + } ); + + function test() { + describe( 'model to view', () => { + it( 'should convert', () => { + setModelData( model, '' ); + + expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'allow-everything, id=https://ckeditor.com' + + '
' + + '
' + ); + } ); + + it( 'should convert the url attribute change', () => { + setModelData( model, '' ); + const media = doc.getRoot().getChild( 0 ); + + model.change( writer => { + writer.setAttribute( 'url', 'https://cksource.com', media ); + } ); + + expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'allow-everything, id=https://cksource.com' + + '
' + + '
' + ); + } ); + + it( 'should convert the url attribute removal', () => { + setModelData( model, '' ); + const media = doc.getRoot().getChild( 0 ); + + model.change( writer => { + writer.removeAttribute( 'url', media ); + } ); + + expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) ) + .to.equal( + '
' + + '
' + + '
' + + '
' + ); + } ); + + it( 'should not convert the url attribute removal if is already consumed', () => { + setModelData( model, '' ); + const media = doc.getRoot().getChild( 0 ); + + editor.editing.downcastDispatcher.on( 'attribute:url:media', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, 'attribute:url' ); + }, { priority: 'high' } ); + + model.change( writer => { + writer.removeAttribute( 'url', media ); + } ); + + expect( getViewData( view, { withoutSelection: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'allow-everything, id=https://ckeditor.com' + + '
' + + '
' + ); + } ); + + // Related to https://github.com/ckeditor/ckeditor5/issues/407. + it( 'should not discard internals (e.g. UI) injected by other features when converting the url attribute', () => { + setModelData( model, '' ); + const media = doc.getRoot().getChild( 0 ); + + editor.editing.view.change( writer => { + const widgetViewElement = editor.editing.mapper.toViewElement( media ); + + const externalUIElement = writer.createUIElement( 'div', null, function( domDocument ) { + const domElement = this.toDomElement( domDocument ); + + domElement.innerHTML = 'external UI'; + + return domElement; + } ); + + writer.insert( writer.createPositionAt( widgetViewElement, 'end' ), externalUIElement ); + } ); + + expect( getViewData( view, { withoutSelection: true, renderUIElements: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'allow-everything, id=https://ckeditor.com' + + '
' + + '
external UI
' + + '
' + ); + + model.change( writer => { + writer.setAttribute( 'url', 'https://cksource.com', media ); + } ); + + expect( getViewData( view, { withoutSelection: true, renderUIElements: true, renderRawElements: true } ) ).to.equal( + '
' + + '
' + + 'allow-everything, id=https://cksource.com' + + '
' + + '
external UI
' + + '
' + ); + } ); + } ); + } + } ); + } ); + + function testMediaUpcast( urls, expected ) { + for ( const url of urls ) { + editor.setData( `
` ); + + const viewData = getViewData( view, { withoutSelection: true, renderRawElements: true } ); + let expectedRegExp; + + const expectedUrl = url.match( /^https?:\/\// ) ? url : 'https://' + url; + + if ( expected ) { + expectedRegExp = new RegExp( + ']+>' + + ']+>' + + normalizeHtml( expected ) + + '' + + '' ); + } else { + expectedRegExp = new RegExp( + ']+>' + + ']+>' + + '' + + '' + + '' ); + } + + expect( normalizeHtml( viewData ) ).to.match( expectedRegExp, `assertion for "${ url }"` ); + } + } + + function createTestEditor( mediaEmbedConfig ) { + return VirtualTestEditor + .create( { + plugins: [ MediaEmbedEditing ], + mediaEmbed: mediaEmbedConfig + } ); + } +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/mediaembedtoolbar.js b/packages/ckeditor5-media-embed-eduflow/tests/mediaembedtoolbar.js new file mode 100644 index 00000000000..6bd87074ce0 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/mediaembedtoolbar.js @@ -0,0 +1,274 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document, console */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor'; +import MediaEmbed from '../src/mediaembed'; +import MediaEmbedToolbar from '../src/mediaembedtoolbar'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import View from '@ckeditor/ckeditor5-ui/src/view'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'MediaEmbedToolbar', () => { + let editor, element, widgetToolbarRepository, balloon, toolbar, model; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + return ClassicTestEditor.create( element, { + plugins: [ Paragraph, MediaEmbed, MediaEmbedToolbar, FakeButton ], + mediaEmbed: { + toolbar: [ 'fake_button' ] + } + } ).then( _editor => { + editor = _editor; + model = editor.model; + widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + toolbar = widgetToolbarRepository._toolbarDefinitions.get( 'mediaEmbed' ).view; + balloon = editor.plugins.get( 'ContextualBalloon' ); + } ); + } ); + + afterEach( () => { + return editor.destroy() + .then( () => element.remove() ); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( MediaEmbedToolbar ) ).to.be.instanceOf( MediaEmbedToolbar ); + } ); + + describe( 'toolbar', () => { + it( 'should use the config.table.tableWidget to create items', () => { + expect( toolbar.items ).to.have.length( 1 ); + expect( toolbar.items.get( 0 ).label ).to.equal( 'fake button' ); + } ); + + it( 'should set proper CSS classes', () => { + const spy = sinon.spy( balloon, 'add' ); + + editor.ui.focusTracker.isFocused = true; + + setData( model, '[]' ); + + sinon.assert.calledWithMatch( spy, sinon.match( ( { balloonClassName, view } ) => { + return view === toolbar && balloonClassName === 'ck-toolbar-container'; + } ) ); + } ); + + it( 'should set aria-label attribute', () => { + toolbar.render(); + + expect( toolbar.element.getAttribute( 'aria-label' ) ).to.equal( 'Media toolbar' ); + + toolbar.destroy(); + } ); + } ); + + describe( 'integration with the editor focus', () => { + it( 'should show the toolbar when the editor gains focus and the media widget is selected', () => { + editor.ui.focusTracker.isFocused = true; + + setData( editor.model, '[]' ); + + editor.ui.focusTracker.isFocused = false; + expect( balloon.visibleView ).to.be.null; + + editor.ui.focusTracker.isFocused = true; + expect( balloon.visibleView ).to.equal( toolbar ); + } ); + + it( 'should hide the toolbar when the editor loses focus and the media widget is selected', () => { + editor.ui.focusTracker.isFocused = false; + + setData( editor.model, '[]' ); + + editor.ui.focusTracker.isFocused = true; + expect( balloon.visibleView ).to.equal( toolbar ); + + editor.ui.focusTracker.isFocused = false; + expect( balloon.visibleView ).to.be.null; + } ); + } ); + + describe( 'integration with the editor selection', () => { + beforeEach( () => { + editor.ui.focusTracker.isFocused = true; + } ); + + it( 'should show the toolbar on ui#update when the media widget is selected', () => { + setData( editor.model, '[foo]' ); + + expect( balloon.visibleView ).to.be.null; + + editor.ui.fire( 'update' ); + + expect( balloon.visibleView ).to.be.null; + + editor.model.change( writer => { + // Select the [] + writer.setSelection( editor.model.document.getRoot().getChild( 1 ), 'on' ); + } ); + + expect( balloon.visibleView ).to.equal( toolbar ); + + // Make sure successive change does not throw, e.g. attempting + // to insert the toolbar twice. + editor.ui.fire( 'update' ); + expect( balloon.visibleView ).to.equal( toolbar ); + } ); + + it( 'should not engage when the toolbar is in the balloon yet invisible', () => { + setData( editor.model, '' ); + + expect( balloon.visibleView ).to.equal( toolbar ); + + const lastView = new View(); + lastView.element = document.createElement( 'div' ); + + balloon.add( { + view: lastView, + position: { + target: document.body + } + } ); + + expect( balloon.visibleView ).to.equal( lastView ); + + editor.ui.fire( 'update' ); + + expect( balloon.visibleView ).to.equal( lastView ); + } ); + + it( 'should hide the toolbar on ui#update if the media is de–selected', () => { + setData( model, 'foo[]' ); + + expect( balloon.visibleView ).to.equal( toolbar ); + + model.change( writer => { + // Select the [...] + writer.setSelection( model.document.getRoot().getChild( 0 ), 'in' ); + } ); + + expect( balloon.visibleView ).to.be.null; + + // Make sure successive change does not throw, e.g. attempting + // to remove the toolbar twice. + editor.ui.fire( 'update' ); + expect( balloon.visibleView ).to.be.null; + } ); + } ); +} ); + +describe( 'MediaEmbedToolbar - integration with BalloonEditor', () => { + let clock, editor, balloonToolbar, element, widgetToolbarRepository, balloon, toolbar, model; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + clock = testUtils.sinon.useFakeTimers(); + + return BalloonEditor.create( element, { + plugins: [ Paragraph, MediaEmbed, MediaEmbedToolbar, FakeButton, Bold ], + balloonToolbar: [ 'bold' ], + mediaEmbed: { + toolbar: [ 'fake_button' ] + } + } ).then( _editor => { + editor = _editor; + model = editor.model; + widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + toolbar = widgetToolbarRepository._toolbarDefinitions.get( 'mediaEmbed' ).view; + balloon = editor.plugins.get( 'ContextualBalloon' ); + balloonToolbar = editor.plugins.get( 'BalloonToolbar' ); + + editor.ui.focusTracker.isFocused = true; + } ); + } ); + + afterEach( () => { + return editor.destroy() + .then( () => element.remove() ); + } ); + + it( 'balloon toolbar should be hidden when media widget is selected', () => { + setData( model, '[abc]' ); + editor.editing.view.document.isFocused = true; + + expect( balloon.visibleView ).to.equal( null ); + + model.change( writer => { + // Select the [] + writer.setSelection( model.document.getRoot().getChild( 1 ), 'on' ); + } ); + + expect( balloon.visibleView ).to.equal( toolbar ); + + clock.tick( 200 ); + + expect( balloon.visibleView ).to.equal( toolbar ); + } ); + + it( 'balloon toolbar should be visible when media widget is not selected', () => { + setData( model, 'abc[]' ); + editor.editing.view.document.isFocused = true; + + expect( balloon.visibleView ).to.equal( toolbar ); + + model.change( writer => { + // Select the [abc] + writer.setSelection( model.document.getRoot().getChild( 0 ), 'in' ); + } ); + + clock.tick( 200 ); + + expect( balloon.visibleView ).to.equal( balloonToolbar.toolbarView ); + } ); + + it( 'does not create the toolbar if its items are not specified', () => { + const consoleWarnStub = sinon.stub( console, 'warn' ); + const element = document.createElement( 'div' ); + + return BalloonEditor.create( element, { + plugins: [ Paragraph, MediaEmbed, MediaEmbedToolbar, Bold ] + } ).then( editor => { + widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + + expect( widgetToolbarRepository._toolbarDefinitions.get( 'mediaEmbed' ) ).to.be.undefined; + expect( consoleWarnStub.calledOnce ).to.equal( true ); + expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^widget-toolbar-no-items/ ); + + element.remove(); + return editor.destroy(); + } ); + } ); +} ); + +// Plugin that adds fake_button to editor's component factory. +class FakeButton extends Plugin { + init() { + this.editor.ui.componentFactory.add( 'fake_button', locale => { + const view = new ButtonView( locale ); + + view.set( { + label: 'fake button' + } ); + + return view; + } ); + } +} diff --git a/packages/ckeditor5-media-embed-eduflow/tests/mediaembedui.js b/packages/ckeditor5-media-embed-eduflow/tests/mediaembedui.js new file mode 100644 index 00000000000..cd3d08b0861 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/mediaembedui.js @@ -0,0 +1,263 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import MediaEmbed from '../src/mediaembed'; +import MediaEmbedUI from '../src/mediaembedui'; +import MediaFormView from '../src/ui/mediaformview'; +import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview'; +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import mediaIcon from '../theme/icons/media.svg'; + +describe( 'MediaEmbedUI', () => { + let editorElement, editor, dropdown, button, form; + + beforeEach( () => { + editorElement = global.document.createElement( 'div' ); + global.document.body.appendChild( editorElement ); + + return ClassicTestEditor + .create( editorElement, { + plugins: [ MediaEmbed ], + mediaEmbed: { + providers: [ + { + name: 'valid-media', + url: /^https:\/\/valid\/(.*)/, + html: id => `` + } + ] + } + } ) + .then( newEditor => { + editor = newEditor; + dropdown = editor.ui.componentFactory.create( 'mediaEmbed' ); + button = dropdown.buttonView; + form = dropdown.panelView.children.get( 0 ); + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should be named', () => { + expect( MediaEmbedUI.pluginName ).to.equal( 'MediaEmbedUI' ); + } ); + + it( 'should add the "mediaEmbed" component to the factory', () => { + expect( dropdown ).to.be.instanceOf( DropdownView ); + } ); + + it( 'should allow creating two instances', () => { + let secondInstance; + + expect( function createSecondInstance() { + secondInstance = editor.ui.componentFactory.create( 'mediaEmbed' ); + } ).not.to.throw(); + expect( dropdown ).to.not.equal( secondInstance ); + } ); + + describe( 'dropdown', () => { + it( 'should bind #isEnabled to the command', () => { + const command = editor.commands.get( 'mediaEmbed' ); + + expect( dropdown.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( dropdown.isEnabled ).to.be.false; + } ); + + it( 'should add a form to the panelView#children collection', () => { + expect( dropdown.panelView.children.length ).to.equal( 1 ); + expect( dropdown.panelView.children.get( 0 ) ).to.be.instanceOf( MediaFormView ); + } ); + + describe( 'button', () => { + it( 'should set a #label of the #buttonView', () => { + expect( dropdown.buttonView.label ).to.equal( 'Insert media' ); + } ); + + it( 'should set an #icon of the #buttonView', () => { + expect( dropdown.buttonView.icon ).to.equal( mediaIcon ); + } ); + + it( 'should enable tooltips for the #buttonView', () => { + expect( dropdown.buttonView.tooltip ).to.be.true; + } ); + + describe( '#open event', () => { + it( 'executes the actions with the "low" priority', () => { + const spy = sinon.spy(); + const selectSpy = sinon.spy( form.urlInputView.fieldView, 'select' ); + + button.on( 'open', () => { + spy(); + } ); + + button.fire( 'open' ); + sinon.assert.callOrder( spy, selectSpy ); + } ); + + it( 'should update form\'s #url', () => { + const command = editor.commands.get( 'mediaEmbed' ); + + button.fire( 'open' ); + expect( form.url ).to.equal( '' ); + + command.value = 'foo'; + button.fire( 'open' ); + expect( form.url ).to.equal( 'foo' ); + } ); + + it( 'should select the content of the input', () => { + const spy = sinon.spy( form.urlInputView.fieldView, 'select' ); + + button.fire( 'open' ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'should focus the form', () => { + const spy = sinon.spy( form, 'focus' ); + + button.fire( 'open' ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'should disable CSS transitions to avoid unnecessary animations (and then enable them again)', () => { + const disableCssTransitionsSpy = sinon.spy( form, 'disableCssTransitions' ); + const enableCssTransitionsSpy = sinon.spy( form, 'enableCssTransitions' ); + const selectSpy = sinon.spy( form.urlInputView.fieldView, 'select' ); + + button.fire( 'open' ); + + sinon.assert.callOrder( disableCssTransitionsSpy, selectSpy, enableCssTransitionsSpy ); + } ); + } ); + } ); + + describe( '#submit event', () => { + it( 'checks if the form is valid', () => { + const spy = sinon.spy( form, 'isValid' ); + + dropdown.fire( 'submit' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'executes the command and closes the UI (if the form is valid)', () => { + const viewFocusSpy = sinon.spy( editor.editing.view, 'focus' ); + const commandSpy = sinon.spy( editor.commands.get( 'mediaEmbed' ), 'execute' ); + + // The form is invalid. + form.url = 'https://invalid/url'; + dropdown.isOpen = true; + + dropdown.fire( 'submit' ); + + sinon.assert.notCalled( commandSpy ); + sinon.assert.notCalled( viewFocusSpy ); + expect( dropdown.isOpen ).to.be.true; + + // The form is valid. + form.url = 'https://valid/url'; + dropdown.fire( 'submit' ); + + sinon.assert.calledOnce( commandSpy ); + sinon.assert.calledWithExactly( commandSpy, 'https://valid/url' ); + sinon.assert.calledOnce( viewFocusSpy ); + expect( dropdown.isOpen ).to.be.false; + } ); + } ); + + describe( '#change:isOpen event', () => { + it( 'resets form status', () => { + const spy = sinon.spy( form, 'resetFormStatus' ); + + dropdown.fire( 'change:isOpen' ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( '#cancel event', () => { + it( 'closes the UI', () => { + const viewFocusSpy = sinon.spy( editor.editing.view, 'focus' ); + + dropdown.isOpen = true; + dropdown.fire( 'cancel' ); + + sinon.assert.calledOnce( viewFocusSpy ); + expect( dropdown.isOpen ).to.be.false; + } ); + } ); + } ); + + describe( 'form', () => { + it( 'delegates #submit to the dropdown', done => { + dropdown.once( 'submit', () => done() ); + + form.fire( 'submit' ); + } ); + + it( 'delegates #cancel to the dropdown', done => { + dropdown.once( 'submit', () => done() ); + + form.fire( 'submit' ); + } ); + + it( 'binds urlInputView#isReadOnly to command#isEnabled', () => { + const command = editor.commands.get( 'mediaEmbed' ); + + expect( form.urlInputView.isReadOnly ).to.be.false; + + command.isEnabled = false; + expect( form.urlInputView.isReadOnly ).to.be.true; + } ); + + it( 'should trim URL input value', () => { + form.urlInputView.fieldView.element.value = ' '; + form.urlInputView.fieldView.fire( 'input' ); + + expect( form.mediaURLInputValue ).to.equal( '' ); + + form.urlInputView.fieldView.element.value = ' test '; + form.urlInputView.fieldView.fire( 'input' ); + + expect( form.mediaURLInputValue ).to.equal( 'test' ); + } ); + + it( 'binds saveButtonView#isEnabled to trimmed URL input value', () => { + form.urlInputView.fieldView.fire( 'input' ); + + expect( form.saveButtonView.isEnabled ).to.be.false; + + form.urlInputView.fieldView.element.value = 'test'; + form.urlInputView.fieldView.fire( 'input' ); + + expect( form.saveButtonView.isEnabled ).to.be.true; + } ); + + describe( 'validators', () => { + it( 'check the empty URL', () => { + form.url = ''; + expect( form.isValid() ).to.be.false; + + form.url = 'https://valid/url'; + expect( form.isValid() ).to.be.true; + } ); + + it( 'check the supported media', () => { + form.url = 'https://invalid/url'; + expect( form.isValid() ).to.be.false; + + form.url = 'https://valid/url'; + expect( form.isValid() ).to.be.true; + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/mediaregistry.js b/packages/ckeditor5-media-embed-eduflow/tests/mediaregistry.js new file mode 100644 index 00000000000..9495ea9a337 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/mediaregistry.js @@ -0,0 +1,125 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console */ + +import MediaRegistry from '../src/mediaregistry'; + +describe( 'MediaRegistry', () => { + describe( 'constructor()', () => { + it( 'filters out providers that should be removed', () => { + const providers = [ + { name: 'dailymotion', url: [] }, + { name: 'spotify', url: [] }, + { name: 'youtube', url: [] }, + { name: 'vimeo', url: [] } + ]; + const removeProviders = [ 'spotify' ]; + + const mediaRegistry = new MediaRegistry( {}, { providers, removeProviders } ); + const availableProviders = mediaRegistry.providerDefinitions.map( provider => provider.name ); + + expect( availableProviders ).to.deep.equal( [ 'dailymotion', 'youtube', 'vimeo' ] ); + } ); + + it( 'allows extending providers using `extraProviders` option', () => { + const providers = [ + { name: 'dailymotion', url: [] }, + { name: 'youtube', url: [] }, + { name: 'vimeo', url: [] } + ]; + const extraProviders = [ + { name: 'spotify', url: [] } + ]; + + const mediaRegistry = new MediaRegistry( {}, { providers, extraProviders } ); + const availableProviders = mediaRegistry.providerDefinitions.map( provider => provider.name ); + + expect( availableProviders ).to.deep.equal( [ 'dailymotion', 'youtube', 'vimeo', 'spotify' ] ); + } ); + + it( 'logs a warning when provider\'s name is not defined', () => { + const consoleWarnStub = sinon.stub( console, 'warn' ); + + const providers = [ + { url: [ /dailymotion\.com/ ] }, + { name: 'spotify', url: [] }, + { name: 'youtube', url: [] }, + { name: 'vimeo', url: [] } + ]; + + const mediaRegistry = new MediaRegistry( {}, { providers } ); + const availableProviders = mediaRegistry.providerDefinitions.map( provider => provider.name ); + + expect( availableProviders ).to.deep.equal( [ 'spotify', 'youtube', 'vimeo' ] ); + expect( consoleWarnStub.calledOnce ).to.equal( true ); + expect( consoleWarnStub.firstCall.args[ 0 ] ).to.match( /^media-embed-no-provider-name/ ); + expect( consoleWarnStub.firstCall.args[ 1 ] ).to.deep.equal( { provider: { url: [ /dailymotion\.com/ ] } } ); + } ); + } ); + + describe( '_getMedia()', () => { + let mediaRegistry, htmlSpy; + + beforeEach( () => { + htmlSpy = sinon.spy(); + + mediaRegistry = new MediaRegistry( {}, { + providers: [ + { + name: 'youtube', + url: [ + /^youtube\.com\/watch\?v=([\w-]+)/, + /^youtube\.com\/v\/([\w-]+)/, + /^youtube\.com\/embed\/([\w-]+)/, + /^youtu\.be\/([\w-]+)/ + ], + html: htmlSpy + } + ] + } ); + } ); + + it( 'works fine for url with sub-domain and the protocol', () => { + const media = mediaRegistry._getMedia( 'https://www.youtube.com/watch?v=euqbMkM-QQk' ); + + expect( media ).is.not.null; + expect( media.url ).to.equal( 'https://www.youtube.com/watch?v=euqbMkM-QQk' ); + } ); + + it( 'works fine for url with defined protocol', () => { + const media = mediaRegistry._getMedia( 'https://youtube.com/watch?v=euqbMkM-QQk' ); + + expect( media ).is.not.null; + expect( media.url ).to.equal( 'https://youtube.com/watch?v=euqbMkM-QQk' ); + } ); + + it( 'works fine for url with sub-domain without protocol', () => { + const media = mediaRegistry._getMedia( 'www.youtube.com/watch?v=euqbMkM-QQk' ); + + expect( media ).is.not.null; + expect( media.url ).to.equal( 'https://www.youtube.com/watch?v=euqbMkM-QQk' ); + } ); + + it( 'works fine for url without protocol', () => { + const media = mediaRegistry._getMedia( 'youtube.com/watch?v=euqbMkM-QQk' ); + + expect( media ).is.not.null; + expect( media.url ).to.equal( 'https://youtube.com/watch?v=euqbMkM-QQk' ); + } ); + + it( 'passes the entire match array to render function', () => { + const media = mediaRegistry._getMedia( 'https://www.youtube.com/watch?v=euqbMkM-QQk' ); + + media._getPreviewHtml(); + + expect( htmlSpy.calledOnce ).to.equal( true ); + expect( htmlSpy.firstCall.args[ 0 ] ).to.deep.equal( [ + 'youtube.com/watch?v=euqbMkM-QQk', + 'euqbMkM-QQk' + ] ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/tests/ui/mediaformview.js b/packages/ckeditor5-media-embed-eduflow/tests/ui/mediaformview.js new file mode 100644 index 00000000000..7d5bc62dddb --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/tests/ui/mediaformview.js @@ -0,0 +1,319 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals Event */ + +import MediaFormView from '../../src/ui/mediaformview'; +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'; + +describe( 'MediaFormView', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new MediaFormView( [], { t: val => val } ); + view.render(); + } ); + + describe( 'constructor()', () => { + it( 'accepts validators', () => { + const validators = []; + const view = new MediaFormView( validators, { t: val => val } ); + + expect( view._validators ).to.equal( validators ); + } ); + + it( 'should create element from template', () => { + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-media-form' ) ).to.true; + expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'should create child views', () => { + expect( view.urlInputView ).to.be.instanceOf( View ); + expect( view.saveButtonView ).to.be.instanceOf( View ); + expect( view.cancelButtonView ).to.be.instanceOf( View ); + + expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true; + expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true; + + 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 ); + } ); + + 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 "cancel" event on cancelButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'cancel', spy ); + + view.cancelButtonView.fire( 'execute' ); + + expect( spy.calledOnce ).to.true; + } ); + + it( 'should implement the CSS transition disabling feature', () => { + expect( view.disableCssTransitions ).to.be.a( 'function' ); + } ); + + describe( 'url input view', () => { + it( 'has info text', () => { + expect( view.urlInputView.infoText ).to.match( /^Paste the media URL/ ); + } ); + + it( 'displays the tip upon #input when the field has a value', () => { + view.urlInputView.fieldView.element.value = 'foo'; + view.urlInputView.fieldView.fire( 'input' ); + + expect( view.urlInputView.infoText ).to.match( /^Tip: Paste the URL into/ ); + + view.urlInputView.fieldView.element.value = ''; + view.urlInputView.fieldView.fire( 'input' ); + + expect( view.urlInputView.infoText ).to.match( /^Paste the media URL/ ); + } ); + } ); + + describe( 'template', () => { + it( 'has url input view', () => { + expect( view.template.children[ 0 ] ).to.equal( view.urlInputView ); + } ); + + it( 'has button views', () => { + expect( view.template.children[ 1 ] ).to.equal( view.saveButtonView ); + expect( view.template.children[ 2 ] ).to.equal( view.cancelButtonView ); + } ); + } ); + } ); + + describe( 'render()', () => { + it( 'should register child views in #_focusables', () => { + expect( view._focusables.map( f => f ) ).to.have.members( [ + view.urlInputView, + view.saveButtonView, + view.cancelButtonView + ] ); + } ); + + it( 'should register child views\' #element in #focusTracker', () => { + view = new MediaFormView( [], { t: () => {} } ); + + const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); + + view.render(); + + 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 ); + } ); + + it( 'starts listening for #keystrokes coming from #element', () => { + view = new MediaFormView( [], { 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 url input is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.urlInputView.element; + + const spy = sinon.spy( view.saveButtonView, '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 cancel button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.cancelButtonView.element; + + const spy = sinon.spy( view.saveButtonView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + + it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => { + const keyEvtData = { + stopPropagation: sinon.spy() + }; + + keyEvtData.keyCode = keyCodes.arrowdown; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowup; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledTwice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowleft; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledThrice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowright; + view.keystrokes.press( keyEvtData ); + sinon.assert.callCount( keyEvtData.stopPropagation, 4 ); + } ); + + it( 'intercepts the "selectstart" event of the #urlInputView with the high priority', () => { + const spy = sinon.spy(); + const event = new Event( 'selectstart', { + bubbles: true, + cancelable: true + } ); + + event.stopPropagation = spy; + + view.urlInputView.element.dispatchEvent( event ); + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'DOM bindings', () => { + describe( 'submit event', () => { + it( 'should trigger submit event', () => { + const spy = sinon.spy(); + + view.on( 'submit', spy ); + view.element.dispatchEvent( new Event( 'submit' ) ); + + expect( spy.calledOnce ).to.true; + } ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the #urlInputView', () => { + const spy = sinon.spy( view.urlInputView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'url()', () => { + it( 'returns the #inputView DOM value', () => { + view.urlInputView.fieldView.element.value = 'foo'; + + expect( view.url ).to.equal( 'foo' ); + } ); + + it( 'sets the #inputView DOM value', () => { + view.urlInputView.fieldView.element.value = 'bar'; + + view.url = 'foo'; + expect( view.urlInputView.fieldView.element.value ).to.equal( 'foo' ); + + view.url = ' baz '; + expect( view.urlInputView.fieldView.element.value ).to.equal( 'baz' ); + } ); + } ); + + describe( 'isValid()', () => { + it( 'calls resetFormStatus()', () => { + const spy = sinon.spy( view, 'resetFormStatus' ); + + view.isValid(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'returns false when at least one validator has failed', () => { + const val1 = sinon.stub().returns( 'some error' ); + const val2 = sinon.stub().returns( false ); + const validators = [ val1, val2 ]; + const view = new MediaFormView( validators, { t: val => val } ); + + expect( view.isValid() ).to.be.false; + + sinon.assert.calledOnce( val1 ); + sinon.assert.notCalled( val2 ); + + expect( view.urlInputView.errorText ).to.equal( 'some error' ); + } ); + + it( 'returns true when all validators passed', () => { + const val1 = sinon.stub().returns( false ); + const val2 = sinon.stub().returns( false ); + const validators = [ val1, val2 ]; + const view = new MediaFormView( validators, { t: val => val } ); + + expect( view.isValid() ).to.be.true; + + sinon.assert.calledOnce( val1 ); + sinon.assert.calledOnce( val2 ); + + expect( view.urlInputView.errorText ).to.be.null; + } ); + } ); + + describe( 'resetFormStatus()', () => { + it( 'resets urlInputView#errorText', () => { + view.urlInputView.errorText = 'foo'; + + view.resetFormStatus(); + + expect( view.urlInputView.errorText ).to.be.null; + } ); + + it( 'resets urlInputView#infoText', () => { + view.urlInputView.infoText = 'foo'; + + view.resetFormStatus(); + + expect( view.urlInputView.infoText ).to.match( /^Paste the media URL/ ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-media-embed-eduflow/theme/icons/media-placeholder.svg b/packages/ckeditor5-media-embed-eduflow/theme/icons/media-placeholder.svg new file mode 100644 index 00000000000..f140b5f7b89 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/theme/icons/media-placeholder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-media-embed-eduflow/theme/icons/media.svg b/packages/ckeditor5-media-embed-eduflow/theme/icons/media.svg new file mode 100644 index 00000000000..1bdb82aae43 --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/theme/icons/media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-media-embed-eduflow/theme/icons/media/twitter.svg b/packages/ckeditor5-media-embed-eduflow/theme/icons/media/twitter.svg new file mode 100755 index 00000000000..6b421ee917f --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/theme/icons/media/twitter.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/packages/ckeditor5-media-embed-eduflow/theme/mediaembed.css b/packages/ckeditor5-media-embed-eduflow/theme/mediaembed.css new file mode 100644 index 00000000000..dc1a578ab7c --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/theme/mediaembed.css @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +.ck-content .media { + /* Don't allow floated content overlap the media. + https://github.com/ckeditor/ckeditor5-media-embed/issues/53 */ + clear: both; + + /* Make sure there is some space between the content and the media. */ + margin: 1em 0; + + /* Make sure media is not overriden with Bootstrap default `flex` value. + See: https://github.com/ckeditor/ckeditor5/issues/1373. */ + display: block; + + /* Give the media some minimal width in the content to prevent them + from being "squashed" in tight spaces, e.g. in table cells (#44) */ + min-width: 15em; +} diff --git a/packages/ckeditor5-media-embed-eduflow/theme/mediaembedediting.css b/packages/ckeditor5-media-embed-eduflow/theme/mediaembedediting.css new file mode 100644 index 00000000000..aa9e4e8fcab --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/theme/mediaembedediting.css @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "@ckeditor/ckeditor5-ui/theme/components/tooltip/mixins/_tooltip.css"; + +.ck-media__wrapper { + & .ck-media__placeholder { + display: flex; + flex-direction: column; + align-items: center; + + & .ck-media__placeholder__url { + @mixin ck-tooltip_enabled; + + /* Otherwise the URL will overflow when the content is very narrow. */ + max-width: 100%; + + position: relative; + + &:hover { + @mixin ck-tooltip_visible; + } + + & .ck-media__placeholder__url__text { + overflow: hidden; + display: block; + } + } + } + + &[data-oembed-url*="twitter.com"], + &[data-oembed-url*="google.com/maps"], + &[data-oembed-url*="facebook.com"], + &[data-oembed-url*="instagram.com"] { + & .ck-media__placeholder__icon * { + display: none; + } + } +} + +/* Disable all mouse interaction as long as the editor is not read–only. + https://github.com/ckeditor/ckeditor5-media-embed/issues/58 */ +.ck-editor__editable:not(.ck-read-only) .ck-media__wrapper > *:not(.ck-media__placeholder) { + pointer-events: none; +} + +/* Disable all mouse interaction when the widget is not selected (e.g. to avoid opening links by accident). + https://github.com/ckeditor/ckeditor5-media-embed/issues/18 */ +.ck-editor__editable:not(.ck-read-only) .ck-widget:not(.ck-widget_selected) .ck-media__placeholder { + pointer-events: none; +} diff --git a/packages/ckeditor5-media-embed-eduflow/theme/mediaform.css b/packages/ckeditor5-media-embed-eduflow/theme/mediaform.css new file mode 100644 index 00000000000..bc7099cb52a --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/theme/mediaform.css @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +.ck.ck-media-form { + display: flex; + align-items: flex-start; + flex-direction: row; + flex-wrap: nowrap; + + & .ck-labeled-field-view { + display: inline-block; + } + + & .ck-label { + display: none; + } + + @mixin ck-media-phone { + flex-wrap: wrap; + + & .ck-labeled-field-view { + flex-basis: 100%; + } + + & .ck-button { + flex-basis: 50%; + } + } +} diff --git a/packages/ckeditor5-media-embed-eduflow/webpack.config.js b/packages/ckeditor5-media-embed-eduflow/webpack.config.js new file mode 100644 index 00000000000..fcaa78a2eed --- /dev/null +++ b/packages/ckeditor5-media-embed-eduflow/webpack.config.js @@ -0,0 +1,17 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +'use strict'; + +/* eslint-env node */ + +const { builds } = require( '@ckeditor/ckeditor5-dev-utils' ); + +module.exports = builds.getDllPluginWebpackConfig( { + themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' ), + packagePath: __dirname, + manifestPath: require.resolve( 'ckeditor5/build/ckeditor5-dll.manifest.json' ), + isDevelopmentMode: process.argv.includes( '--dev' ) +} );