Playlist Editor
Learn how to use YouTube’s API to search for videos and make a playlist. This makes authenticated requests with OAuth2. It uses jQuery++ for drag/drop events. It shows using custom attributes and custom events. This advanced guide takes an hour to complete.
This recipe uses YouTube API Services and follows YouTube Terms of Service and Google Privacy Policy
The final widget looks like:
See the Pen Playlist Editor (Advanced) [Finished] by Bitovi (@bitovi) on CodePen.
To use the widget:
- Click Sign In to give access to the app to create playlists on your behalf.
- Type search terms in Search for videos and hit enter.
- Drag and drop those videos into the playlist area (Drag video here).
- Click Create Playlist.
- Enter a name in the popup.
- Navigate to your YouTube channel to verify the playlist was created.
START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED::
See the Pen Playlist Editor (Advanced) [Starter] by Bitovi (@bitovi) on CodePen.
The following sections are broken down into:
- Problem — A description of what the section is trying to accomplish.
- What you need to know — Information about CanJS that is useful for solving the problem.
- Solution — The solution to the problem.
The following video goes through this recipe:
Set up CanJS and Load Google API
The problem
In this section, we will:
- Load Google’s JS API client,
gapi
, and initialize it to make requests on behalf of the registered "CanJS Playlist" app. - Set up a basic CanJS application.
- Use the basic CanJS application to show when Google’s JS API has finished loading.
What you need to know
The preferred way of loading Google’s JS API is with an
async
script tag like:<script async defer src="https://apis.google.com/js/api.js" onload="this.onload=function(){}; googleScriptLoaded();" onreadystatechange="if (this.readyState === 'complete') this.onload();"> </script>
The
async
attribute allows other JS to execute while theapi.js
file is loading. Once complete, this will call agoogleScriptLoaded
function.Once
api.js
is loaded, it adds the gapi object to the window. This is Google’s JS API. It can be used to load other APIs that extend thegapi
library.The following can be used to load the OAuth2 GAPI libraries:
gapi.load("client:auth2", completeCallback);
Once this functionality is loaded, we can tell
gapi
to make requests on behalf of a registered application. In this case, the following keys enable this client to make requests on behalf of the "CanJS Playlist" application:gapi.client.init({ apiKey: "AIzaSyAbHbOuFtJRvTX731PQXGSTy59eh5rEiE0", clientId: "764983721035-85cbj35n0kmkmrba10f4jtte8fhpst84.apps.googleusercontent.com", discoveryDocs: [ "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest" ], scope: "https://www.googleapis.com/auth/youtube" }).then( completeCallback )
To use your own key, you can follow the instructions here. This is not required to complete this guide.
Instead of callbacks, CanJS favors Promises to manage asynchronous behavior. A promise can be created like:
const messagePromise = new Promise(function(resolve, reject) { setTimeout(function() { resolve("Hello There"); }, 1000); });
resolve
should be called once the promise has a value.reject
should be called if something goes wrong (like an error). We say themessagePromise
resolves with"Hello There"
after one second.Anyone can listen to when
messagePromise
resolves with a value like:messagePromise.then(function(messageValue) { messageValue //-> "Hello There" });
CanJS can use promises in its can-stache templates. More on that below.
A basic CanJS application is a live-bound template (or view) rendered with the component’s props.
import { StacheElement } from "can"; class MyApp extends StacheElement { static view = `<h1>{{ message }}</h1>`; static props = { message: "Hello World" }; } customElements.define("my-app", MyApp);
Mount a can-stache-element by its custom tag like:
<my-app></my-app>
Use {{# if(value) }} to do
if/else
branching in can-stache.Promises are observable with can-stache. Given a promise
somePromise
, you can:- Check if the promise is loading like:
{{# if(somePromise.isPending) }}
. - Loop through the resolved value of the promise like:
{{# for(item of somePromise.value) }}
.
- Check if the promise is loading like:
ObservableObject can be used to define the behavior of observable objects like:
import { ObservableObject } from "can"; class Type extends ObservableObject { static props = { message: String }; }
props are ObservableObject like properties used to control the behavior of a custom element.
import { StacheElement } from "can"; class PlaylistEditor extends StacheElement { static view = `...`; static props = { message: String }; } customElements.define("playlist-editor", PlaylistEditor);
ObservableObjecs can specify a default value and a type:
import { StacheElement } from "can"; class PlaylistEditor extends StacheElement { static view = `...`; static props = { message: { type: String, default: "Hello World" } }; } customElements.define("playlist-editor", PlaylistEditor);
The solution
Update the HTML to:
Note: use your own
clientId
if you use this code outside this guide and CodePen.
<playlist-editor></playlist-editor>
<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/jquerypp@2/dist/global/jquerypp.js"></script>
<script>
window.googleApiLoadedPromise = new Promise(function(resolve) {
window.googleScriptLoaded = function() {
gapi.load("client:auth2", function() {
gapi.client.init({
apiKey: "AIzaSyBcnGGOryOnmjUC09T78VCFEqRQRgvPnAc",
clientId: "764983721035-85cbj35n0kmkmrba10f4jtte8fhpst84.apps.googleusercontent.com",
discoveryDocs: [ "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest" ],
scope: "https://www.googleapis.com/auth/youtube"
}).then(resolve);
});
}
});
</script>
<script async defer src="https://apis.google.com/js/api.js"
onload="this.onload=function(){}; googleScriptLoaded();"
onreadystatechange="if (this.readyState === 'complete') this.onload();">
</script>
Update the JavaScript tab to:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
<div>Loaded Google API</div>
{{/ if }}
`;
static props = {
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
}
};
}
customElements.define("playlist-editor", PlaylistEditor);
Sign in and out
The problem
In this section, we will:
- Show a
Sign In
button that signs a person into their Google account. - Show a
Sign Out
button that signs a person out of their Google account. - Automatically know via Google’s API when the user signs in and out, and update the page accordingly.
- Show a welcome message with the user’s given name.
What you need to know
Once the Google API has been fully loaded, information about the currently authenticated user can be found in the
googleAuth
object. This can be retrieved like:googleApiLoadedPromise.then(function() { const googleAuth = gapi.auth2.getAuthInstance() });
With
googleAuth
, you can:- Know if someone is signed in:
googleAuth.isSignedIn.get()
- Sign someone in:
googleAuth.signIn()
- Sign someone out:
googleAuth.signOut()
- Listen to when someone’s signedIn status changes:
googleAuth.isSignedIn.listen(callback)
- Get the user’s name:
googleAuth.currentUser.get().getBasicProfile().getGivenName()
- Know if someone is signed in:
ES5 getter syntax can be used to define a component property that changes when another property changes. For example, the following defines an
signedOut
property that is the opposite of thesignedIn
property:import { StacheElement } from "can"; class PlaylistEditor extends StacheElement { static view = `...`; static props = { signedIn: Boolean, get signedOut() { return !this.signedIn; } }; } customElements.define("playlist-editor", PlaylistEditor);
Use asynchronous getters to get data from asynchronous sources. For example:
import { StacheElement } from "can"; class MyApp extends StacheElement { static view = `...`; static props = { property: { async(resolve) { apiLoadedPromise .then(function() { resolve(api.getValue()); }); } } }; } customElements.define("my-app", MyApp);
can-stache-element connected hook can be used to perform initialization behavior. For example, the following might initialize
googleApiLoadedPromise
:import { StacheElement, type } from "can"; class PlaylistEditor extends StacheElement { static view = `...`; static props = { googleApiLoadedPromise: type.Any, }; connected() { this.googleApiLoadedPromise = googleApiLoadedPromise; } } customElements.define("playlist-editor", PlaylistEditor);
ObservableObject’s listenTo lets you listen on changes in a component. This can be used to change values when other values change. The following will increment
nameChange
everytime thename
property changes:import { StacheElement } from "can"; class MyApp extends StacheElement { static view = `...`; static props = { name: String, nameChange: Number }; connected() { this.listenTo("name", () => { this.nameChange += 1; }); } }
Note: EventStreams provide a much better way of doing this. Check out can-define-stream-kefir.
Use on:EVENT to listen to an event on an element and call a method in can-stache. For example, the following calls
sayHi()
when the<div>
is clicked.<div on:click="sayHi()"> <!-- ... --> </div>
The solution
Update the JavaScript tab to:
import { StacheElement, type } from "//unpkg.com/can@6/core.mjs";
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
{{# if(this.signedIn) }}
Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
{{ else }}
<button on:click="this.googleAuth.signIn()">Sign In</button>
{{/ if }}
{{/ if }}
`;
static props = {
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
},
googleAuth: {
async(resolve) {
this.googleApiLoadedPromise.then(() => {
resolve(gapi.auth2.getAuthInstance());
});
}
},
signedIn: Boolean,
get givenName() {
return (
this.googleAuth &&
this.googleAuth.currentUser
.get()
.getBasicProfile()
.getGivenName()
);
}
};
connected() {
this.listenTo("googleAuth", ({ value: googleAuth }) => {
this.signedIn = googleAuth.isSignedIn.get();
googleAuth.isSignedIn.listen(isSignedIn => {
this.signedIn = isSignedIn;
});
});
}
}
customElements.define("playlist-editor", PlaylistEditor);
Search for videos
The problem
In this section, we will:
- Create a search
<input>
where a user can type a search query. - When the user types more than 2 characters, get a list of video search results and display them to the user.
What you need to know
Use value:bind to setup a two-way binding in can-stache. For example, the following keeps
searchQuery
and the input’svalue
in sync:<input value:bind="searchQuery">
Use
gapi.client.youtube.search.list
to search YouTube like:const googlePromise = gapi.client.youtube.search.list({ q: "dogs", part: "snippet", type: "video" }).then(function(response) { response //-> { // result: { // items: [ // { // id: {videoId: "ajsadfa"}, // snippet: { // title: "dogs", // thumbnails: {default: {url: "https://example.com/dog.png"}} // } // } // ] // } // } });
To convert a
googlePromise
to a native Promise use:new Promise(function(resolve, reject) { googlePromise.then(resolve, reject); })
The solution
Update the JavaScript tab to:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
{{# if(this.signedIn) }}
Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
{{ else }}
<button on:click="this.googleAuth.signIn()">Sign In</button>
{{/ if }}
<div>
<input value:bind="this.searchQuery" placeholder="Search for videos">
</div>
{{# if(this.searchResultsPromise.isPending) }}
<div class="loading">Loading videos…</div>
{{/ if }}
{{# if(this.searchResultsPromise.isResolved) }}
<ul class="source">
{{# for(searchResult of this.searchResultsPromise.value) }}
<li>
<a href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
<img src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ searchResult.snippet.title }}
</li>
{{/ for }}
</ul>
{{/ if }}
{{/ if }}
`;
static props = {
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
},
googleAuth: {
async(resolve) {
this.googleApiLoadedPromise.then(() => {
resolve(gapi.auth2.getAuthInstance());
});
}
},
signedIn: Boolean,
get givenName() {
return (
this.googleAuth &&
this.googleAuth.currentUser
.get()
.getBasicProfile()
.getGivenName()
);
},
searchQuery: "",
get searchResultsPromise() {
if (this.searchQuery.length > 2) {
return gapi.client.youtube.search
.list({
q: this.searchQuery,
part: "snippet",
type: "video"
})
.then(response => {
console.info("Search results:", response.result.items);
return response.result.items;
});
}
}
};
connected() {
this.listenTo("googleAuth", ({ value: googleAuth }) => {
this.signedIn = googleAuth.isSignedIn.get();
googleAuth.isSignedIn.listen(isSignedIn => {
this.signedIn = isSignedIn;
});
});
}
}
customElements.define("playlist-editor", PlaylistEditor);
Drag videos
The problem
In this section, we will:
- Let a user drag around a cloned representation of the searched videos.
What you need to know
The jQuery++ library (which is already included on the page), supports the following
drag
events:dragdown
- the mouse cursor is pressed downdraginit
- the drag motion is starteddragmove
- the drag is moveddragend
- the drag has endeddragover
- the drag is over a drop pointdragout
- the drag moved out of a drop point
You can bind on them manually with jQuery like:
$(element).on("draginit", function(ev, drag) { drag.limit($(this).parent()); drag.horizontal(); });
Notice that
drag
is the 2nd argument to the event. You can listen todrag
events in can-stache and pass thedrag
argument to a function like:on:draginit="startedDrag(scope.arguments[1])"
You can use addJQueryEvents() to listen to custom jQuery events (such as jQuery++’s
draginit
above):import { addJQueryEvents } from "can"; addJQueryEvents(jQuery);
The
drag.ghost()
method copies the elements being dragged and drags that instead. The.ghost()
method returns the copied elements wrapped with jQuery. Add theghost
className to style the ghost elements, like:drag.ghost().addClass("ghost");
To add a method to a can-stache-element, just add a function shown below:
import { StacheElement } from "can"; class PlaylistEditor extends StacheElement { static view = `...`; static props = { ... }; startedDrag() { console.log("you did it!") } }
Certain browsers have default drag behaviors for certain elements like
<a>
and<img>
that can be prevented with thedraggable="false"
attribute.
The solution
Update the JavaScript tab to:
import { addJQueryEvents, StacheElement } from "//unpkg.com/can@6/core.mjs";
addJQueryEvents(jQuery);
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
{{# if(this.signedIn) }}
Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
{{ else }}
<button on:click="this.googleAuth.signIn()">Sign In</button>
{{/ if }}
<div>
<input value:bind="this.searchQuery" placeholder="Search for videos">
</div>
{{# if(this.searchResultsPromise.isPending) }}
<div class="loading">Loading videos…</div>
{{/ if }}
{{# if(this.searchResultsPromise.isResolved) }}
<ul class="source">
{{# for(searchResult of this.searchResultsPromise.value) }}
<li on:draginit="this.videoDrag(scope.arguments[1])">
<a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
<img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ searchResult.snippet.title }}
</li>
{{/ for }}
</ul>
{{/ if }}
{{/ if }}
`;
static props = {
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
},
googleAuth: {
async(resolve) {
this.googleApiLoadedPromise.then(() => {
resolve(gapi.auth2.getAuthInstance());
});
}
},
signedIn: Boolean,
get givenName() {
return (
this.googleAuth &&
this.googleAuth.currentUser
.get()
.getBasicProfile()
.getGivenName()
);
},
searchQuery: "",
get searchResultsPromise() {
if (this.searchQuery.length > 2) {
return gapi.client.youtube.search
.list({
q: this.searchQuery,
part: "snippet",
type: "video"
})
.then(response => {
console.info("Search results:", response.result.items);
return response.result.items;
});
}
}
};
connected() {
this.listenTo("googleAuth", ({ value: googleAuth }) => {
this.signedIn = googleAuth.isSignedIn.get();
googleAuth.isSignedIn.listen(isSignedIn => {
this.signedIn = isSignedIn;
});
});
}
videoDrag(drag) {
drag.ghost().addClass("ghost");
}
}
customElements.define("playlist-editor", PlaylistEditor);
Drop videos
The problem
In this section, we will:
- Allow a user to drop videos on a playlist element.
- When the user drags a video over the playlist element, a placeholder of the video will appear in the first position of the playlist.
- If the video is dragged out of the playlist element, the placeholder will be removed.
- If the video is dropped on the playlist element, it will be added to the playlist’s list of videos.
- Prepare for inserting the placeholder or video in any position in the list.
What you need to know
The
PlaylistEditor
element should maintain a list of playlist videos (playlistVideos
) and the placeholder video (dropPlaceholderData
) separately. It can combine these two values into a single value (videosWithDropPlaceholder
) of the videos to display to the user. On a high-level, this might look like:import { StacheElement, type, ObservableArray } from "can"; class PlaylistEditor extends StacheElement { static view = `...`; static props = { // ... // { video: video, index: 0 } dropPlaceholderData: type.Any, // [video1, video2, ...] playlistVideos: { get default() { return new ObservableArray(); } }, get videosWithDropPlaceholder() { const copyOfPlaylistVideos = this.placeListVideos.map( /* ... */ ); // insert this.dropPlaceholderData into copyOfPlaylistVideos return copyOfPlaylistVideos; } }; }
The methods that add a placeholder (
addDropPlaceholder
) and add video to the playlist (addVideo
) should take an index like:addDropPlaceholder(index, video) { /* ... */ } addVideo(index, video) { /* ... */ }
These functions will be called with
0
as the index for this section.jQuery++ supports the following drop events:
- dropinit - the drag motion is started, drop positions are calculated
- dropover - a drag moves over a drop element, called once as the drop is dragged over the element
- dropout - a drag moves out of the drop element
- dropmove - a drag is moved over a drop element, called repeatedly as the element is moved
- dropon - a drag is released over a drop element
- dropend - the drag motion has completed
You can bind on them manually with jQuery like:
$(element).on("dropon", (ev, drop, drag) => { /* ... */ });
Notice that
drop
is now the 2nd argument to the event. You can listen todrop
events in can-stache, and pass thedrag
argument to a function, like:on:dropon="addVideo(scope.arguments[2])"
You will need to associate the drag objects with the video being dragged so you know which video is being dropped when a
drop
happens. The following utilities help create that association:The
drag.element
is the jQuery-wrapped element that the user initiated the drag motion upon.CanJS’s {{ domData("DATANAME") }} helper lets you associate custom data with an element. The following saves the current
context
of the<li>
as"dragData"
on the<li>
:<li on:draginit="this.videoDrag(scope.arguments[1])" {{domData("dragData")}}></li>
domData.get() can access this data like:
import { domData } from "can"; domData.get(drag.element[0], "dragData");
The solution
Update the JavaScript tab to:
import {
addJQueryEvents,
ObservableArray,
StacheElement,
type
} from "//unpkg.com/can@6/core.mjs";
addJQueryEvents(jQuery);
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
{{# if(this.signedIn) }}
Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
{{ else }}
<button on:click="this.googleAuth.signIn()">Sign In</button>
{{/ if }}
<div>
<input value:bind="this.searchQuery" placeholder="Search for videos">
</div>
{{# if(this.searchResultsPromise.isPending) }}
<div class="loading">Loading videos…</div>
{{/ if }}
{{# if(this.searchResultsPromise.isResolved) }}
<ul class="source">
{{# for(searchResult of this.searchResultsPromise.value) }}
<li on:draginit="this.videoDrag(scope.arguments[1])"
{{ domData("dragData", searchResult) }}>
<a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
<img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ searchResult.snippet.title }}
</li>
{{/ for }}
</ul>
{{/ if }}
{{# if(this.searchResultsPromise.value.length) }}
<div class="new-playlist">
<ul
on:dropover="this.addDropPlaceholder(0,this.getDragData(scope.arguments[2]))"
on:dropout="this.clearDropPlaceholder()"
on:dropon="this.addVideo(0,this.getDragData(scope.arguments[2]))"
>
{{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
<li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
<a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target="_blank">
<img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ videoWithDropPlaceholder.video.snippet.title }}
</li>
{{ else }}
<div class="content">Drag video here</div>
{{/ for }}
</ul>
</div>
{{/ if }}
{{/ if }}
`;
static props = {
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
},
googleAuth: {
async(resolve) {
this.googleApiLoadedPromise.then(() => {
resolve(gapi.auth2.getAuthInstance());
});
}
},
signedIn: Boolean,
get givenName() {
return (
this.googleAuth &&
this.googleAuth.currentUser
.get()
.getBasicProfile()
.getGivenName()
);
},
searchQuery: "",
dropPlaceholderData: type.Any,
playlistVideos: {
get default() {
return new ObservableArray();
}
},
get searchResultsPromise() {
if (this.searchQuery.length > 2) {
return gapi.client.youtube.search
.list({
q: this.searchQuery,
part: "snippet",
type: "video"
})
.then(response => {
console.info("Search results:", response.result.items);
return response.result.items;
});
}
},
get videosWithDropPlaceholder() {
const copy = this.playlistVideos.map(video => {
return {
video: video,
isPlaceholder: false
};
});
if (this.dropPlaceholderData) {
copy.splice(this.dropPlaceholderData.index, 0, {
video: this.dropPlaceholderData.video,
isPlaceholder: true
});
}
return copy;
}
};
connected() {
this.listenTo("googleAuth", ({ value: googleAuth }) => {
this.signedIn = googleAuth.isSignedIn.get();
googleAuth.isSignedIn.listen(isSignedIn => {
this.signedIn = isSignedIn;
});
});
}
videoDrag(drag) {
drag.ghost().addClass("ghost");
}
getDragData(drag) {
return can.domData.get(drag.element[0], "dragData");
}
addDropPlaceholder(index, video) {
this.dropPlaceholderData = {
index: index,
video: video
};
}
clearDropPlaceholder() {
this.dropPlaceholderData = null;
}
addVideo(index, video) {
this.dropPlaceholderData = null;
if (index >= this.playlistVideos.length) {
this.playlistVideos.push(video);
} else {
this.playlistVideos.splice(index, 0, video);
}
}
}
customElements.define("playlist-editor", PlaylistEditor);
Drop videos in order
The problem
In this section, we will:
- Allow a user to drop videos in order they prefer.
What you need to know
[can-stache-elements StacheElement]s are best left knowing very little about the DOM. This makes them more easily unit-testable. To make this interaction, we need to know where the mouse is in relation to the playlist’s videos. This requires a lot of DOM interaction and is best done outside the element.
Specifically, we’d like to translate the
dropmove
anddropon
events into other events that let people know where thedropmove
anddropon
events are happening in relationship to the drop target’s child elements.Our goal is to:
Translate
dropmove
intosortableplaceholderat
events that dispatch events with theindex
where a placeholder should be inserted and thedragData
of what is being dragged.Translate
dropon
intosortableinsertat
events that dispatch events with theindex
where the dragged item should be inserted and thedragData
of what is being dragged.
Control is useful for listening to events on an element in a memory-safe way. Use extend to define a
Control
type, as follows:import { Control } from "can"; const Sortable = Control.extend({ // Event handlers and methods });
To listen to events (like
dragmove
) on a control, use an event handler with{element} EVENTNAME
, as follows:import { Control } from "can"; const Sortable = Control.extend({ "{element} dropmove": function(el, ev, drop, drag) { // do stuff on dropmove like call method: this.method(); }, method() { // do something } });
Use
new Control(element)
to create a control on an element. The following would setup thedropmove
binding onel
:new Sortable(el);
viewCallbacks.attr() can listen to when a custom attribute is found in a can-stache template like:
import { viewCallbacks } from "can"; viewCallbacks.attr("sortable", function(el, attrData) {});
This can be useful to create controls on an element with that attribute. For example, if a user has:
<ul sortable> <!-- ... --> </ul>
The following will create the
Sortable
control on that<ul>
:import { viewCallbacks } from "can"; viewCallbacks.attr("sortable", function(el) { new Sortable(el); });
Use domEvents.dispatch() to fire custom events:
import { domEvents } from "can"; domEvents.dispatch(element, { type: "sortableinsertat", index: 0, dragData: dragData });
Access the event object in a on:event with scope.event, like:
on:sortableinsertat="addVideo(scope.event.index, scope.event.dragData)"
Mouse events like
click
anddropmove
anddropon
have apageY
property that tells how many pixels down the page a user’s mouse is.jQuery.offset returns an element’s position on the page.
jQuery.height returns an element’s height.
If the mouse position is below an element’s center, the placeholder should be inserted after the element. If the mouse position is above an element’s center, it should be inserted before the element.
The solution
Update the JavaScript tab to:
import {
addJQueryEvents,
Control,
StacheElement,
domData,
domEvents,
ObservableArray,
type,
viewCallbacks
} from "//unpkg.com/can@6/core.mjs";
addJQueryEvents(jQuery);
const Sortable = Control.extend({
"{element} dropmove": function(el, ev, drop, drag) {
this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
},
"{element} dropon": function(el, ev, drop, drag) {
this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
},
fireEventForDropPosition: function(ev, drop, drag, eventName) {
const dragData = domData.get(drag.element[0], "dragData");
const sortables = $(this.element).children();
for (var i = 0; i < sortables.length; i++) {
//check if cursor is past 1/2 way
const sortable = $(sortables[i]);
if (
ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)
) {
// index at which it needs to be inserted before
domEvents.dispatch(this.element, {
type: eventName,
index: i,
dragData: dragData
});
return;
}
}
if (!sortables.length) {
domEvents.dispatch(this.element, {
type: eventName,
index: 0,
dragData: dragData
});
} else {
domEvents.dispatch(this.element, {
type: eventName,
index: i,
dragData: dragData
});
}
}
});
viewCallbacks.attr("sortable", function(el) {
new Sortable(el);
});
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
{{# if(this.signedIn) }}
Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
{{ else }}
<button on:click="this.googleAuth.signIn()">Sign In</button>
{{/ if }}
<div>
<input value:bind="this.searchQuery" placeholder="Search for videos">
</div>
{{# if(this.searchResultsPromise.isPending) }}
<div class="loading">Loading videos…</div>
{{/ if }}
{{# if(this.searchResultsPromise.isResolved) }}
<ul class="source">
{{# for(searchResult of this.searchResultsPromise.value) }}
<li on:draginit="this.videoDrag(scope.arguments[1])"
{{ domData("dragData", searchResult) }}>
<a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
<img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ searchResult.snippet.title }}
</li>
{{/ for }}
</ul>
{{/ if }}
{{# if(this.searchResultsPromise.value.length) }}
<div class="new-playlist">
<ul
sortable
on:sortableplaceholderat="this.addDropPlaceholder(scope.event.index, scope.event.dragData)"
on:sortableinsertat="this.addVideo(scope.event.index, scope.event.dragData)"
on:dropout="this.clearDropPlaceholder()"
>
{{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
<li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
<a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target="_blank">
<img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ videoWithDropPlaceholder.video.snippet.title }}
</li>
{{ else }}
<div class="content">Drag video here</div>
{{/ for }}
</ul>
</div>
{{/ if }}
{{/ if }}
`;
static props = {
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
},
googleAuth: {
async(resolve) {
this.googleApiLoadedPromise.then(() => {
resolve(gapi.auth2.getAuthInstance());
});
}
},
signedIn: Boolean,
get givenName() {
return (
this.googleAuth &&
this.googleAuth.currentUser
.get()
.getBasicProfile()
.getGivenName()
);
},
searchQuery: "",
dropPlaceholderData: type.Any,
playlistVideos: {
get default() {
return new ObservableArray();
}
},
get searchResultsPromise() {
if (this.searchQuery.length > 2) {
return gapi.client.youtube.search
.list({
q: this.searchQuery,
part: "snippet",
type: "video"
})
.then(response => {
console.info("Search results:", response.result.items);
return response.result.items;
});
}
},
get videosWithDropPlaceholder() {
const copy = this.playlistVideos.map(video => {
return {
video: video,
isPlaceholder: false
};
});
if (this.dropPlaceholderData) {
copy.splice(this.dropPlaceholderData.index, 0, {
video: this.dropPlaceholderData.video,
isPlaceholder: true
});
}
return copy;
}
};
connected() {
this.listenTo("googleAuth", ({ value: googleAuth }) => {
this.signedIn = googleAuth.isSignedIn.get();
googleAuth.isSignedIn.listen(isSignedIn => {
this.signedIn = isSignedIn;
});
});
}
videoDrag(drag) {
drag.ghost().addClass("ghost");
}
getDragData(drag) {
return can.domData.get(drag.element[0], "dragData");
}
addDropPlaceholder(index, video) {
this.dropPlaceholderData = {
index: index,
video: video
};
}
clearDropPlaceholder() {
this.dropPlaceholderData = null;
}
addVideo(index, video) {
this.dropPlaceholderData = null;
if (index >= this.playlistVideos.length) {
this.playlistVideos.push(video);
} else {
this.playlistVideos.splice(index, 0, video);
}
}
}
customElements.define("playlist-editor", PlaylistEditor);
Revert videos not dropped on playlist
The problem
In this section, we will:
- Revert videos not dropped on the playlist. If a user drags a video, but does not drop it on the playlist, show an animation returning the video to its original place.
What you need to know
- If you call
drag.revert()
, the drag element will animate back to its original position.
The solution
Update the JavaScript tab to:
import {
addJQueryEvents,
Control,
StacheElement,
domData,
domEvents,
ObservableArray,
type,
viewCallbacks
} from "//unpkg.com/can@6/core.mjs";
addJQueryEvents(jQuery);
const Sortable = Control.extend({
"{element} dropinit": function() {
this.droppedOn = false;
},
"{element} dropmove": function(el, ev, drop, drag) {
this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
},
"{element} dropon": function(el, ev, drop, drag) {
this.droppedOn = true;
this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
},
"{element} dropend": function(el, ev, drop, drag) {
if (!this.droppedOn) {
drag.revert();
}
},
fireEventForDropPosition: function(ev, drop, drag, eventName) {
const dragData = domData.get(drag.element[0], "dragData");
const sortables = $(this.element).children();
for (var i = 0; i < sortables.length; i++) {
//check if cursor is past 1/2 way
const sortable = $(sortables[i]);
if (
ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)
) {
// index at which it needs to be inserted before
domEvents.dispatch(this.element, {
type: eventName,
index: i,
dragData: dragData
});
return;
}
}
if (!sortables.length) {
domEvents.dispatch(this.element, {
type: eventName,
index: 0,
dragData: dragData
});
} else {
domEvents.dispatch(this.element, {
type: eventName,
index: i,
dragData: dragData
});
}
}
});
viewCallbacks.attr("sortable", function(el) {
new Sortable(el);
});
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
{{# if(this.signedIn) }}
Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
{{ else }}
<button on:click="this.googleAuth.signIn()">Sign In</button>
{{/ if }}
<div>
<input value:bind="this.searchQuery" placeholder="Search for videos">
</div>
{{# if(this.searchResultsPromise.isPending) }}
<div class="loading">Loading videos…</div>
{{/ if }}
{{# if(this.searchResultsPromise.isResolved) }}
<ul class="source">
{{# for(searchResult of this.searchResultsPromise.value) }}
<li on:draginit="this.videoDrag(scope.arguments[1])"
{{ domData("dragData", searchResult) }}>
<a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
<img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ searchResult.snippet.title }}
</li>
{{/ for }}
</ul>
{{/ if }}
{{# if(this.searchResultsPromise.value.length) }}
<div class="new-playlist">
<ul
sortable
on:sortableplaceholderat="this.addDropPlaceholder(scope.event.index, scope.event.dragData)"
on:sortableinsertat="this.addVideo(scope.event.index, scope.event.dragData)"
on:dropout="this.clearDropPlaceholder()"
>
{{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
<li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
<a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target="_blank">
<img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ videoWithDropPlaceholder.video.snippet.title }}
</li>
{{ else }}
<div class="content">Drag video here</div>
{{/ for }}
</ul>
</div>
{{/ if }}
{{/ if }}
`;
static props = {
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
},
googleAuth: {
async(resolve) {
this.googleApiLoadedPromise.then(() => {
resolve(gapi.auth2.getAuthInstance());
});
}
},
signedIn: Boolean,
get givenName() {
return (
this.googleAuth &&
this.googleAuth.currentUser
.get()
.getBasicProfile()
.getGivenName()
);
},
searchQuery: "",
dropPlaceholderData: type.Any,
playlistVideos: {
get default() {
return new ObservableArray();
}
},
get searchResultsPromise() {
if (this.searchQuery.length > 2) {
return gapi.client.youtube.search
.list({
q: this.searchQuery,
part: "snippet",
type: "video"
})
.then(response => {
console.info("Search results:", response.result.items);
return response.result.items;
});
}
},
get videosWithDropPlaceholder() {
const copy = this.playlistVideos.map(video => {
return {
video: video,
isPlaceholder: false
};
});
if (this.dropPlaceholderData) {
copy.splice(this.dropPlaceholderData.index, 0, {
video: this.dropPlaceholderData.video,
isPlaceholder: true
});
}
return copy;
}
};
connected() {
this.listenTo("googleAuth", ({ value: googleAuth }) => {
this.signedIn = googleAuth.isSignedIn.get();
googleAuth.isSignedIn.listen(isSignedIn => {
this.signedIn = isSignedIn;
});
});
}
videoDrag(drag) {
drag.ghost().addClass("ghost");
}
getDragData(drag) {
return can.domData.get(drag.element[0], "dragData");
}
addDropPlaceholder(index, video) {
this.dropPlaceholderData = {
index: index,
video: video
};
}
clearDropPlaceholder() {
this.dropPlaceholderData = null;
}
addVideo(index, video) {
this.dropPlaceholderData = null;
if (index >= this.playlistVideos.length) {
this.playlistVideos.push(video);
} else {
this.playlistVideos.splice(index, 0, video);
}
}
}
customElements.define("playlist-editor", PlaylistEditor);
Create a playlist
The problem
In this section, we will:
- Add a
Create Playlist
button that prompts the user for the playlist name. - After the user enters the name, the playlist is saved.
- Disable the button while the playlist is being created.
- Empty the playlist after it is created.
What you need to know
Use https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt to prompt a user for a simple string value.
YouTube only allows you to create a playlist and add items to it.
To create a playlist:
let lastPromise = gapi.client.youtube.playlists.insert({ part: "snippet,status", resource: { snippet: { title: PLAYLIST_NAME, description: "A private playlist created with the YouTube API and CanJS" }, status: { privacyStatus: "private" } } }).then(function(response) { response //->{} response.result.id // result: { // id: "lk2asf8o" // } });
To insert something onto the end of it:
gapi.client.youtube.playlistItems.insert({ part: "snippet", resource: { snippet: { playlistId: playlistId, resourceId: video.id } } }).then();
These requests must run in order. You can make one request run after another, like:
lastPromise = makeRequest(1); lastPromise = lastPromise.then(function() { return makeRequest(2); }); lastPromise = lastPromise.then(function() { return makeRequest(3); });
When a callback to
.then
returns a promise,.then
returns a promise that resolves after the inner promise has been resolved.Use {disabled:from="boolean"} to make an input disabled, like:
<button disabled:from="createPlaylistPromise.isPending()">
When the promise has finished, set the
playlistVideos
property back to an empty list. This can be done by listening tocreatePlaylistPromise
:this.listenTo("createPlaylistPromise", function({ value: promise }) { /* ... */ })
The solution
Update the JavaScript tab to:
import {
addJQueryEvents,
Control,
domData,
domEvents,
ObservableArray,
StacheElement,
type,
viewCallbacks
} from "//unpkg.com/can@6/core.mjs";
addJQueryEvents(jQuery);
const Sortable = Control.extend({
"{element} dropinit": function() {
this.droppedOn = false;
},
"{element} dropmove": function(el, ev, drop, drag) {
this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
},
"{element} dropon": function(el, ev, drop, drag) {
this.droppedOn = true;
this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
},
"{element} dropend": function(el, ev, drop, drag) {
if (!this.droppedOn) {
drag.revert();
}
},
fireEventForDropPosition(ev, drop, drag, eventName) {
const dragData = domData.get(drag.element[0], "dragData");
const sortables = $(this.element).children();
for (var i = 0; i < sortables.length; i++) {
//check if cursor is past 1/2 way
const sortable = $(sortables[i]);
if (
ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)
) {
// index at which it needs to be inserted before
domEvents.dispatch(this.element, {
type: eventName,
index: i,
dragData: dragData
});
return;
}
}
if (!sortables.length) {
domEvents.dispatch(this.element, {
type: eventName,
index: 0,
dragData: dragData
});
} else {
domEvents.dispatch(this.element, {
type: eventName,
index: i,
dragData: dragData
});
}
}
});
viewCallbacks.attr("sortable", el => {
new Sortable(el);
});
class PlaylistEditor extends StacheElement {
static view = `
{{# if(this.googleApiLoadedPromise.isPending) }}
<div>Loading Google API…</div>
{{ else }}
{{# if(this.signedIn) }}
Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
{{ else }}
<button on:click="this.googleAuth.signIn()">Sign In</button>
{{/ if }}
<div>
<input value:bind="this.searchQuery" placeholder="Search for videos">
</div>
{{# if(this.searchResultsPromise.isPending) }}
<div class="loading">Loading videos…</div>
{{/ if }}
{{# if(this.searchResultsPromise.isResolved) }}
<ul class="source">
{{# for(searchResult of this.searchResultsPromise.value) }}
<li
on:draginit="this.videoDrag(scope.arguments[1])"
{{ domData("dragData", searchResult) }}
>
<a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target='_blank'>
<img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ searchResult.snippet.title }}
</li>
{{/ for }}
</ul>
{{# if(this.searchResultsPromise.value.length) }}
<div class="new-playlist">
<ul
sortable
on:sortableplaceholderat="this.addDropPlaceholder(scope.event.index, scope.event.dragData)"
on:sortableinsertat="this.addVideo(scope.event.index, scope.event.dragData)"
on:dropout="this.clearDropPlaceholder()"
>
{{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
<li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
<a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target='_blank'>
<img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
</a>
{{ videoWithDropPlaceholder.video.snippet.title }}
</li>
{{else}}
<div class="content">Drag video here</div>
{{/ for }}
</ul>
{{# if(this.playlistVideos.length) }}
<button
on:click="this.createPlaylist()"
disabled:from="this.createPlaylistPromise.isPending()"
>
Create Playlist
</button>
{{/ if }}
</div>
{{/ if }}
{{/ if }}
{{/ if }}
`;
static props = {
signedIn: Boolean,
googleApiLoadedPromise: {
get default() {
return googleApiLoadedPromise;
}
},
googleAuth: {
async(resolve) {
this.googleApiLoadedPromise.then(() => {
resolve(gapi.auth2.getAuthInstance());
});
}
},
searchQuery: "",
dropPlaceholderData: type.Any,
playlistVideos: {
get default() {
return new ObservableArray();
}
},
createPlaylistPromise: type.Any,
get givenName() {
return (
this.googleAuth &&
this.googleAuth.currentUser
.get()
.getBasicProfile()
.getGivenName()
);
},
get searchResultsPromise() {
if (this.searchQuery.length > 2) {
return gapi.client.youtube.search
.list({
q: this.searchQuery,
part: "snippet",
type: "video"
})
.then(response => {
console.info("Search results:", response.result.items);
return response.result.items;
});
}
},
get videosWithDropPlaceholder() {
const copy = this.playlistVideos.map(video => {
return {
video: video,
isPlaceholder: false
};
});
if (this.dropPlaceholderData) {
copy.splice(this.dropPlaceholderData.index, 0, {
video: this.dropPlaceholderData.video,
isPlaceholder: true
});
}
return copy;
}
};
connected() {
this.listenTo("googleAuth", ({ value: googleAuth }) => {
this.signedIn = googleAuth.isSignedIn.get();
googleAuth.isSignedIn.listen(isSignedIn => {
this.signedIn = isSignedIn;
});
});
this.listenTo("createPlaylistPromise", ({ value: promise }) => {
if (promise) {
promise.then(() => {
this.playlistVideos = [];
this.createPlaylistPromise = null;
});
}
});
}
videoDrag(drag) {
drag.ghost().addClass("ghost");
}
getDragData(drag) {
return domData.get(drag.element[0], "dragData");
}
addDropPlaceholder(index, video) {
this.dropPlaceholderData = {
index: index,
video: video
};
}
clearDropPlaceholder() {
this.dropPlaceholderData = null;
}
addVideo(index, video) {
this.dropPlaceholderData = null;
if (index >= this.playlistVideos.length) {
this.playlistVideos.push(video);
} else {
this.playlistVideos.splice(index, 0, video);
}
}
createPlaylist() {
const playlistName = prompt("What would you like to name your playlist?");
if (!playlistName) {
return;
}
let playlistId;
let lastPromise = gapi.client.youtube.playlists
.insert({
part: "snippet,status",
resource: {
snippet: {
title: playlistName,
description:
"A private playlist created with the YouTube API and CanJS"
},
status: {
privacyStatus: "private"
}
}
})
.then(response => {
playlistId = response.result.id;
});
const playlistVideos = this.playlistVideos.slice();
playlistVideos.forEach(video => {
lastPromise = lastPromise.then(() => {
return gapi.client.youtube.playlistItems
.insert({
part: "snippet",
resource: {
snippet: {
playlistId: playlistId,
resourceId: video.id
}
}
});
});
});
this.createPlaylistPromise = lastPromise;
}
}
customElements.define("playlist-editor", PlaylistEditor);
Result
Congrats! You now have your very own YouTube Playlist Editor.
When finished, you should see something like the following CodePen:
See the Pen Playlist Editor (Advanced) [Finished] by Bitovi (@bitovi) on CodePen.