Problem/Motivation

CKEditor 4.5 added HTML5 drag-and-drop file uploads. This can be done by using the uploadwidget.

Proposed resolution

We should support this feature in Drupal 8 core too and add it in a minor release by creating a custom plugin which handles drag-and-drop image uploads.

Remaining tasks

- Add uploadwidget
- Create the new plugin which should automatically open the image dialog when and image is dragged and dropped on the CKEditor textarea
- Usability testing

User interface changes

TBD

API changes

None.

Data model changes

None.

Files: 
CommentFileSizeAuthor
#50 2560457-with-file-50.patch2.71 MBthpoul
#50 interdiff-2560457-with-file-43-50.txt4.71 KBthpoul
#50 2560457-with-blob-as-file-50.patch2.71 MBthpoul
#50 interdiff-2560457-with-blob-as-file-43-50.txt4.77 KBthpoul
#43 2560457-43.patch2.71 MBthpoul
#43 interdiff-2560457-39-43.txt604 bytesthpoul
#42 2560457-42-do-not-test.patch9.44 KBthpoul
#39 2560457-39.patch2.7 MBthpoul
#39 interdiff-2560457-35-39.txt503 bytesthpoul
#36 interdiff-2560457-33-35.txt1.3 KBthpoul
#35 2560457-35.patch2.7 MBthpoul
#33 2560457-33.patch2.7 MBthpoul
#33 interdiff-2560457-32-33.txt2.41 KBthpoul
#32 2560457-32.patch2.7 MBthpoul
#32 interdiff-2560457-28-32.txt624 bytesthpoul
#31 2560457-28-changes-only-do-not-test.patch7.82 KBWim Leers
#28 2560457-28.patch2.7 MBthpoul
#28 interdiff-2560457-26-28.txt3.18 KBthpoul
#26 interdiff.txt3.64 KBWim Leers
#26 2560457-26.patch2.71 MBWim Leers
#23 interdiff-2560457-18-23.txt4 KBthpoul
#23 2560457-23.patch2.7 MBthpoul
#18 2560457-18.patch2.7 MBthpoul
#18 interdiff-2560457-16-18.txt6.91 KBthpoul
#16 2560457-15.patch2.7 MBthpoul
#14 3OayvkBBGY.gif76.84 KBthpoul
#14 2560457-14-no-vendor.txt4.77 KBthpoul
#14 2560457-14.patch2.79 MBthpoul
#9 eH6xm6pEWm.gif291.33 KBthpoul
#9 2560457-9-do-not-test.patch2.26 MBthpoul
#9 2560457-8-9.txt5.06 KBthpoul
#8 2560457-8.patch2.26 MBthpoul

Comments

Wim Leers created an issue. See original summary.

yched’s picture

Might be interesting to reach out to @jcisio, he started to work on an intergration of file drag-n-drop with the D7 scald widgets : https://www.drupal.org/project/direct_upload

yched’s picture

Also, in D8, "media / scald entity embed" is entity_embed, which, if enabled, will also likely want to integrate with a "drag-and-drop image upload" action.

So I guess we'll need to let site admins control what happens on "drag-and-drop image upload", depending on their media handling setup : simple image embed, creation and embed of a media entity, etc...

Wim Leers’s picture

+1 to all that.

But IMO this would initially just skip two steps of uploading an inline image as it exists in D8: rather than having to click the image button and then clicking the "upload" button, you'd just drag in the image and have that open the dialog. That's IMO step 1.

Wim Leers’s picture

Version: 8.1.x-dev » 8.2.x-dev
Wim Leers’s picture

Status: Postponed » Active

No need for this to be postponed.

thpoul’s picture

Assigned: Unassigned » thpoul
Issue summary: View changes
thpoul’s picture

Issue summary: View changes
FileSize
2.26 MB

Let's go with one thing at a time, so it's easier to review. Added uploadwidget.

thpoul’s picture

Status: Active » Needs review
FileSize
5.06 KB
2.26 MB
291.33 KB

In this step I added a new DrupalImageDragNDrop plugin which for now detects when a file is dropped in the CKEditor area and simply opens a the add a new file dialog just to get a simple poc working.

