Profile Stack
The profile stack shows the list of users currently accessing the Document. Try adding and deleting users to see how the profile stack changes.
main.js
1import yorkie from '@yorkie-js/sdk';2import { getRandomName, getRandomColor } from './util.js';34const client = new yorkie.Client({5 rpcAddr: import.meta.env.VITE_YORKIE_API_ADDR,6 apiKey: import.meta.env.VITE_YORKIE_API_KEY,7});89const doc = new yorkie.Document('profile-stack', {10 enableDevtools: true,11});1213const myRandomPresence = {14 name: getRandomName(),15 color: getRandomColor(),16};1718const MAX_PEER_VIEW = 3;19const SPEECH_BUBBLE_INDEX = {20 me: 0,21 peer: (index) => index + 1,22 more: 4,23};2425let activeSpeechBubbleIndex = null;26let myPresence = null;27let peerList = [];2829async function main() {30 await client.activate();31 doc.subscribe('presence', (e) => {32 initUserPresences(doc.getPresences());33 renderPeerList();34 initEditProfileModal();35 renderSpeechBubble(activeSpeechBubbleIndex);36 });37 await client.attach(doc, {38 initialPresence: {39 name: myRandomPresence.name,40 color: myRandomPresence.color,41 },42 });43 bindGlobalClickDismiss();44}4546// A presence is keyed by the client's actor, not by the per-session id that47// `client.getID()` returns, so "me" is `client.getActorID()`. Comparing48// against `getID()` finds nobody and leaves `myPresence` undefined.49const initUserPresences = (peers) => {50 peerList = peers.filter(({ clientID: id }) => id !== client.getActorID());51 myPresence = peers.find(52 ({ clientID: id }) => id === client.getActorID(),53 )?.presence;54};5556const bindGlobalClickDismiss = () => {57 window.addEventListener('click', (event) => {58 const $target = event.target;59 const $profile = $target.closest('.profile');60 const $speechBubble = $target.closest('.speech-bubble');61 const $editProfileModal = $target.closest('.modal');62 if ($profile || $speechBubble || $editProfileModal) {63 return;64 }65 removeAllSpeechBubbles();66 });67};6869// user profile70// `color` is peer-controlled presence just like `name`, so it never reaches an71// HTML parser: a value like `red" onerror="...` would break out of the `src`72// attribute if it were interpolated into `innerHTML`. The icon is built as73// elements, the URL is assigned through the `src` property, and the colour is74// clamped to the palette the example actually ships images for so a peer cannot75// point the image anywhere else either.76const PROFILE_COLORS = ['red', 'yellow', 'orange', 'green', 'blue', 'purple'];77const profileImageSrc = (color) =>78 `./images/profile-${79 PROFILE_COLORS.includes(color) ? color : PROFILE_COLORS[0]80 }.svg`;8182const createUserIcon = (color) => {83 const $peer = document.createElement('div');84 $peer.className = 'peer';85 const $profile = document.createElement('div');86 $profile.className = 'profile';87 const $img = document.createElement('img');88 $img.className = 'profile-img';89 $img.alt = 'profile';90 $img.src = profileImageSrc(color);91 $profile.appendChild($img);92 $peer.appendChild($profile);93 return $peer;94};9596const createSmallUserProfile = (color, name) => {97 const $peer = document.createElement('div');98 $peer.className = 'small-peer';99 const $userIcon = createUserIcon(color);100 $userIcon.className = 'user-icon';101 $peer.appendChild($userIcon);102 $peer.appendChild(document.createElement('span'));103 $peer.querySelector('span').className = 'name';104 // `name` is whatever a peer typed into its own presence, so it goes in as105 // text. Interpolating it into `innerHTML` let any collaborator run script in106 // every other participant's page just by saving a display name like107 // `<img src=x onerror=...>`.108 $peer.querySelector('span').textContent = name;109 return $peer;110};111112export const renderPeerList = () => {113 const $peerList = document.getElementById('peerList');114 $peerList.innerHTML = '';115 const $me = createUserIcon(myPresence.color);116 const $profile = $me.querySelector('.profile');117 $profile.addEventListener('click', () => {118 activeSpeechBubbleIndex = SPEECH_BUBBLE_INDEX.me;119 renderSpeechBubble(activeSpeechBubbleIndex);120 });121 $me.classList.add('me');122 $peerList.appendChild($me);123124 peerList.forEach((peer, i) => {125 const { color } = peer.presence;126 if (i < MAX_PEER_VIEW) {127 const $peer = createUserIcon(color);128 const $profile = $peer.querySelector('.profile');129 $profile.addEventListener('click', () => {130 activeSpeechBubbleIndex = SPEECH_BUBBLE_INDEX.peer(i);131 renderSpeechBubble(activeSpeechBubbleIndex);132 });133 $peerList.appendChild($peer);134 return;135 }136 });137138 const hasMorePeers = peerList.length > MAX_PEER_VIEW;139140 if (hasMorePeers) {141 const $viewMore = document.createElement('div');142 $viewMore.className = 'peer more';143 $viewMore.innerHTML = `144 <div class="profile">145 +${peerList.length - MAX_PEER_VIEW}146 </div>147 `;148 $peerList.appendChild($viewMore);149 const $profile = $viewMore.querySelector('.profile');150 $profile.addEventListener('click', () => {151 activeSpeechBubbleIndex = SPEECH_BUBBLE_INDEX.more;152 renderSpeechBubble(activeSpeechBubbleIndex);153 });154 }155};156157// speech bubble158const createSpeechBubbleContainer = () => {159 const $speechBubbleContainer = document.createElement('div');160 $speechBubbleContainer.className = 'speech-bubble';161 return $speechBubbleContainer;162};163164const createUserNameSpeechBubble = (name, isMe) => {165 const $speechBubbleContainer = createSpeechBubbleContainer();166 const $editProfileBtn = document.createElement('button');167 $editProfileBtn.className = 'edit-profile-btn';168 $editProfileBtn.innerHTML = 'Edit Profile';169 $editProfileBtn.addEventListener('click', openEditModal);170 // See `createSmallUserProfile`: `name` is peer-controlled, so the bubble is171 // built as an element with a text child rather than interpolated markup.172 const $name = document.createElement('span');173 $name.className = 'name';174 $name.textContent = `${name}${isMe ? ' (me)' : ''}`;175 $speechBubbleContainer.appendChild($name);176 if (isMe) {177 $speechBubbleContainer.classList.add('me');178 $speechBubbleContainer.appendChild($editProfileBtn);179 }180 return $speechBubbleContainer;181};182183const createPeerListSpeechBubble = (moreUserProfiles) => {184 const $speechBubbleContainer = createSpeechBubbleContainer();185 moreUserProfiles.forEach((profile) => {186 $speechBubbleContainer.appendChild(187 createSmallUserProfile(profile.color, profile.name),188 );189 });190 return $speechBubbleContainer;191};192193const removeAllSpeechBubbles = () => {194 const $speechBubble = document.querySelectorAll(`.speech-bubble`);195 $speechBubble.forEach((bubble) => {196 bubble.remove();197 });198};199200export const renderSpeechBubble = (speechBubbleIndex) => {201 removeAllSpeechBubbles();202 let $speechBubble;203 if (speechBubbleIndex === null) return;204 if (speechBubbleIndex === SPEECH_BUBBLE_INDEX.me) {205 $speechBubble = createUserNameSpeechBubble(myPresence?.name, true);206 } else if (speechBubbleIndex === SPEECH_BUBBLE_INDEX.more) {207 const moreUserProfiles = peerList208 .filter((_, i) => i >= MAX_PEER_VIEW)209 .map(({ presence }) => ({210 color: presence.color,211 name: presence.name,212 }));213 $speechBubble = createPeerListSpeechBubble(moreUserProfiles);214 $speechBubble.classList.add('peer-more-list');215 } else {216 const peerName = peerList[speechBubbleIndex - 1].presence.name;217 $speechBubble = createUserNameSpeechBubble(peerName, false);218 }219 const $targetPeer =220 document.querySelectorAll(`#peerList .peer`)[speechBubbleIndex];221 $targetPeer.appendChild($speechBubble);222};223224// modal225const initEditProfileModal = () => {226 const $editProfileModal = document.getElementById('editProfileModal');227 const $editProfileModalCloseBtn = $editProfileModal.querySelector('.close');228 $editProfileModalCloseBtn.addEventListener('click', closeEditModal);229 const $editProfileModalInput = $editProfileModal.querySelector('input');230 $editProfileModalInput.defaultValue = myPresence?.name;231 const $editProfileModalSaveBtn = $editProfileModal.querySelector('.save');232 $editProfileModalSaveBtn.addEventListener('click', saveEditProfile);233 const $editProfileModalImg = $editProfileModal.querySelector('.profile-img');234 $editProfileModalImg.src = profileImageSrc(myPresence?.color);235};236237const openEditModal = () => {238 const $editProfileModal = document.getElementById('editProfileModal');239 $editProfileModal.style.display = 'block';240};241242const closeEditModal = () => {243 const $editProfileModal = document.getElementById('editProfileModal');244 $editProfileModal.style.display = 'none';245};246247const saveEditProfile = async () => {248 const $editProfileModal = document.getElementById('editProfileModal');249 const $editProfileModalInput = $editProfileModal.querySelector('input');250 const newName = $editProfileModalInput.value;251 doc.update((_, presence) => {252 presence.set({253 name: newName,254 });255 });256 closeEditModal();257};258259main();
- User 1
- User 2
- User 1
- User 2