diff --git a/bun.lock b/bun.lock index 8c57a426..b3e636ba 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@zenfs/dom": "^1.2.7", "@zenfs/emscripten": "^1.0.5", "ani-cursor": "^0.0.5", + "clippyjs": "^0.1.0", "html2canvas": "^1.4.1", "os-gui": "^0.7.3", }, @@ -421,6 +422,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="], + "clippyjs": ["clippyjs@0.1.0", "", {}, "sha512-XnchzSR8H8X43TvwnAktK5xDewKGSyfGu8DagBZCbOUdx0dWAFrFdxdCWlaJkyfx4M3Amvvu13jbHQ/IYQOD+w=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/index.html b/index.html index 74ae44ee..03170c64 100644 --- a/index.html +++ b/index.html @@ -112,7 +112,6 @@ - - - - - -
-
-
Clippy.js Demo - Reusable Assistant
-
- - - -
-
-
-

This demo showcases the decoupled Clippy library using 98.css. It works on any standard webpage!

- -
- Assistant Settings -
- - -
-
- - -
-
- -
- Actions -
- - - - - - -
-
- -
- Implementation Example -
-
clippy.load('Clippy', function(agent) {
-    agent.show();
-    agent.setRecommendedVoice();
-
-    // New reusable Ask feature
-    agent.ask({
-        title: 'User Prompt',
-        onAsk: (val) => agent.speak('Received: ' + val)
-    });
-});
-
-
- -
-

Status: Ready

-

Personality: Default

-

CPU Usage: 0%

-
-
-
- - - - - - - - - diff --git a/src/apps/clippy/clippy-app.js b/src/apps/clippy/clippy-app.js index c058b0b2..7983ce6e 100644 --- a/src/apps/clippy/clippy-app.js +++ b/src/apps/clippy/clippy-app.js @@ -38,8 +38,10 @@ export class ClippyApp extends Application { _cleanup() { const agent = window.clippyAgent; if (agent) { - agent.hide(); - $(".clippy, .clippy-balloon").remove(); + agent.hide(false, () => { + agent.dispose(); + $(".clippy, .clippy-balloon").remove(); + }); $(".os-menu").remove(); const trayIcon = document.querySelector("#tray-icon-clippy"); if (trayIcon) { diff --git a/src/apps/clippy/clippy-service.js b/src/apps/clippy/clippy-service.js new file mode 100644 index 00000000..84930ed3 --- /dev/null +++ b/src/apps/clippy/clippy-service.js @@ -0,0 +1,253 @@ +import { initAgent } from 'clippyjs'; +import * as agents from 'clippyjs/agents'; + +/** + * Ported extensions from the legacy clippy_extensions_complete.js + * modified to work with the new clippyjs library. + */ +export function extendAgent(agent) { + /** + * Get the appropriate goodbye animation name + */ + agent.getGoodbyeAnimation = function () { + return this.hasAnimation("Goodbye") ? "Goodbye" : this.hasAnimation("GoodBye") ? "GoodBye" : "Hide"; + }; + + agent.exitAnimation = function() { + this._animator.exitAnimation(); + }; + + /** + * Speak text while simultaneously playing an animation + */ + agent.speakAndAnimate = function (text, animation, options = {}) { + const { callback, hold } = options; + const animationTimeout = options.animationTimeout || 8000; + + if (!this.hasAnimation(animation)) { + this.speak(text, hold); + if (callback) setTimeout(callback, 0); + return; + } + + this._addToQueue(function (complete) { + let speechCompleted = false; + let animationCompleted = false; + let hasCalledComplete = false; + + const checkCompletion = () => { + if (speechCompleted && animationCompleted && !hasCalledComplete) { + hasCalledComplete = true; + if (callback) callback(); + complete(); + } + }; + + // Force animation completion after timeout + let animSafetyTimeout = setTimeout(() => { + if (!animationCompleted) { + this.exitAnimation(); + // Second timeout to force it if exit branch hangs + setTimeout(() => { + if (!animationCompleted) { + animationCompleted = true; + checkCompletion(); + } + }, 1000); + } + }, animationTimeout); + + this._playInternal(animation, (name, state) => { + if (state === 0) { // Animator.States.EXITED + clearTimeout(animSafetyTimeout); + animationCompleted = true; + checkCompletion(); + } + }); + + this._balloon.speak(() => { + speechCompleted = true; + // For looping animations, we need to explicitly exit them when speech is done + this.exitAnimation(); + checkCompletion(); + }, text, hold); + }, this); + }; + + /** + * Show an interactive input balloon + */ + agent.ask = function (options = {}) { + const title = options.title || "What would you like to do?"; + const placeholder = options.placeholder || "Ask me anything..."; + const askButtonText = options.askButtonText || "Ask"; + const cancelButtonText = options.cancelButtonText || "Cancel"; + const timeout = options.timeout || 60000; + let inputBalloonTimeout = null; + + this.stop(); + // clippyjs's stop() triggers a 2-second hide timeout in the balloon. + // We must pause it to keep the balloon open for our custom UI. + this._balloon.pause(); + + const balloonContent = ` +
+ ${title} + +
+ + +
+
`; + + const balloonEl = this._balloon._balloon; + const contentEl = this._balloon._content; + + this._balloon._hidden = false; + this._balloon.show(); + contentEl.style.height = 'auto'; + contentEl.style.width = 'auto'; + contentEl.innerHTML = balloonContent; + + this._balloon.reposition(); + + const $balloon = $(balloonEl); + const $input = $balloon.find("textarea"); + const $askButton = $balloon.find(".ask-button"); + const $cancelButton = $balloon.find(".cancel-button"); + + $input.focus(); + + const resetBalloonTimeout = () => { + if (inputBalloonTimeout) clearTimeout(inputBalloonTimeout); + inputBalloonTimeout = setTimeout(() => this.closeBalloon(), timeout); + }; + + const clearBalloonTimeout = () => { + if (inputBalloonTimeout) clearTimeout(inputBalloonTimeout); + }; + + const askHandler = () => { + clearBalloonTimeout(); + const question = $input.val(); + if (options.onAsk) options.onAsk(question); + this.closeBalloon(); + }; + + $input.on("keypress", (e) => { + resetBalloonTimeout(); + if (e.which === 13) { + e.preventDefault(); + askHandler(); + } + }); + + $askButton.on("click", askHandler); + $cancelButton.on("click", () => { + clearBalloonTimeout(); + if (options.onCancel) options.onCancel(); + this.closeBalloon(); + }); + + resetBalloonTimeout(); + }; + + /** + * speakStream implementation + */ + agent.speakStream = async function(asyncIterable, options = {}) { + const { hold } = options; + const useTTS = options.tts || this.isTTSEnabled(); + + return new Promise((resolve) => { + this._addToQueue(async function(complete) { + this._balloon._hidden = false; + this._balloon.show(); + const contentEl = this._balloon._content; + contentEl.style.height = 'auto'; + contentEl.style.width = 'auto'; + contentEl.textContent = ''; + + // Play an animation during streaming + if (this.hasAnimation("Explain")) { + this._playInternal("Explain"); + } + + let fullText = ''; + try { + for await (const chunk of asyncIterable) { + fullText += chunk; + contentEl.textContent = fullText; + this._balloon.reposition(); + } + } catch (e) { + console.error("Stream error:", e); + } + + if (useTTS) { + const utterance = new SpeechSynthesisUtterance(fullText); + window.speechSynthesis.speak(utterance); + } + + if (this.hasAnimation("Explain")) { + this.exitAnimation(); + } + + if (!hold) { + setTimeout(() => { + this._balloon.hide(); + complete(); + resolve(); + }, this._balloon.CLOSE_BALLOON_DELAY); + } else { + complete(); + resolve(); + } + }, this); + }); + }; + + agent.isTTSEnabled = function() { + return this._ttsEnabled; + }; + + agent.setTTSEnabled = function(enabled) { + this._ttsEnabled = enabled; + }; + + // Helper for speakAndAnimate with TTS + const originalSpeakAndAnimate = agent.speakAndAnimate; + agent.speakAndAnimate = function(text, animation, options = {}) { + if (options.useTTS === undefined) { + options.useTTS = this.isTTSEnabled(); + } + + const originalSpeak = this._balloon.speak; + const self = this; + this._balloon.speak = function(complete, text, hold) { + if (options.useTTS) { + const utterance = new SpeechSynthesisUtterance(text); + window.speechSynthesis.speak(utterance); + } + originalSpeak.call(this, complete, text, hold); + }; + + originalSpeakAndAnimate.call(this, text, animation, options); + this._balloon.speak = originalSpeak; + }; + + agent.setRecommendedVoice = function() { + // Not implemented for now + }; +} + +export async function loadAgent(name) { + const lowercaseName = name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); + const agentLoader = agents[lowercaseName] || agents.Clippy; + if (!agentLoader) { + throw new Error(`Agent ${name} not found`); + } + const agent = await initAgent(agentLoader); + extendAgent(agent); + return agent; +} diff --git a/src/apps/clippy/clippy.js b/src/apps/clippy/clippy.js index 5f82790d..146ef3d9 100644 --- a/src/apps/clippy/clippy.js +++ b/src/apps/clippy/clippy.js @@ -8,16 +8,34 @@ import { releaseBusyState, } from '../../system/busy-state-manager.js'; import { appManager } from '../../system/app-manager.js'; +import { AGENT_NAMES } from '../../config/agents.js'; +import { loadAgent } from './clippy-service.js'; window.clippyAppInstance = null; let currentAgentName = getItem(LOCAL_STORAGE_KEYS.CLIPPY_AGENT_NAME) || "Clippy"; +const activeBusyStates = new Set(); + function setCurrentAgentName(name) { currentAgentName = name; setItem(LOCAL_STORAGE_KEYS.CLIPPY_AGENT_NAME, name); } +function clearAllBusyStates() { + const agent = window.clippyAgent; + if (!agent) return; + + const clippyEl = agent._el; + const balloonEl = agent._balloon._balloon; + + activeBusyStates.forEach(speakId => { + releaseBusyState(speakId, clippyEl); + releaseBusyState(speakId, balloonEl); + }); + activeBusyStates.clear(); +} + function showClippyInputBalloon() { const agent = window.clippyAgent; if (!agent) return; @@ -33,6 +51,10 @@ async function askClippy(agent, question) { if (!question || question.trim().length === 0) return; const ttsEnabled = agent.isTTSEnabled(); + const clippyEl = agent._el; + const balloonEl = agent._balloon._balloon; + const speakId = `speak-${Date.now()}`; + agent.speakAndAnimate("Let me think about it...", "Thinking", { useTTS: ttsEnabled, }); @@ -44,12 +66,22 @@ async function askClippy(agent, question) { ); const data = await response.json(); - for (const fragment of data) { - const cleanAnswer = fragment.answer.replace(/\*\*/g, ""); - await agent.speakAndAnimate(cleanAnswer, fragment.animation, { - useTTS: ttsEnabled, - }); + requestBusyState(speakId, clippyEl); + requestBusyState(speakId, balloonEl); + activeBusyStates.add(speakId); + + // Streaming implementation using speakStream + async function* answerStream() { + for (const fragment of data) { + const cleanAnswer = fragment.answer.replace(/\*\*/g, ""); + yield cleanAnswer + " "; + } } + + await agent.speakStream(answerStream(), { + tts: ttsEnabled + }); + } catch (error) { agent.speakAndAnimate( "Sorry, I couldn't get an answer for that at this time!", @@ -57,11 +89,13 @@ async function askClippy(agent, question) { { useTTS: ttsEnabled }, ); console.error("API Error:", error); + } finally { + releaseBusyState(speakId, clippyEl); + releaseBusyState(speakId, balloonEl); + activeBusyStates.delete(speakId); } } -import { AGENT_NAMES } from '../../config/agents.js'; - export function getClippyMenuItems(app) { const appInstance = app || window.clippyAppInstance; const agent = window.clippyAgent; @@ -126,7 +160,8 @@ export function getClippyMenuItems(app) { { useTTS: ttsEnabled, callback: () => { - agent.play(agent.getGoodbyeAnimation(), 5000, () => { + const goodbyeAnim = agent.getGoodbyeAnimation(); + agent.play(goodbyeAnim, 5000, () => { if (appInstance) { appManager.closeApp(appInstance.id); } @@ -144,104 +179,107 @@ export function showClippyContextMenu(event, app) { new window.ContextMenu(menuItems, event); } -export function launchClippyApp(app, agentName = currentAgentName) { +let isLaunching = false; +export async function launchClippyApp(app, agentName = currentAgentName) { + if (isLaunching) return; + isLaunching = true; + if (app) { window.clippyAppInstance = app; } const appInstance = app || window.clippyAppInstance; - if (window.clippyAgent) { - // Gracefully hide and remove the current agent before loading a new one - window.clippyAgent.hide(() => { - $(".clippy, .clippy-balloon").remove(); + // Ensure the menu is removed if it exists + const existingMenus = document.querySelectorAll(".menu-popup"); + existingMenus.forEach((menu) => menu.remove()); + + const oldAgent = window.clippyAgent; + if (oldAgent) { + clearAllBusyStates(); + await new Promise((resolve) => { + oldAgent.hide(false, () => { + oldAgent.dispose(); + $(".clippy, .clippy-balloon").remove(); + resolve(); + }); }); + window.clippyAgent = null; } else { $(".clippy, .clippy-balloon").remove(); } - // Ensure the menu is removed if it exists - const existingMenus = document.querySelectorAll(".menu-popup"); - existingMenus.forEach((menu) => menu.remove()); + try { + const agent = await loadAgent(agentName); + window.clippyAgent = agent; + agent._el.setAttribute('data-testid', 'clippy-agent'); - clippy.load(agentName, function (agent) { - window.clippyAgent = agent; - agent._el[0].setAttribute('data-testid', 'clippy-agent'); + const ttsUserPref = getItem(LOCAL_STORAGE_KEYS.CLIPPY_TTS_ENABLED) ?? true; + agent.setTTSEnabled(ttsUserPref); - const ttsUserPref = getItem(LOCAL_STORAGE_KEYS.CLIPPY_TTS_ENABLED) ?? true; - agent.setTTSEnabled(ttsUserPref); + agent.show(); - agent.show(); + let contextMenuOpened = false; - let contextMenuOpened = false; + const ttsEnabled = agent.isTTSEnabled(); - const ttsEnabled = agent.isTTSEnabled(); - if (ttsEnabled) { - const setDefaultVoice = () => { - agent.setRecommendedVoice(); - }; - if (window.speechSynthesis.getVoices().length) { - setDefaultVoice(); - } else { - window.speechSynthesis.addEventListener( - "voiceschanged", - setDefaultVoice, - { once: true }, - ); - } - } + agent.isSpeaking = false; // Initial state - agent.isSpeaking = false; // Initial state - - // Wrap the original speakAndAnimate function - const originalSpeakAndAnimate = agent.speakAndAnimate; - agent.speakAndAnimate = function (text, animation, options) { - agent.isSpeaking = true; - - const clippyEl = agent._el[0]; - const balloonEl = agent._balloon._balloon[0]; - const speakId = `speak-${Date.now()}`; - requestBusyState(speakId, clippyEl); - requestBusyState(speakId, balloonEl); - - const originalCallback = options?.callback; - const newOptions = { - ...options, - callback: () => { - if (originalCallback) { - originalCallback(); - } - agent.isSpeaking = false; - releaseBusyState(speakId, clippyEl); - releaseBusyState(speakId, balloonEl); - }, + // Wrap the original speakAndAnimate function + const originalSpeakAndAnimate = agent.speakAndAnimate; + agent.speakAndAnimate = function (text, animation, options) { + agent.isSpeaking = true; + + const clippyEl = agent._el; + const balloonEl = agent._balloon._balloon; + const speakId = `speak-${Date.now()}`; + requestBusyState(speakId, clippyEl); + requestBusyState(speakId, balloonEl); + activeBusyStates.add(speakId); + + const originalCallback = options?.callback; + const newOptions = { + ...options, + callback: () => { + if (originalCallback) { + originalCallback(); + } + agent.isSpeaking = false; + releaseBusyState(speakId, clippyEl); + releaseBusyState(speakId, balloonEl); + activeBusyStates.delete(speakId); + }, + }; + return originalSpeakAndAnimate.call(this, text, animation, newOptions); }; - return originalSpeakAndAnimate.call(this, text, animation, newOptions); - }; - agent.speakAndAnimate( - "Hey, there. Want quick answers to your questions? Just click me.", - "Explain", - { useTTS: ttsEnabled }, - ); + agent.speakAndAnimate( + "Hey, there. Want quick answers to your questions? Just click me.", + "Explain", + { useTTS: ttsEnabled }, + ); - agent._el.on("click", (e) => { - if (contextMenuOpened) { - contextMenuOpened = false; - return; - } - if (agent.isSpeaking) return; - // Also check if a context menu is open - if (document.querySelector(".menu-popup")) return; - showClippyInputBalloon(); - }); + $(agent._el).on("click", (e) => { + if (contextMenuOpened) { + contextMenuOpened = false; + return; + } + if (agent.isSpeaking) return; + // Also check if a context menu is open + if (document.querySelector(".menu-popup")) return; + showClippyInputBalloon(); + }); - agent._el.on("contextmenu", function (e) { - if (agent.isSpeaking) return; - e.preventDefault(); - contextMenuOpened = true; - showClippyContextMenu(e, appInstance); - }); - }); + $(agent._el).on("contextmenu", function (e) { + if (agent.isSpeaking) return; + e.preventDefault(); + contextMenuOpened = true; + showClippyContextMenu(e, appInstance); + }); + } catch (error) { + console.error("Failed to load clippy agent:", error); + } finally { + isLaunching = false; + } } function startTutorial(agent) { @@ -249,14 +287,8 @@ function startTutorial(agent) { agent.stop(); const ttsEnabled = agent.isTTSEnabled(); - const initialPos = agent._el.offset(); - - const getElementTopLeft = (selector) => { - const el = document.querySelector(selector); - if (!el) return null; - const rect = el.getBoundingClientRect(); - return { x: rect.left, y: rect.top }; - }; + const $el = $(agent._el); + const initialPos = $el.offset(); const getElementCenter = (selector) => { const el = document.querySelector(selector); @@ -266,11 +298,14 @@ function startTutorial(agent) { }; const playGesture = (x, y, callback) => { - const direction = agent._getDirection(x, y); - const gestureAnim = "Gesture" + direction; - const lookAnim = "Look" + direction; - const animation = agent.hasAnimation(gestureAnim) ? gestureAnim : lookAnim; - agent.play(animation, 3000, callback); + agent.gestureAt(x, y); + // Gesture animations in clippyjs are just animations that are queued. + // They don't have a direct callback but we can queue one. + agent.delay(3000); + agent._addToQueue((complete) => { + if (callback) callback(); + complete(); + }); }; const toggleIconHighlight = (iconEl, highlight) => { @@ -301,7 +336,7 @@ function startTutorial(agent) { // 2. Start Menu if (startButton) { sequence.push((done) => - agent._el.animate( + $el.animate( { top: startButton.y - 80, left: startButton.x + 80 }, 1500, done, @@ -342,7 +377,7 @@ function startTutorial(agent) { // 3. Desktop Icons sequence.push((done) => - agent._el.animate( + $el.animate( { top: iconsArea.y, left: iconsArea.x + 100 }, 1500, done, @@ -357,210 +392,49 @@ function startTutorial(agent) { ), ); - const internetExplorerIcon = getElementTopLeft( - '.desktop-icon[data-app-id="internet-explorer"]', - ); - const webampIcon = getElementTopLeft('.desktop-icon[data-app-id="webamp"]'); - const pinballIcon = getElementTopLeft('.desktop-icon[data-app-id="pinball"]'); - const briefcaseIcon = getElementTopLeft( - '.desktop-icon[data-app-id="my-briefcase"]', - ); - const coffeeIcon = getElementTopLeft( - '.desktop-icon[data-app-id="buy-me-a-coffee"]', - ); - const readmeIcon = getElementTopLeft( - '.desktop-icon[data-app-id="file-readme"]', - ); - - // 4. Internet Explorer - if (internetExplorerIcon) { - const iconEl = document.querySelector( - '.desktop-icon[data-app-id="internet-explorer"]', - ); - sequence.push((done) => - agent._el.animate( - { top: internetExplorerIcon.y, left: internetExplorerIcon.x + 80 }, - 1500, - done, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, true); - playGesture(internetExplorerIcon.x, internetExplorerIcon.y, () => { - setTimeout(done, 500); - }); - }); - sequence.push((done) => - agent.speakAndAnimate( - "Surf the web like it's 1999. Open any URL and Internet Explorer will load the page as it was in 1999. Really.", - "Explain", - { useTTS: ttsEnabled, callback: done }, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, false); - done(); - }); - } - - // 5. Winamp - if (webampIcon) { - const iconEl = document.querySelector( - '.desktop-icon[data-app-id="webamp"]', - ); - sequence.push((done) => - agent._el.animate( - { top: webampIcon.y, left: webampIcon.x + 80 }, - 1500, - done, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, true); - playGesture(webampIcon.x, webampIcon.y, () => { - setTimeout(done, 500); - }); - }); - sequence.push((done) => - agent.speakAndAnimate( - "Got some mp3 files? Play it with Winamp! Customize the skin as well!", - "Explain", - { useTTS: ttsEnabled, callback: done }, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, false); - done(); - }); - } - - // 6. Pinball - if (pinballIcon) { - const iconEl = document.querySelector( - '.desktop-icon[data-app-id="pinball"]', - ); - sequence.push((done) => - agent._el.animate( - { top: pinballIcon.y, left: pinballIcon.x + 80 }, - 1500, - done, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, true); - playGesture(pinballIcon.x, pinballIcon.y, () => { - setTimeout(done, 500); - }); - }); - sequence.push((done) => - agent.speakAndAnimate( - "Try playing a round of the classic Space Cadet Pinball game.", - "Explain", - { useTTS: ttsEnabled, callback: done }, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, false); - done(); - }); - } - - // 7. My Briefcase - if (briefcaseIcon) { - const iconEl = document.querySelector( - '.desktop-icon[data-app-id="my-briefcase"]', - ); - sequence.push((done) => - agent._el.animate( - { top: briefcaseIcon.y, left: briefcaseIcon.x + 80 }, - 1500, - done, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, true); - playGesture(briefcaseIcon.x, briefcaseIcon.y, () => { - setTimeout(done, 500); - }); - }); - sequence.push((done) => - agent.speakAndAnimate( - "Drag files from your device to an open My Briefcase window to use it in Windows 98.", - "Explain", - { useTTS: ttsEnabled, callback: done }, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, false); - done(); - }); - } - - // 8. Buy me a coffee - if (coffeeIcon) { - const iconEl = document.querySelector( - '.desktop-icon[data-app-id="buy-me-a-coffee"]', - ); - sequence.push((done) => - agent._el.animate( - { top: coffeeIcon.y, left: coffeeIcon.x + 80 }, - 1500, - done, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, true); - playGesture(coffeeIcon.x, coffeeIcon.y, () => { - setTimeout(done, 500); - }); - }); - sequence.push((done) => - agent.speakAndAnimate( - "If you have some to spare, consider supporting this project to keep it alive and well.", - "Explain", - { useTTS: ttsEnabled, callback: done }, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, false); - done(); - }); - } + const appsToTour = [ + { id: "internet-explorer", text: "Surf the web like it's 1999. Open any URL and Internet Explorer will load the page as it was in 1999. Really." }, + { id: "webamp", text: "Got some mp3 files? Play it with Winamp! Customize the skin as well!" }, + { id: "pinball", text: "Try playing a round of the classic Space Cadet Pinball game." }, + { id: "my-briefcase", text: "Drag files from your device to an open My Briefcase window to use it in Windows 98." }, + { id: "buy-me-a-coffee", text: "If you have some to spare, consider supporting this project to keep it alive and well." }, + { id: "file-readme", text: "For more information about the project, read the README.md file here." } + ]; - // 9. Readme.md - if (readmeIcon) { - const iconEl = document.querySelector( - '.desktop-icon[data-app-id="file-readme"]', - ); - sequence.push((done) => - agent._el.animate( - { top: readmeIcon.y, left: readmeIcon.x + 80 }, - 1500, - done, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, true); - playGesture(readmeIcon.x, readmeIcon.y, () => { - setTimeout(done, 500); - }); - }); - sequence.push((done) => - agent.speakAndAnimate( - "For more information about the project, read the README.md file here.", - "Explain", - { useTTS: ttsEnabled, callback: done }, - ), - ); - sequence.push((done) => { - toggleIconHighlight(iconEl, false); - done(); - }); - } + appsToTour.forEach(app => { + const iconEl = document.querySelector(`.desktop-icon[data-app-id="${app.id}"]`); + if (iconEl) { + const rect = iconEl.getBoundingClientRect(); + sequence.push((done) => + $el.animate( + { top: rect.top, left: rect.left + 80 }, + 1500, + done, + ), + ); + sequence.push((done) => { + toggleIconHighlight(iconEl, true); + playGesture(rect.left, rect.top, () => { + setTimeout(done, 500); + }); + }); + sequence.push((done) => + agent.speakAndAnimate( + app.text, + "Explain", + { useTTS: ttsEnabled, callback: done }, + ), + ); + sequence.push((done) => { + toggleIconHighlight(iconEl, false); + done(); + }); + } + }); // 10. Return home sequence.push((done) => - agent._el.animate( + $el.animate( { top: initialPos.top, left: initialPos.left }, 2000, done,