This needs a review to check if it's on the right track.

Drag n Drop

droplet’s picture

IMO, this is not the right way.

I think it should be a centre API for all these uploads.

INPUT -> ACTION -> CALLBACK

1. Drop file
2. via CKEditor event API, trigger drupal new upload API (HTML5 dnd)
3. start upload file ( update the file list )
4. callback to CKEditor event to handle further actions, eg insert it as inline img.

the new API shareable with:
- #2113931: File Field design update
- any file upload plugin

I believe it can help to solve:
#2666746: [PERFORMANCE] Simultaneous file uploads re-posting data

and clientside progress instead of serverside:
#2662932: Drupal.file.progressBar doesn't replace APC_UPLOAD_PROGRESS|UPLOAD_IDENTIFIER in time

Wim Leers’s picture

Assigned: thpoul » mlewand

#9: I'd like to get feedback from @mlewand on this, since this is using a CKEditor API and he's going to be far more knowledgeable about this than me.

#10: I don't see how this is remotely related to #2113931: File Field design update? It sounds like you're thinking/saying/arguing that images uploaded via CKEditor should always also be listed in a @FieldType=file field on the same entity? That's the only way I can make sense of #10. If that's what you mean, I agree that'd be nice, but A) it's not possible to require that, B) making that required would be a huge BC break. This issue is just about making the existing experience nicer. See #4.

mlewand’s picture

Assigned: mlewand » Wim Leers

The code looks good, generally you'll be fine if you follow uploadimage implementation. I'm not able verify how it works at the end because temporarily I'm unable to install D8.

Wim Leers’s picture

Assigned: Wim Leers » Unassigned

because temporarily I'm unable to install D8.

You need to do composer install now — see https://www.drupal.org/node/2648064.


Thanks, @mlewand! Now @thpoul can continue :)

thpoul’s picture

Let's see if we can make it in time to land this on 8.2.

2560457-14-no-vendor.txt contains only the Drupal plugin for easier review. The patch simply adds the CKE uploadwidget plugin in order to make dnd upload work.

Practically this patch uploads a dropped image into a CKEditor instance.
AFAICT there are two main tasks that still need to be done for this to be ready:

  • Inherit the drupalimage and drupalimagecaption functionality
  • Write tests

Drag and Drop upload in CKEditor

Status: Needs review » Needs work

The last submitted patch, 14: 2560457-14.patch, failed testing.

thpoul’s picture

Status: Needs work » Needs review
FileSize
2.7 MB

Oops :P

Wim Leers’s picture

AWESOME WORK!!!

I think there are two big missing things:

  1. Before showing the dialog: this is not doing the "instant preview" of the dropped image, unlike http://sdk.ckeditor.com/samples/fileupload.html#uploading-dropped-and-pa... — it looks like #9 actually had some of the code necessary for that?
  2. After saving the dialog: there's no <img src=…> being inserted yet!

Once that's done, the behavior/experience is similar to that at http://sdk.ckeditor.com/samples/fileupload.html#uploading-dropped-and-pa..., but with one key UX difference: you're forced to enter an alt tag, because Drupal cares about accessibility. (And a bunch of technical differences, of course.)


To address Inherit the drupalimage and drupalimagecaption functionality: do we even need a separate plugin? I don't think we do? I think this can live in the drupalimage CKEditor plugin. I also don't see how it can work otherwise; how could it otherwise work with drupalimagecaption?

IOW: just remove the new PHP code, and just merge the JS code with the existing JS code in drupalimage/plugin.js

thpoul’s picture

Thank you Wim for the review! I think that with this patch we are much closer! Point 2 is resolved. I will work on 1 now.

thpoul’s picture

I am thinking that point 1 might not be something that users need.
Image the following scenarios:

  • User drops image > image dialog opens > user sets alt > user presses save > dialog box closes > image appears on editor exactly where they dropped it
  • User drops image > image dialog opens > user presses cancel > dialog box closes > no image should appear

IMHO in both scenarios we shouldn't have an instant thumbnail (which btw is not instant at all since it has to go through FileReader's readAsDataUrl()) because it wouldn't add anything to the user's experience plus it would require some extra code to handle switching between the temporary thumb and the actual image.

So my question is, do we really need/want point 1?

Wim Leers’s picture

Simulate 3G in your browser. It now takes a long time for your dialog to show up. Then it takes a long time for the image to upload.

Having that preview would make this much more tolerable. It ensures the user sees that things are working.

Wim Leers’s picture

Status: Needs review » Needs work

I now tested #20 myself. With both 3G and 2G simulation.

CKEditor shows a "preview" of the image while the uploading still happens at http://sdk.ckeditor.com/samples/fileupload.html#uploading-dropped-and-pa...

.cke_upload_uploading img { opacity: 0.3; }

I could see that helping things a lot in those "slow network" scenarios.


  1. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -267,6 +267,47 @@
    +        supportedTypes: /image\/(jpeg|png|gif|bmp)/
    ...
    +        if (CKEDITOR.fileTools.isTypeSupported(file, /image\/(jpeg|png|gif|bmp)/)) {
    

    We should hardcode this to match \Drupal\editor\Form\EditorImageDialog::buildForm()'s hardcoded values:

    'file_validate_extensions' => array('gif png jpg jpeg'),
    
  2. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -267,6 +267,47 @@
    +          $(window).on('dialogcreate', function (e, dialog, $element, settings) {
    

    This means every created dialog in the future will have this happen. We need it to happen only once.

  3. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -267,6 +267,47 @@
    +            var uploadButtonId = '#' + form.find('[name=fid_upload_button]').attr('id');
    

    You should not use [name=…], but instead [data-drupal-selector=…].

  4. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -267,6 +267,47 @@
    +            _.each(Drupal.ajax.instances, function (value, key, list) {
    +              if (value !== null && value.selector === uploadButtonId) {
    +                var myAjaxObject = Drupal.ajax.instances[key];
    

    Rather than doing this difficult-to-understand nesting, let's have a helper function to find the right AJAX object.

samuel.mortenson’s picture

  1. +            _.each(Drupal.ajax.instances, function (value, key, list) {
    

    You'll need to pass Underscore to the outer function/closure with CKEDITOR and jQuery. You also use $.each later on, could we be consistent and use one or the other?

  2. +        supportedTypes: /image\/(jpeg|png|gif|bmp)/
    ...
    +        if (CKEDITOR.fileTools.isTypeSupported(file, /image\/(jpeg|png|gif|bmp)/)) {
    

    Can we make this configurable? That could be a follow-up issue.

More comments in the editor.on('paste') callback would be good too, to explain the logic. I'm not sure I understand the need for myAjaxObject.beforeSerialize.

thpoul’s picture

Status: Needs work » Needs review
FileSize
2.7 MB
4 KB

Thank you both for your reviews!

I totally agree with #20, as it's a reminder that this issue aims to improve the usability and ux of image uploading within the CKE.

This patch is a small step forward based on the feedback of #21 and #22. It shows the preview of the image while uploading but nothing more. I still need to figure out a way to properly show/hide the preview. Didn't have much luck with it yet.

PS: Excuse the spaghetti within fileTools.addUploadWidget(), still trying to make it work properly :)

Wim Leers’s picture

#23 is a huge improvement, UX-wise!

I'd love to see something like .cke_upload_uploading img { opacity: 0.3; } to indicate it's an in-progress thing. thpoul told me in IRC that he had difficulty finding the right place to do this: CKEditor's uploadwidget uses CKEDITOR.addCss(), which we cannot use. Plus, that only works for iframe instances, we need it to work for inline and iframe instances, and it must be customizable by themes.

After some searching, I found the solution:

12:25:58 <WimLeers> the "text editor" in-place editor's getAttachments() calls the text editor plugin manager's getAttachments(), which calls each text editor plugin's getLibraries() method
12:26:12 <WimLeers> And the "CKEditor" text editor plugin has this:
12:26:15 <WimLeers> 
  public function getLibraries(Editor $editor) {
    $libraries = array(
      'ckeditor/drupal.ckeditor',
    );

    // Get the required libraries for any enabled plugins.
    $enabled_plugins = array_keys($this->ckeditorPluginManager->getEnabledPluginFiles($editor));
    foreach ($enabled_plugins as $plugin_id) {
      $plugin = $this->ckeditorPluginManager->createInstance($plugin_id);
      $additional_libraries = array_diff($plugin->getLibraries($editor), $libraries);
      $libraries = array_merge($libraries, $additional_libraries);
    }

    return $libraries;
  }
12:26:31 <WimLeers> i.e. it calls the getLibraries() method on every CKE plugin plugin
12:26:58 <WimLeers> So… we could let \Drupal\ckeditor\Plugin\CKEditorPlugin\DrupalImage::getLibraries return a file that contains this
12:27:08 <WimLeers> So I think the right solution is actually:
12:27:27 <WimLeers> 1. add new "image/ckeditor-drag-and-drop" library
12:27:32 <WimLeers> 2. return that in \Drupal\ckeditor\Plugin\CKEditorPlugin\DrupalImage::getLibraries()
12:27:56 <WimLeers> 3. Let image.module implement hook_ckeditor_css_alter() and return the same CSS file there

Now reviewing the actual patch.

Wim Leers’s picture

Hmmm dammit! #24 assumes that the DrupalImage plugin lives in the image module, but it lives in the ckeditor module. So the library would be called something like ckeditor/plugins.drupalimage.drag-and-drop-uploads.

Wim Leers’s picture

  1. beforeSerialize() needs to be better documented. What does it do? Why specifically beforeSerialize() and not beforeSubmit() or one of the others?
  2.               options.contentType = false;
                  options.processData = false;
    

    Why these overrides? Needs docs.

  3. Now only one dialog is being updated, but it still isn't guaranteed to be the right dialog. It's just doing it for the first dialog that appears. I realize that it's extremely unlikely that another dialog will be opened in the mean time, but it still doesn't feel right.
  4. http://sdk.ckeditor.com/samples/fileupload.html supports dropping multiple files. We must limit it to a single file (i.e. ignore other dropped files), because we can't open multiple dialogs at the same time. (And again, we want that dialog to be opened so the content creator is forced to provide alternative text.)
  5. I tried to fully grok what https://github.com/ckeditor/ckeditor-dev/blob/master/plugins/uploadwidge... + https://github.com/ckeditor/ckeditor-dev/blob/master/plugins/uploadimage... do. But I failed. We need somebody from the CKEditor team to review this code.
    (Note that I even went back in time to see how they dealt with integrating uploadimage and image2, but they never integrated that. Note that Drupal's drupalimage plugins is just an extension of/a layer on top of image2.)
  6. Finally, I looked into the (AFAICT) sole missing feature: when closing the EditorImageDialog, the placeholder image should be removed. The tricky thing there is that the design/assumption is that it's only Editor(Link|Image)Dialog that's creating/inserting HTML. But in this case, we already insert HTML before opening the dialog, so we must remove it afterwards. That makes it more difficult than I hoped it would be.
    I considered making saveCallback be called even upon closing (but this time with its argument not being the values, but undefined). Unfortunately, that would be an API change.
    But the reason that that is designed in that particular way, is because Drupal's AJAX Dialog API is designed to be used solely from the server side, not from the client side. It's literally impossible to specify a close() callback for a specific dialog instance (you could do it for all of them, but that'd be beside the point), despite http://api.jqueryui.com/dialog/#event-close explicitly supporting that. This is also in part to the horrendous complexity/confusingness that jQuery UI (Dialog)'s API is. (Look at core/misc/dialog/dialog.js + core/misc/dialog/dialog.position.js + core/misc/dialog/dialog.ajax.js to get even a vague idea.)
  7. So, the best we can do is to provide editor:dialogopen (to solve point 3) and editor:dialogclose events (to solve the previous point), similar to the already-existing editor:dialogsave event. We cannot tie it to this particular dialog instance due to the reasons given in the previous point, but we can make it reliable nonetheless: by adding a certain class to the dialog and then mapping dialog:aftercreate to editor:dialogopen and dialog:afterclose to editor:dialogclose, but only if those events are being triggered for a dialog that has that certain class, we have sufficient guarantees. Because there's guaranteed to only ever be one such modal dialog active.
  8. Finally, we need to make sure that "undo" still works correctly. Currently, if you drag-and-drop an image into CKEditor, complete the dialog, then if you undo, you'll actually revert back to the preview image. We don't want that, of course :)

This reroll fixes points 3, 6 and 7. And my changes need further refinement, this is just providing some of the missing things that this issue really needs.

Wim Leers’s picture

#26 took me >3 hours to figure out how to make those events work (i.e. points 6 + 7). Then once I had them, I fixed point 3 in a few minutes.

thpoul’s picture

Superb! I was really stuck there, thank you for pushing it forward and for making this list of things we need to address!

This patch should solve #24. I'll try and submit a new one with points 1,2 and 4 tomorrow.

@Wim Leers: Should we ping mlewand for 5 once the rest is done?

Status: Needs review » Needs work

The last submitted patch, 28: 2560457-28.patch, failed testing.

Wim Leers’s picture

I already pinged @mlewand for #26.5 :)

#28's interdiff is looking good! (The failing test is because you need to add the same stuff to the stable theme.)

Wim Leers’s picture

And to make it easier to see the changes for the CKEditor team people who review this: this is #28, minus the changes to our build of CKEditor. i.e. just the changes that need review.

thpoul’s picture

Status: Needs work » Needs review
FileSize
624 bytes
2.7 MB

This should solve 30. Still working on points 1,2 and 4.

thpoul’s picture

Worked a bit on 1 and 2 (hopefully they are on the right track) and solved 8 (credit to Reinmar of the CKE team). Getting closer :D

Edit: also ran eslint and fixed a couple of warnings.

The last submitted patch, 32: 2560457-32.patch, failed testing.

thpoul’s picture

Some fixes after manual testing and test bot giving me a red light.

thpoul’s picture

FileSize
1.3 KB

Missed the proper interdiff

The last submitted patch, 33: 2560457-33.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 35: 2560457-35.patch, failed testing.

thpoul’s picture

Status: Needs work » Needs review
FileSize
503 bytes
2.7 MB

Go testbot go!

Wim Leers’s picture

#26.8 has not yet been addressed — or at least it's not yet working correctly for me.

Finally, we need to make sure that undo still works correctly. Currently, if you drag-and-drop an image into CKEditor, complete the dialog, then if you undo, you'll actually revert back to the preview image. We don’t want that, of course :)

EDIT: well, it works okay in case of canceling (drag + drop image, then close the image dialog). But it does not yet work correctly in case of the dialog being used as intended: alternative text filled out, click "save", and then undo.

Wim Leers’s picture

Status: Needs review » Needs work
Issue tags: +Needs tests

Plus, this is still missing JS test coverage.

It'd also be great if the CKEditor team could review this of course, so I pinged them as well.

thpoul’s picture

This is the diff without the CKEditor library rebuild.

thpoul’s picture

Status: Needs work » Needs review
FileSize
604 bytes
2.71 MB

This fixes #26.8

Wim Leers’s picture

Status: Needs review » Needs work

I'd love to RTBC this, but I can't, not without a \Drupal\FunctionalJavascriptTests\JavascriptTestBase test. I'd prefer to have the CKEditor team's approval of the JS too, but I could live without that since the code is so small, and easily revertible.

The functionality now works great!

IMHO this should qualify for beta target, since it's easily revertible.

  1. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -267,6 +267,76 @@
    +              // jQuery.ajax() by default sets the contentType as
    +              // “application/x-www-form-urlencoded; charset=UTF-8”. As of
    +              // jQuery 1.6 you can pass false to tell jQuery to not set any
    +              // content type header.
    +              // @see http://api.jquery.com/jquery.ajax/
    

    Why do we not want to send a Content-Type request header?

  2. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -267,6 +267,76 @@
    +              // order to send a DOMDocument, or other non-processed data, we
    

    We're not sending a DOMDocument? (That's PHP!) We're sending FormData?

  3. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -363,6 +433,25 @@
    +        ajaxInstance = Drupal.ajax.instances[k];
    +        return ajaxInstance;
    

    Don't assign, just return.

  4. +++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
    @@ -363,6 +433,25 @@
    +    return ajaxInstance;
    

    Just return null.

effulgentsia’s picture

Issue tags: +beta target

From https://www.drupal.org/core/d8-allowed-changes#beta:

Certain other issues with high impact and low disruption at committer discretion only.

From https://www.drupal.org/project/drupal/releases/8.2.0-beta1:

A handful of additional beta target issues are being considered for a later beta, including ... a couple CKEditor enhancements.

I discussed this issue with @webchick and @xjm, and we agree that it's high impact, low disruption, and the beta1 release notes already mention it indirectly, so tagging as a beta target.

Version: 8.2.x-dev » 8.3.x-dev

Drupal 8.2.0-beta1 was released on August 3, 2016, which means new developments and disruptive changes should now be targeted against the 8.3.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

webchick’s picture

Note that Wim is on honeymoon for the next 2 weeks, so if someone else is able to re-roll per his comments, that'd be awesome.

thpoul’s picture

@webchick I'm still working on the test needed for this, plus Wim's comments :)

webchick’s picture

Yay, thanks Thanos! :)

thpoul’s picture

#44-1: Setting any Content-Type just breaks the ajax request for some reason and I haven't been able to figure out why.
#44-2: Yes we are sending FormData not DOMDocument (btw the comments are from the jQuery.ajax documentation.)
#44-3, 4: You can stop the loop from within the callback function by returning false. Assigning the value is necessary in order to return it out of the loop, not to mention that eslint was complaining about it too :) I switched from $.each to Array.prototype.forEach in order to be inline with the other loops in the file.

I am also working on a JavascriptTestBase test which is included in this patch. It took a lot of effort to actually be able to replicate the drag-and-drop functionality in a few js lines (kudos to Tade0 for his valuable input) and I think we are close to what we should have. The test fails right now with TypeError: Attempted to assign to readonly property. which I can't understand why (seems it has something to do with beforeSerialize at /core/modules/ckeditor/js/plugins/drupalimage/plugin.js:306.

You can still run the test using phantomjs --ssl-protocol=any --ignore-ssl-errors=true vendor/jcalderonzumba/gastonjs/src/Client/main.js 8510 1024 768 false (note the false at the end which sets js_errors = false at the Poltergeist configuration) but it will still fail due to RuntimeException: Unfinished AJAX requests whilst tearing down a test which is most probably caused by the previous TypeError.

Note: Pasting the JS code from the with-blob-as-file test in the console won't work because of the Blob not being a File (I struggled a lot with it because I was testing the JS code from my browser console but it's a known phantomjs bug). If you need to test in from your browser I have attached the with-file alternative which unfortunately can't be in the test due to the phantomjs bug.

The last submitted patch, 50: 2560457-with-blob-as-file-50.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 50: 2560457-with-file-50.patch, failed testing.

xjm’s picture

Issue tags: -beta target

Thanks @thpoul for helping address that feedback!

At this point we've essentially come to the deadline for beta targets, so this issue will need to go into 8.3.x. That commit could happen at any time once the issue is ready and it would be great to have this as one of the first features in 8.3.x.

webchick’s picture

Version: 8.3.x-dev » 8.4.x-dev

Drupal 8.3.0-alpha1 will be released the week of January 30, 2017, which means new developments and disruptive changes should now be targeted against the 8.4.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

sukanya.ramakrishnan’s picture

We are in need of the 'resizing of an image using Drag' in CKEditor . Wondering if there are plans to provide this functionality soon.

Thanks in advance for any assistance.

Thanks,
Sukany

sukanya.ramakrishnan’s picture

Apologies for the off track comment.
Whoever is looking for the drag resize functionality for images, i found that this functionality is already available in Drupal 8. Our FULL HTML editor was restricted to limit certain HTML tags and so it wasnt working properly. here is the solution, When text editor is configured, if the 'Limit allowed HTML tags and correct faulty HTML
Enabled ' filter is enabled, add the height and width attributes to the img tag in this setting and the drag resize handle works perfectly.