diff --git a/dist/extension.zip b/dist/extension.zip index 4d79e87..13ed65e 100644 Binary files a/dist/extension.zip and b/dist/extension.zip differ diff --git a/dist/extension/birb.js b/dist/extension/birb.js index b822389..b535cf4 100644 --- a/dist/extension/birb.js +++ b/dist/extension/birb.js @@ -2111,6 +2111,13 @@ * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote */ + /** + * @typedef {Object} SavedBirdPosition + * @property {number} x + * @property {number} y + * @property {number} updatedAt + */ + /** * @typedef {Object} BirbSaveData * @property {string[]} unlockedSpecies @@ -2119,6 +2126,7 @@ * @property {string} currentHat * @property {Partial} settings * @property {SavedStickyNote[]} [stickyNotes] + * @property {Record} [birdPositions] */ /** @@ -2600,11 +2608,16 @@ // Petting boosts const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes - const PET_FEATHER_BOOST = 2; - const PET_HAT_BOOST = 1.5; + const PET_FEATHER_BOOST = 2; // Multiplier for feather effect + const PET_HAT_BOOST = 1.5; // Multiplier for hat effect // Focus element constraints - const MIN_FOCUS_ELEMENT_WIDTH = 100; + const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target + const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow + const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again + const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty" + const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep + const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab /** @type {Partial} */ let userSettings = {}; @@ -2741,7 +2754,7 @@ }), new Separator(), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), - new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), + new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false), ]; /** @type {Birb} */ @@ -2780,6 +2793,13 @@ let currentHat = DEFAULT_HAT; // let visible = true; let lastPetTimestamp = 0; + /** @type {Record} */ + let savedBirdPositions = {}; + let holdRestoredYPosition = false; + let birdPositionDirty = false; + let lastTrackedBirdX = birdX; + let lastTrackedBirdY = birdY; + let birdSessionKey = ""; /** @type {StickyNote[]} */ let stickyNotes = []; @@ -2798,6 +2818,7 @@ currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; currentHat = saveData.currentHat ?? DEFAULT_HAT; + savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions); stickyNotes = []; if (saveData.stickyNotes) { @@ -2832,6 +2853,9 @@ left: note.left })); } + if (Object.keys(savedBirdPositions).length > 0) { + saveData.birdPositions = savedBirdPositions; + } getContext().putSaveData(saveData); } @@ -2919,19 +2943,26 @@ drawStickyNotes(stickyNotes, save, deleteStickyNote); - let lastPath = getContext().getPath().split("?")[0]; + let lastPath = normalizePath(getContext().getPath()); setInterval(() => { - const currentPath = getContext().getPath().split("?")[0]; + const currentPath = normalizePath(getContext().getPath()); if (currentPath !== lastPath) { log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); + saveBirdPosition(true); lastPath = currentPath; drawStickyNotes(stickyNotes, save, deleteStickyNote); + restoreBirdPosition(); } }, URL_CHECK_INTERVAL); setInterval(update, UPDATE_INTERVAL); + setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL); + window.addEventListener("pagehide", () => saveBirdPosition(true)); + window.addEventListener("beforeunload", () => saveBirdPosition(true)); - flyToElement(true); + if (!restoreBirdPosition()) { + flyToElement(true); + } } function update() { @@ -2992,7 +3023,9 @@ if (focusedElement && !isWithinHorizontalBounds()) { flyToElement(); } - birdY = getFocusedY(); + if (focusedElement || !holdRestoredYPosition) { + birdY = getFocusedY(); + } } else if (currentState === States.FLYING) { // Fly to target location (even if in the air) if (updateParabolicPath(FLY_SPEED, 2)) { @@ -3022,6 +3055,13 @@ // Update HTML element position birb.setX(birdX); birb.setY(birdY); + const movedX = Math.abs(birdX - lastTrackedBirdX); + const movedY = Math.abs(birdY - lastTrackedBirdY); + if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) { + birdPositionDirty = true; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + } } /** @@ -3578,6 +3618,7 @@ if (frozen) { return false; } + holdRestoredYPosition = false; const previousElement = focusedElement; focusedElement = getRandomValidElement(); updateFocusedElementBounds(); @@ -3594,6 +3635,7 @@ * @param {number} y */ function teleportTo(x, y) { + holdRestoredYPosition = false; birdX = x; birdY = y; setState(States.IDLE); @@ -3628,11 +3670,16 @@ focusedBounds = { left, right, top }; } + function getCanvasWidth() { + return birb.getElementWidth(); + } + function hop() { if (frozen) { return; } if (currentState === States.IDLE) { + holdRestoredYPosition = false; setState(States.HOP); birb.setAnimation(Animations.FLYING); if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { @@ -3663,6 +3710,7 @@ * @param {number} y */ function flyTo(x, y) { + holdRestoredYPosition = false; targetX = x; targetY = y; setState(States.FLYING); @@ -3692,6 +3740,183 @@ birb.setY(birdY); } + /** + * @param {unknown} value + * @returns {Record} + */ + function sanitizeSavedBirdPositions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + /** @type {Record} */ + const result = {}; + for (const [key, position] of Object.entries(value)) { + if (!position || typeof position !== "object" || Array.isArray(position)) { + continue; + } + // @ts-expect-error + const x = Number(position.x); + // @ts-expect-error + const y = Number(position.y); + // @ts-expect-error + const updatedAt = Number(position.updatedAt ?? 0); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 }; + } + return result; + } + + /** + * @param {string} path + * @returns {string} + */ + function normalizePath(path) { + return path.split("?")[0].split("#")[0]; + } + + function trimSavedBirdPositions() { + const entries = Object.entries(savedBirdPositions); + if (entries.length <= MAX_SAVED_BIRD_POSITIONS) { + return; + } + entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt); + for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) { + delete savedBirdPositions[entries[i][0]]; + } + } + + function getBirdPositionScopeKey() { + if (birdSessionKey) { + return birdSessionKey; + } + + const existingWindowName = typeof window.name === "string" ? window.name : ""; + const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER); + if (markerIndex >= 0) { + const end = existingWindowName.indexOf("|", markerIndex); + birdSessionKey = end >= 0 + ? existingWindowName.slice(markerIndex, end) + : existingWindowName.slice(markerIndex); + return birdSessionKey; + } + + const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`; + + try { + window.name = existingWindowName + ? `${existingWindowName}|${birdSessionKey}` + : birdSessionKey; + } catch { + // Ignore if the page blocks changing window.name. + } + + return birdSessionKey; + } + + /** + * @param {boolean} [force] + */ + function saveBirdPosition(force = false) { + if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) { + return; + } + if (!force && !birdPositionDirty) { + return; + } + + const now = Date.now(); + const scopeKey = getBirdPositionScopeKey(); + const previous = savedBirdPositions[scopeKey]; + if (!force && previous) { + const movedX = Math.abs(previous.x - birdX); + const movedY = Math.abs(previous.y - birdY); + if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) { + birdPositionDirty = false; + return; + } + } + + savedBirdPositions[scopeKey] = { + x: birdX, + y: birdY, + updatedAt: now + }; + trimSavedBirdPositions(); + birdPositionDirty = false; + save(); + } + + /** + * @returns {boolean} + */ + function restoreBirdPosition() { + const scopeKey = getBirdPositionScopeKey(); + const saved = savedBirdPositions[scopeKey]; + if (!saved) { + holdRestoredYPosition = false; + return false; + } + + const maxX = Math.max(0, window.innerWidth - getCanvasWidth()); + const maxY = getWindowHeight() * 1.5; + birdX = Math.min(Math.max(saved.x, 0), maxX); + birdY = Math.min(Math.max(saved.y, 0), maxY); + + // Attempt to keep the bird perched if an element still exists near the saved position. + focusedElement = getElementAtPosition(birdX, birdY); + updateFocusedElementBounds(); + + holdRestoredYPosition = focusedElement === null; + birdPositionDirty = false; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + + setState(States.IDLE); + birb.setX(birdX); + birb.setY(birdY); + return true; + } + + /** + * @param {number} x + * @param {number} y + * @returns {HTMLElement|null} + */ + function getElementAtPosition(x, y) { + const desiredTop = getWindowHeight() - y; + let bestElement = null; + let bestScore = Number.POSITIVE_INFINITY; + const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + continue; + } + const xDistance = Math.abs((rect.left + rect.right) / 2 - x); + const yDistance = Math.abs(rect.top - desiredTop); + const score = xDistance + yDistance * 1.5; + if (score < bestScore) { + bestScore = score; + bestElement = element; + } + } + if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) { + return null; + } + return bestElement; + } + // Helper functions /** diff --git a/dist/extension/manifest.json b/dist/extension/manifest.json index 7aaadfd..e5b6f4a 100644 --- a/dist/extension/manifest.json +++ b/dist/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Pocket Bird", "description": "It's a pet bird in your browser, what more could you want?", - "version": "2026.4.4", + "version": "2026.4.6", "homepage_url": "https://idreesinc.com", "icons": { "48": "images/icons/transparent/48x48x1.png", diff --git a/dist/obsidian/main.js b/dist/obsidian/main.js index 417086a..0e6cb2c 100644 --- a/dist/obsidian/main.js +++ b/dist/obsidian/main.js @@ -1,7 +1,7 @@ const { Plugin, Notice } = require('obsidian'); module.exports = class PocketBird extends Plugin { onload() { - console.log("Loading Pocket Bird version 2026.4.4..."); + console.log("Loading Pocket Bird version 2026.4.6..."); const OBSIDIAN_PLUGIN = this; (function () { 'use strict'; @@ -2144,6 +2144,13 @@ module.exports = class PocketBird extends Plugin { * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote */ + /** + * @typedef {Object} SavedBirdPosition + * @property {number} x + * @property {number} y + * @property {number} updatedAt + */ + /** * @typedef {Object} BirbSaveData * @property {string[]} unlockedSpecies @@ -2152,6 +2159,7 @@ module.exports = class PocketBird extends Plugin { * @property {string} currentHat * @property {Partial} settings * @property {SavedStickyNote[]} [stickyNotes] + * @property {Record} [birdPositions] */ /** @@ -2633,11 +2641,16 @@ module.exports = class PocketBird extends Plugin { // Petting boosts const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes - const PET_FEATHER_BOOST = 2; - const PET_HAT_BOOST = 1.5; + const PET_FEATHER_BOOST = 2; // Multiplier for feather effect + const PET_HAT_BOOST = 1.5; // Multiplier for hat effect // Focus element constraints - const MIN_FOCUS_ELEMENT_WIDTH = 100; + const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target + const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow + const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again + const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty" + const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep + const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab /** @type {Partial} */ let userSettings = {}; @@ -2774,7 +2787,7 @@ module.exports = class PocketBird extends Plugin { }), new Separator(), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), - new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), + new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false), ]; /** @type {Birb} */ @@ -2813,6 +2826,13 @@ module.exports = class PocketBird extends Plugin { let currentHat = DEFAULT_HAT; // let visible = true; let lastPetTimestamp = 0; + /** @type {Record} */ + let savedBirdPositions = {}; + let holdRestoredYPosition = false; + let birdPositionDirty = false; + let lastTrackedBirdX = birdX; + let lastTrackedBirdY = birdY; + let birdSessionKey = ""; /** @type {StickyNote[]} */ let stickyNotes = []; @@ -2831,6 +2851,7 @@ module.exports = class PocketBird extends Plugin { currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; currentHat = saveData.currentHat ?? DEFAULT_HAT; + savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions); stickyNotes = []; if (saveData.stickyNotes) { @@ -2865,6 +2886,9 @@ module.exports = class PocketBird extends Plugin { left: note.left })); } + if (Object.keys(savedBirdPositions).length > 0) { + saveData.birdPositions = savedBirdPositions; + } getContext().putSaveData(saveData); } @@ -2952,19 +2976,26 @@ module.exports = class PocketBird extends Plugin { drawStickyNotes(stickyNotes, save, deleteStickyNote); - let lastPath = getContext().getPath().split("?")[0]; + let lastPath = normalizePath(getContext().getPath()); setInterval(() => { - const currentPath = getContext().getPath().split("?")[0]; + const currentPath = normalizePath(getContext().getPath()); if (currentPath !== lastPath) { log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); + saveBirdPosition(true); lastPath = currentPath; drawStickyNotes(stickyNotes, save, deleteStickyNote); + restoreBirdPosition(); } }, URL_CHECK_INTERVAL); setInterval(update, UPDATE_INTERVAL); + setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL); + window.addEventListener("pagehide", () => saveBirdPosition(true)); + window.addEventListener("beforeunload", () => saveBirdPosition(true)); - flyToElement(true); + if (!restoreBirdPosition()) { + flyToElement(true); + } } function update() { @@ -3025,7 +3056,9 @@ module.exports = class PocketBird extends Plugin { if (focusedElement && !isWithinHorizontalBounds()) { flyToElement(); } - birdY = getFocusedY(); + if (focusedElement || !holdRestoredYPosition) { + birdY = getFocusedY(); + } } else if (currentState === States.FLYING) { // Fly to target location (even if in the air) if (updateParabolicPath(FLY_SPEED, 2)) { @@ -3055,6 +3088,13 @@ module.exports = class PocketBird extends Plugin { // Update HTML element position birb.setX(birdX); birb.setY(birdY); + const movedX = Math.abs(birdX - lastTrackedBirdX); + const movedY = Math.abs(birdY - lastTrackedBirdY); + if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) { + birdPositionDirty = true; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + } } /** @@ -3611,6 +3651,7 @@ module.exports = class PocketBird extends Plugin { if (frozen) { return false; } + holdRestoredYPosition = false; const previousElement = focusedElement; focusedElement = getRandomValidElement(); updateFocusedElementBounds(); @@ -3627,6 +3668,7 @@ module.exports = class PocketBird extends Plugin { * @param {number} y */ function teleportTo(x, y) { + holdRestoredYPosition = false; birdX = x; birdY = y; setState(States.IDLE); @@ -3661,11 +3703,16 @@ module.exports = class PocketBird extends Plugin { focusedBounds = { left, right, top }; } + function getCanvasWidth() { + return birb.getElementWidth(); + } + function hop() { if (frozen) { return; } if (currentState === States.IDLE) { + holdRestoredYPosition = false; setState(States.HOP); birb.setAnimation(Animations.FLYING); if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { @@ -3696,6 +3743,7 @@ module.exports = class PocketBird extends Plugin { * @param {number} y */ function flyTo(x, y) { + holdRestoredYPosition = false; targetX = x; targetY = y; setState(States.FLYING); @@ -3725,6 +3773,183 @@ module.exports = class PocketBird extends Plugin { birb.setY(birdY); } + /** + * @param {unknown} value + * @returns {Record} + */ + function sanitizeSavedBirdPositions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + /** @type {Record} */ + const result = {}; + for (const [key, position] of Object.entries(value)) { + if (!position || typeof position !== "object" || Array.isArray(position)) { + continue; + } + // @ts-expect-error + const x = Number(position.x); + // @ts-expect-error + const y = Number(position.y); + // @ts-expect-error + const updatedAt = Number(position.updatedAt ?? 0); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 }; + } + return result; + } + + /** + * @param {string} path + * @returns {string} + */ + function normalizePath(path) { + return path.split("?")[0].split("#")[0]; + } + + function trimSavedBirdPositions() { + const entries = Object.entries(savedBirdPositions); + if (entries.length <= MAX_SAVED_BIRD_POSITIONS) { + return; + } + entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt); + for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) { + delete savedBirdPositions[entries[i][0]]; + } + } + + function getBirdPositionScopeKey() { + if (birdSessionKey) { + return birdSessionKey; + } + + const existingWindowName = typeof window.name === "string" ? window.name : ""; + const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER); + if (markerIndex >= 0) { + const end = existingWindowName.indexOf("|", markerIndex); + birdSessionKey = end >= 0 + ? existingWindowName.slice(markerIndex, end) + : existingWindowName.slice(markerIndex); + return birdSessionKey; + } + + const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`; + + try { + window.name = existingWindowName + ? `${existingWindowName}|${birdSessionKey}` + : birdSessionKey; + } catch { + // Ignore if the page blocks changing window.name. + } + + return birdSessionKey; + } + + /** + * @param {boolean} [force] + */ + function saveBirdPosition(force = false) { + if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) { + return; + } + if (!force && !birdPositionDirty) { + return; + } + + const now = Date.now(); + const scopeKey = getBirdPositionScopeKey(); + const previous = savedBirdPositions[scopeKey]; + if (!force && previous) { + const movedX = Math.abs(previous.x - birdX); + const movedY = Math.abs(previous.y - birdY); + if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) { + birdPositionDirty = false; + return; + } + } + + savedBirdPositions[scopeKey] = { + x: birdX, + y: birdY, + updatedAt: now + }; + trimSavedBirdPositions(); + birdPositionDirty = false; + save(); + } + + /** + * @returns {boolean} + */ + function restoreBirdPosition() { + const scopeKey = getBirdPositionScopeKey(); + const saved = savedBirdPositions[scopeKey]; + if (!saved) { + holdRestoredYPosition = false; + return false; + } + + const maxX = Math.max(0, window.innerWidth - getCanvasWidth()); + const maxY = getWindowHeight() * 1.5; + birdX = Math.min(Math.max(saved.x, 0), maxX); + birdY = Math.min(Math.max(saved.y, 0), maxY); + + // Attempt to keep the bird perched if an element still exists near the saved position. + focusedElement = getElementAtPosition(birdX, birdY); + updateFocusedElementBounds(); + + holdRestoredYPosition = focusedElement === null; + birdPositionDirty = false; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + + setState(States.IDLE); + birb.setX(birdX); + birb.setY(birdY); + return true; + } + + /** + * @param {number} x + * @param {number} y + * @returns {HTMLElement|null} + */ + function getElementAtPosition(x, y) { + const desiredTop = getWindowHeight() - y; + let bestElement = null; + let bestScore = Number.POSITIVE_INFINITY; + const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + continue; + } + const xDistance = Math.abs((rect.left + rect.right) / 2 - x); + const yDistance = Math.abs(rect.top - desiredTop); + const score = xDistance + yDistance * 1.5; + if (score < bestScore) { + bestScore = score; + bestElement = element; + } + } + if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) { + return null; + } + return bestElement; + } + // Helper functions /** diff --git a/dist/obsidian/manifest.json b/dist/obsidian/manifest.json index fd707c9..392d62b 100644 --- a/dist/obsidian/manifest.json +++ b/dist/obsidian/manifest.json @@ -1,7 +1,7 @@ { "id": "pocket-bird", "name": "Pocket Bird", - "version": "2026.4.4", + "version": "2026.4.6", "minAppVersion": "0.15.0", "description": "Add a pet bird to fly around your notes and keep you company!", "author": "Idrees Hassan", diff --git a/dist/userscript/birb.user.js b/dist/userscript/birb.user.js index 603516c..1e76b62 100644 --- a/dist/userscript/birb.user.js +++ b/dist/userscript/birb.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Pocket Bird // @namespace https://idreesinc.com -// @version 2026.4.4 +// @version 2026.4.6 // @description It's a pet bird in your browser, what more could you want? // @author Idrees // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js @@ -2106,6 +2106,13 @@ * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote */ + /** + * @typedef {Object} SavedBirdPosition + * @property {number} x + * @property {number} y + * @property {number} updatedAt + */ + /** * @typedef {Object} BirbSaveData * @property {string[]} unlockedSpecies @@ -2114,6 +2121,7 @@ * @property {string} currentHat * @property {Partial} settings * @property {SavedStickyNote[]} [stickyNotes] + * @property {Record} [birdPositions] */ /** @@ -2595,11 +2603,16 @@ // Petting boosts const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes - const PET_FEATHER_BOOST = 2; - const PET_HAT_BOOST = 1.5; + const PET_FEATHER_BOOST = 2; // Multiplier for feather effect + const PET_HAT_BOOST = 1.5; // Multiplier for hat effect // Focus element constraints - const MIN_FOCUS_ELEMENT_WIDTH = 100; + const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target + const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow + const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again + const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty" + const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep + const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab /** @type {Partial} */ let userSettings = {}; @@ -2736,7 +2749,7 @@ }), new Separator(), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), - new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), + new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false), ]; /** @type {Birb} */ @@ -2775,6 +2788,13 @@ let currentHat = DEFAULT_HAT; // let visible = true; let lastPetTimestamp = 0; + /** @type {Record} */ + let savedBirdPositions = {}; + let holdRestoredYPosition = false; + let birdPositionDirty = false; + let lastTrackedBirdX = birdX; + let lastTrackedBirdY = birdY; + let birdSessionKey = ""; /** @type {StickyNote[]} */ let stickyNotes = []; @@ -2793,6 +2813,7 @@ currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; currentHat = saveData.currentHat ?? DEFAULT_HAT; + savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions); stickyNotes = []; if (saveData.stickyNotes) { @@ -2827,6 +2848,9 @@ left: note.left })); } + if (Object.keys(savedBirdPositions).length > 0) { + saveData.birdPositions = savedBirdPositions; + } getContext().putSaveData(saveData); } @@ -2914,19 +2938,26 @@ drawStickyNotes(stickyNotes, save, deleteStickyNote); - let lastPath = getContext().getPath().split("?")[0]; + let lastPath = normalizePath(getContext().getPath()); setInterval(() => { - const currentPath = getContext().getPath().split("?")[0]; + const currentPath = normalizePath(getContext().getPath()); if (currentPath !== lastPath) { log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); + saveBirdPosition(true); lastPath = currentPath; drawStickyNotes(stickyNotes, save, deleteStickyNote); + restoreBirdPosition(); } }, URL_CHECK_INTERVAL); setInterval(update, UPDATE_INTERVAL); + setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL); + window.addEventListener("pagehide", () => saveBirdPosition(true)); + window.addEventListener("beforeunload", () => saveBirdPosition(true)); - flyToElement(true); + if (!restoreBirdPosition()) { + flyToElement(true); + } } function update() { @@ -2987,7 +3018,9 @@ if (focusedElement && !isWithinHorizontalBounds()) { flyToElement(); } - birdY = getFocusedY(); + if (focusedElement || !holdRestoredYPosition) { + birdY = getFocusedY(); + } } else if (currentState === States.FLYING) { // Fly to target location (even if in the air) if (updateParabolicPath(FLY_SPEED, 2)) { @@ -3017,6 +3050,13 @@ // Update HTML element position birb.setX(birdX); birb.setY(birdY); + const movedX = Math.abs(birdX - lastTrackedBirdX); + const movedY = Math.abs(birdY - lastTrackedBirdY); + if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) { + birdPositionDirty = true; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + } } /** @@ -3573,6 +3613,7 @@ if (frozen) { return false; } + holdRestoredYPosition = false; const previousElement = focusedElement; focusedElement = getRandomValidElement(); updateFocusedElementBounds(); @@ -3589,6 +3630,7 @@ * @param {number} y */ function teleportTo(x, y) { + holdRestoredYPosition = false; birdX = x; birdY = y; setState(States.IDLE); @@ -3623,11 +3665,16 @@ focusedBounds = { left, right, top }; } + function getCanvasWidth() { + return birb.getElementWidth(); + } + function hop() { if (frozen) { return; } if (currentState === States.IDLE) { + holdRestoredYPosition = false; setState(States.HOP); birb.setAnimation(Animations.FLYING); if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { @@ -3658,6 +3705,7 @@ * @param {number} y */ function flyTo(x, y) { + holdRestoredYPosition = false; targetX = x; targetY = y; setState(States.FLYING); @@ -3687,6 +3735,183 @@ birb.setY(birdY); } + /** + * @param {unknown} value + * @returns {Record} + */ + function sanitizeSavedBirdPositions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + /** @type {Record} */ + const result = {}; + for (const [key, position] of Object.entries(value)) { + if (!position || typeof position !== "object" || Array.isArray(position)) { + continue; + } + // @ts-expect-error + const x = Number(position.x); + // @ts-expect-error + const y = Number(position.y); + // @ts-expect-error + const updatedAt = Number(position.updatedAt ?? 0); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 }; + } + return result; + } + + /** + * @param {string} path + * @returns {string} + */ + function normalizePath(path) { + return path.split("?")[0].split("#")[0]; + } + + function trimSavedBirdPositions() { + const entries = Object.entries(savedBirdPositions); + if (entries.length <= MAX_SAVED_BIRD_POSITIONS) { + return; + } + entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt); + for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) { + delete savedBirdPositions[entries[i][0]]; + } + } + + function getBirdPositionScopeKey() { + if (birdSessionKey) { + return birdSessionKey; + } + + const existingWindowName = typeof window.name === "string" ? window.name : ""; + const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER); + if (markerIndex >= 0) { + const end = existingWindowName.indexOf("|", markerIndex); + birdSessionKey = end >= 0 + ? existingWindowName.slice(markerIndex, end) + : existingWindowName.slice(markerIndex); + return birdSessionKey; + } + + const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`; + + try { + window.name = existingWindowName + ? `${existingWindowName}|${birdSessionKey}` + : birdSessionKey; + } catch { + // Ignore if the page blocks changing window.name. + } + + return birdSessionKey; + } + + /** + * @param {boolean} [force] + */ + function saveBirdPosition(force = false) { + if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) { + return; + } + if (!force && !birdPositionDirty) { + return; + } + + const now = Date.now(); + const scopeKey = getBirdPositionScopeKey(); + const previous = savedBirdPositions[scopeKey]; + if (!force && previous) { + const movedX = Math.abs(previous.x - birdX); + const movedY = Math.abs(previous.y - birdY); + if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) { + birdPositionDirty = false; + return; + } + } + + savedBirdPositions[scopeKey] = { + x: birdX, + y: birdY, + updatedAt: now + }; + trimSavedBirdPositions(); + birdPositionDirty = false; + save(); + } + + /** + * @returns {boolean} + */ + function restoreBirdPosition() { + const scopeKey = getBirdPositionScopeKey(); + const saved = savedBirdPositions[scopeKey]; + if (!saved) { + holdRestoredYPosition = false; + return false; + } + + const maxX = Math.max(0, window.innerWidth - getCanvasWidth()); + const maxY = getWindowHeight() * 1.5; + birdX = Math.min(Math.max(saved.x, 0), maxX); + birdY = Math.min(Math.max(saved.y, 0), maxY); + + // Attempt to keep the bird perched if an element still exists near the saved position. + focusedElement = getElementAtPosition(birdX, birdY); + updateFocusedElementBounds(); + + holdRestoredYPosition = focusedElement === null; + birdPositionDirty = false; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + + setState(States.IDLE); + birb.setX(birdX); + birb.setY(birdY); + return true; + } + + /** + * @param {number} x + * @param {number} y + * @returns {HTMLElement|null} + */ + function getElementAtPosition(x, y) { + const desiredTop = getWindowHeight() - y; + let bestElement = null; + let bestScore = Number.POSITIVE_INFINITY; + const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + continue; + } + const xDistance = Math.abs((rect.left + rect.right) / 2 - x); + const yDistance = Math.abs(rect.top - desiredTop); + const score = xDistance + yDistance * 1.5; + if (score < bestScore) { + bestScore = score; + bestElement = element; + } + } + if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) { + return null; + } + return bestElement; + } + // Helper functions /** diff --git a/dist/web/birb.embed.js b/dist/web/birb.embed.js index 31365c7..70062f5 100644 --- a/dist/web/birb.embed.js +++ b/dist/web/birb.embed.js @@ -2086,6 +2086,13 @@ * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote */ + /** + * @typedef {Object} SavedBirdPosition + * @property {number} x + * @property {number} y + * @property {number} updatedAt + */ + /** * @typedef {Object} BirbSaveData * @property {string[]} unlockedSpecies @@ -2094,6 +2101,7 @@ * @property {string} currentHat * @property {Partial} settings * @property {SavedStickyNote[]} [stickyNotes] + * @property {Record} [birdPositions] */ /** @@ -2575,11 +2583,16 @@ // Petting boosts const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes - const PET_FEATHER_BOOST = 2; - const PET_HAT_BOOST = 1.5; + const PET_FEATHER_BOOST = 2; // Multiplier for feather effect + const PET_HAT_BOOST = 1.5; // Multiplier for hat effect // Focus element constraints - const MIN_FOCUS_ELEMENT_WIDTH = 100; + const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target + const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow + const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again + const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty" + const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep + const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab /** @type {Partial} */ let userSettings = {}; @@ -2716,7 +2729,7 @@ }), new Separator(), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), - new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), + new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false), ]; /** @type {Birb} */ @@ -2755,6 +2768,13 @@ let currentHat = DEFAULT_HAT; // let visible = true; let lastPetTimestamp = 0; + /** @type {Record} */ + let savedBirdPositions = {}; + let holdRestoredYPosition = false; + let birdPositionDirty = false; + let lastTrackedBirdX = birdX; + let lastTrackedBirdY = birdY; + let birdSessionKey = ""; /** @type {StickyNote[]} */ let stickyNotes = []; @@ -2773,6 +2793,7 @@ currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; currentHat = saveData.currentHat ?? DEFAULT_HAT; + savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions); stickyNotes = []; if (saveData.stickyNotes) { @@ -2807,6 +2828,9 @@ left: note.left })); } + if (Object.keys(savedBirdPositions).length > 0) { + saveData.birdPositions = savedBirdPositions; + } getContext().putSaveData(saveData); } @@ -2894,19 +2918,26 @@ drawStickyNotes(stickyNotes, save, deleteStickyNote); - let lastPath = getContext().getPath().split("?")[0]; + let lastPath = normalizePath(getContext().getPath()); setInterval(() => { - const currentPath = getContext().getPath().split("?")[0]; + const currentPath = normalizePath(getContext().getPath()); if (currentPath !== lastPath) { log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); + saveBirdPosition(true); lastPath = currentPath; drawStickyNotes(stickyNotes, save, deleteStickyNote); + restoreBirdPosition(); } }, URL_CHECK_INTERVAL); setInterval(update, UPDATE_INTERVAL); + setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL); + window.addEventListener("pagehide", () => saveBirdPosition(true)); + window.addEventListener("beforeunload", () => saveBirdPosition(true)); - flyToElement(true); + if (!restoreBirdPosition()) { + flyToElement(true); + } } function update() { @@ -2967,7 +2998,9 @@ if (focusedElement && !isWithinHorizontalBounds()) { flyToElement(); } - birdY = getFocusedY(); + if (focusedElement || !holdRestoredYPosition) { + birdY = getFocusedY(); + } } else if (currentState === States.FLYING) { // Fly to target location (even if in the air) if (updateParabolicPath(FLY_SPEED, 2)) { @@ -2997,6 +3030,13 @@ // Update HTML element position birb.setX(birdX); birb.setY(birdY); + const movedX = Math.abs(birdX - lastTrackedBirdX); + const movedY = Math.abs(birdY - lastTrackedBirdY); + if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) { + birdPositionDirty = true; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + } } /** @@ -3553,6 +3593,7 @@ if (frozen) { return false; } + holdRestoredYPosition = false; const previousElement = focusedElement; focusedElement = getRandomValidElement(); updateFocusedElementBounds(); @@ -3569,6 +3610,7 @@ * @param {number} y */ function teleportTo(x, y) { + holdRestoredYPosition = false; birdX = x; birdY = y; setState(States.IDLE); @@ -3603,11 +3645,16 @@ focusedBounds = { left, right, top }; } + function getCanvasWidth() { + return birb.getElementWidth(); + } + function hop() { if (frozen) { return; } if (currentState === States.IDLE) { + holdRestoredYPosition = false; setState(States.HOP); birb.setAnimation(Animations.FLYING); if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { @@ -3638,6 +3685,7 @@ * @param {number} y */ function flyTo(x, y) { + holdRestoredYPosition = false; targetX = x; targetY = y; setState(States.FLYING); @@ -3667,6 +3715,183 @@ birb.setY(birdY); } + /** + * @param {unknown} value + * @returns {Record} + */ + function sanitizeSavedBirdPositions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + /** @type {Record} */ + const result = {}; + for (const [key, position] of Object.entries(value)) { + if (!position || typeof position !== "object" || Array.isArray(position)) { + continue; + } + // @ts-expect-error + const x = Number(position.x); + // @ts-expect-error + const y = Number(position.y); + // @ts-expect-error + const updatedAt = Number(position.updatedAt ?? 0); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 }; + } + return result; + } + + /** + * @param {string} path + * @returns {string} + */ + function normalizePath(path) { + return path.split("?")[0].split("#")[0]; + } + + function trimSavedBirdPositions() { + const entries = Object.entries(savedBirdPositions); + if (entries.length <= MAX_SAVED_BIRD_POSITIONS) { + return; + } + entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt); + for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) { + delete savedBirdPositions[entries[i][0]]; + } + } + + function getBirdPositionScopeKey() { + if (birdSessionKey) { + return birdSessionKey; + } + + const existingWindowName = typeof window.name === "string" ? window.name : ""; + const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER); + if (markerIndex >= 0) { + const end = existingWindowName.indexOf("|", markerIndex); + birdSessionKey = end >= 0 + ? existingWindowName.slice(markerIndex, end) + : existingWindowName.slice(markerIndex); + return birdSessionKey; + } + + const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`; + + try { + window.name = existingWindowName + ? `${existingWindowName}|${birdSessionKey}` + : birdSessionKey; + } catch { + // Ignore if the page blocks changing window.name. + } + + return birdSessionKey; + } + + /** + * @param {boolean} [force] + */ + function saveBirdPosition(force = false) { + if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) { + return; + } + if (!force && !birdPositionDirty) { + return; + } + + const now = Date.now(); + const scopeKey = getBirdPositionScopeKey(); + const previous = savedBirdPositions[scopeKey]; + if (!force && previous) { + const movedX = Math.abs(previous.x - birdX); + const movedY = Math.abs(previous.y - birdY); + if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) { + birdPositionDirty = false; + return; + } + } + + savedBirdPositions[scopeKey] = { + x: birdX, + y: birdY, + updatedAt: now + }; + trimSavedBirdPositions(); + birdPositionDirty = false; + save(); + } + + /** + * @returns {boolean} + */ + function restoreBirdPosition() { + const scopeKey = getBirdPositionScopeKey(); + const saved = savedBirdPositions[scopeKey]; + if (!saved) { + holdRestoredYPosition = false; + return false; + } + + const maxX = Math.max(0, window.innerWidth - getCanvasWidth()); + const maxY = getWindowHeight() * 1.5; + birdX = Math.min(Math.max(saved.x, 0), maxX); + birdY = Math.min(Math.max(saved.y, 0), maxY); + + // Attempt to keep the bird perched if an element still exists near the saved position. + focusedElement = getElementAtPosition(birdX, birdY); + updateFocusedElementBounds(); + + holdRestoredYPosition = focusedElement === null; + birdPositionDirty = false; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + + setState(States.IDLE); + birb.setX(birdX); + birb.setY(birdY); + return true; + } + + /** + * @param {number} x + * @param {number} y + * @returns {HTMLElement|null} + */ + function getElementAtPosition(x, y) { + const desiredTop = getWindowHeight() - y; + let bestElement = null; + let bestScore = Number.POSITIVE_INFINITY; + const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + continue; + } + const xDistance = Math.abs((rect.left + rect.right) / 2 - x); + const yDistance = Math.abs(rect.top - desiredTop); + const score = xDistance + yDistance * 1.5; + if (score < bestScore) { + bestScore = score; + bestElement = element; + } + } + if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) { + return null; + } + return bestElement; + } + // Helper functions /** diff --git a/dist/web/birb.js b/dist/web/birb.js index 31365c7..70062f5 100644 --- a/dist/web/birb.js +++ b/dist/web/birb.js @@ -2086,6 +2086,13 @@ * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote */ + /** + * @typedef {Object} SavedBirdPosition + * @property {number} x + * @property {number} y + * @property {number} updatedAt + */ + /** * @typedef {Object} BirbSaveData * @property {string[]} unlockedSpecies @@ -2094,6 +2101,7 @@ * @property {string} currentHat * @property {Partial} settings * @property {SavedStickyNote[]} [stickyNotes] + * @property {Record} [birdPositions] */ /** @@ -2575,11 +2583,16 @@ // Petting boosts const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes - const PET_FEATHER_BOOST = 2; - const PET_HAT_BOOST = 1.5; + const PET_FEATHER_BOOST = 2; // Multiplier for feather effect + const PET_HAT_BOOST = 1.5; // Multiplier for hat effect // Focus element constraints - const MIN_FOCUS_ELEMENT_WIDTH = 100; + const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target + const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow + const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again + const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty" + const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep + const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab /** @type {Partial} */ let userSettings = {}; @@ -2716,7 +2729,7 @@ }), new Separator(), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), - new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), + new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false), ]; /** @type {Birb} */ @@ -2755,6 +2768,13 @@ let currentHat = DEFAULT_HAT; // let visible = true; let lastPetTimestamp = 0; + /** @type {Record} */ + let savedBirdPositions = {}; + let holdRestoredYPosition = false; + let birdPositionDirty = false; + let lastTrackedBirdX = birdX; + let lastTrackedBirdY = birdY; + let birdSessionKey = ""; /** @type {StickyNote[]} */ let stickyNotes = []; @@ -2773,6 +2793,7 @@ currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; currentHat = saveData.currentHat ?? DEFAULT_HAT; + savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions); stickyNotes = []; if (saveData.stickyNotes) { @@ -2807,6 +2828,9 @@ left: note.left })); } + if (Object.keys(savedBirdPositions).length > 0) { + saveData.birdPositions = savedBirdPositions; + } getContext().putSaveData(saveData); } @@ -2894,19 +2918,26 @@ drawStickyNotes(stickyNotes, save, deleteStickyNote); - let lastPath = getContext().getPath().split("?")[0]; + let lastPath = normalizePath(getContext().getPath()); setInterval(() => { - const currentPath = getContext().getPath().split("?")[0]; + const currentPath = normalizePath(getContext().getPath()); if (currentPath !== lastPath) { log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); + saveBirdPosition(true); lastPath = currentPath; drawStickyNotes(stickyNotes, save, deleteStickyNote); + restoreBirdPosition(); } }, URL_CHECK_INTERVAL); setInterval(update, UPDATE_INTERVAL); + setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL); + window.addEventListener("pagehide", () => saveBirdPosition(true)); + window.addEventListener("beforeunload", () => saveBirdPosition(true)); - flyToElement(true); + if (!restoreBirdPosition()) { + flyToElement(true); + } } function update() { @@ -2967,7 +2998,9 @@ if (focusedElement && !isWithinHorizontalBounds()) { flyToElement(); } - birdY = getFocusedY(); + if (focusedElement || !holdRestoredYPosition) { + birdY = getFocusedY(); + } } else if (currentState === States.FLYING) { // Fly to target location (even if in the air) if (updateParabolicPath(FLY_SPEED, 2)) { @@ -2997,6 +3030,13 @@ // Update HTML element position birb.setX(birdX); birb.setY(birdY); + const movedX = Math.abs(birdX - lastTrackedBirdX); + const movedY = Math.abs(birdY - lastTrackedBirdY); + if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) { + birdPositionDirty = true; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + } } /** @@ -3553,6 +3593,7 @@ if (frozen) { return false; } + holdRestoredYPosition = false; const previousElement = focusedElement; focusedElement = getRandomValidElement(); updateFocusedElementBounds(); @@ -3569,6 +3610,7 @@ * @param {number} y */ function teleportTo(x, y) { + holdRestoredYPosition = false; birdX = x; birdY = y; setState(States.IDLE); @@ -3603,11 +3645,16 @@ focusedBounds = { left, right, top }; } + function getCanvasWidth() { + return birb.getElementWidth(); + } + function hop() { if (frozen) { return; } if (currentState === States.IDLE) { + holdRestoredYPosition = false; setState(States.HOP); birb.setAnimation(Animations.FLYING); if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { @@ -3638,6 +3685,7 @@ * @param {number} y */ function flyTo(x, y) { + holdRestoredYPosition = false; targetX = x; targetY = y; setState(States.FLYING); @@ -3667,6 +3715,183 @@ birb.setY(birdY); } + /** + * @param {unknown} value + * @returns {Record} + */ + function sanitizeSavedBirdPositions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + /** @type {Record} */ + const result = {}; + for (const [key, position] of Object.entries(value)) { + if (!position || typeof position !== "object" || Array.isArray(position)) { + continue; + } + // @ts-expect-error + const x = Number(position.x); + // @ts-expect-error + const y = Number(position.y); + // @ts-expect-error + const updatedAt = Number(position.updatedAt ?? 0); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 }; + } + return result; + } + + /** + * @param {string} path + * @returns {string} + */ + function normalizePath(path) { + return path.split("?")[0].split("#")[0]; + } + + function trimSavedBirdPositions() { + const entries = Object.entries(savedBirdPositions); + if (entries.length <= MAX_SAVED_BIRD_POSITIONS) { + return; + } + entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt); + for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) { + delete savedBirdPositions[entries[i][0]]; + } + } + + function getBirdPositionScopeKey() { + if (birdSessionKey) { + return birdSessionKey; + } + + const existingWindowName = typeof window.name === "string" ? window.name : ""; + const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER); + if (markerIndex >= 0) { + const end = existingWindowName.indexOf("|", markerIndex); + birdSessionKey = end >= 0 + ? existingWindowName.slice(markerIndex, end) + : existingWindowName.slice(markerIndex); + return birdSessionKey; + } + + const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`; + + try { + window.name = existingWindowName + ? `${existingWindowName}|${birdSessionKey}` + : birdSessionKey; + } catch { + // Ignore if the page blocks changing window.name. + } + + return birdSessionKey; + } + + /** + * @param {boolean} [force] + */ + function saveBirdPosition(force = false) { + if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) { + return; + } + if (!force && !birdPositionDirty) { + return; + } + + const now = Date.now(); + const scopeKey = getBirdPositionScopeKey(); + const previous = savedBirdPositions[scopeKey]; + if (!force && previous) { + const movedX = Math.abs(previous.x - birdX); + const movedY = Math.abs(previous.y - birdY); + if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) { + birdPositionDirty = false; + return; + } + } + + savedBirdPositions[scopeKey] = { + x: birdX, + y: birdY, + updatedAt: now + }; + trimSavedBirdPositions(); + birdPositionDirty = false; + save(); + } + + /** + * @returns {boolean} + */ + function restoreBirdPosition() { + const scopeKey = getBirdPositionScopeKey(); + const saved = savedBirdPositions[scopeKey]; + if (!saved) { + holdRestoredYPosition = false; + return false; + } + + const maxX = Math.max(0, window.innerWidth - getCanvasWidth()); + const maxY = getWindowHeight() * 1.5; + birdX = Math.min(Math.max(saved.x, 0), maxX); + birdY = Math.min(Math.max(saved.y, 0), maxY); + + // Attempt to keep the bird perched if an element still exists near the saved position. + focusedElement = getElementAtPosition(birdX, birdY); + updateFocusedElementBounds(); + + holdRestoredYPosition = focusedElement === null; + birdPositionDirty = false; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + + setState(States.IDLE); + birb.setX(birdX); + birb.setY(birdY); + return true; + } + + /** + * @param {number} x + * @param {number} y + * @returns {HTMLElement|null} + */ + function getElementAtPosition(x, y) { + const desiredTop = getWindowHeight() - y; + let bestElement = null; + let bestScore = Number.POSITIVE_INFINITY; + const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + continue; + } + const xDistance = Math.abs((rect.left + rect.right) / 2 - x); + const yDistance = Math.abs(rect.top - desiredTop); + const score = xDistance + yDistance * 1.5; + if (score < bestScore) { + bestScore = score; + bestElement = element; + } + } + if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) { + return null; + } + return bestElement; + } + // Helper functions /** diff --git a/src/application.js b/src/application.js index b8391a5..212398b 100644 --- a/src/application.js +++ b/src/application.js @@ -51,6 +51,13 @@ import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js'; * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote */ +/** + * @typedef {Object} SavedBirdPosition + * @property {number} x + * @property {number} y + * @property {number} updatedAt + */ + /** * @typedef {Object} BirbSaveData * @property {string[]} unlockedSpecies @@ -59,6 +66,7 @@ import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js'; * @property {string} currentHat * @property {Partial} settings * @property {SavedStickyNote[]} [stickyNotes] + * @property {Record} [birdPositions] */ /** @@ -118,11 +126,16 @@ const FEATHER_FALL_SPEED = 1; // Petting boosts const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes -const PET_FEATHER_BOOST = 2; -const PET_HAT_BOOST = 1.5; +const PET_FEATHER_BOOST = 2; // Multiplier for feather effect +const PET_HAT_BOOST = 1.5; // Multiplier for hat effect // Focus element constraints -const MIN_FOCUS_ELEMENT_WIDTH = 100; +const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target +const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow +const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again +const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty" +const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep +const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab /** @type {Partial} */ let userSettings = {}; @@ -298,6 +311,13 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { let currentHat = DEFAULT_HAT; // let visible = true; let lastPetTimestamp = 0; + /** @type {Record} */ + let savedBirdPositions = {}; + let holdRestoredYPosition = false; + let birdPositionDirty = false; + let lastTrackedBirdX = birdX; + let lastTrackedBirdY = birdY; + let birdSessionKey = ""; /** @type {StickyNote[]} */ let stickyNotes = []; @@ -316,6 +336,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; currentHat = saveData.currentHat ?? DEFAULT_HAT; + savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions); stickyNotes = []; if (saveData.stickyNotes) { @@ -350,6 +371,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { left: note.left })); } + if (Object.keys(savedBirdPositions).length > 0) { + saveData.birdPositions = savedBirdPositions; + } getContext().putSaveData(saveData); } @@ -437,19 +461,26 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { drawStickyNotes(stickyNotes, save, deleteStickyNote); - let lastPath = getContext().getPath().split("?")[0]; + let lastPath = normalizePath(getContext().getPath()); setInterval(() => { - const currentPath = getContext().getPath().split("?")[0]; + const currentPath = normalizePath(getContext().getPath()); if (currentPath !== lastPath) { log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); + saveBirdPosition(true); lastPath = currentPath; drawStickyNotes(stickyNotes, save, deleteStickyNote); + restoreBirdPosition(); } }, URL_CHECK_INTERVAL); setInterval(update, UPDATE_INTERVAL); + setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL); + window.addEventListener("pagehide", () => saveBirdPosition(true)); + window.addEventListener("beforeunload", () => saveBirdPosition(true)); - flyToElement(true); + if (!restoreBirdPosition()) { + flyToElement(true); + } } function update() { @@ -510,7 +541,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { if (focusedElement && !isWithinHorizontalBounds()) { flyToElement(); } - birdY = getFocusedY(); + if (focusedElement || !holdRestoredYPosition) { + birdY = getFocusedY(); + } } else if (currentState === States.FLYING) { // Fly to target location (even if in the air) if (updateParabolicPath(FLY_SPEED, 2)) { @@ -540,6 +573,13 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { // Update HTML element position birb.setX(birdX); birb.setY(birdY); + const movedX = Math.abs(birdX - lastTrackedBirdX); + const movedY = Math.abs(birdY - lastTrackedBirdY); + if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) { + birdPositionDirty = true; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + } } /** @@ -1106,6 +1146,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { if (frozen) { return false; } + holdRestoredYPosition = false; const previousElement = focusedElement; focusedElement = getRandomValidElement(); updateFocusedElementBounds(); @@ -1122,6 +1163,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { * @param {number} y */ function teleportTo(x, y) { + holdRestoredYPosition = false; birdX = x; birdY = y; setState(States.IDLE); @@ -1165,6 +1207,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { return; } if (currentState === States.IDLE) { + holdRestoredYPosition = false; setState(States.HOP); birb.setAnimation(Animations.FLYING); if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { @@ -1195,6 +1238,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { * @param {number} y */ function flyTo(x, y) { + holdRestoredYPosition = false; targetX = x; targetY = y; setState(States.FLYING); @@ -1228,6 +1272,183 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { return Math.random() < 0.5; } + /** + * @param {unknown} value + * @returns {Record} + */ + function sanitizeSavedBirdPositions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + /** @type {Record} */ + const result = {}; + for (const [key, position] of Object.entries(value)) { + if (!position || typeof position !== "object" || Array.isArray(position)) { + continue; + } + // @ts-expect-error + const x = Number(position.x); + // @ts-expect-error + const y = Number(position.y); + // @ts-expect-error + const updatedAt = Number(position.updatedAt ?? 0); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 }; + } + return result; + } + + /** + * @param {string} path + * @returns {string} + */ + function normalizePath(path) { + return path.split("?")[0].split("#")[0]; + } + + function trimSavedBirdPositions() { + const entries = Object.entries(savedBirdPositions); + if (entries.length <= MAX_SAVED_BIRD_POSITIONS) { + return; + } + entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt); + for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) { + delete savedBirdPositions[entries[i][0]]; + } + } + + function getBirdPositionScopeKey() { + if (birdSessionKey) { + return birdSessionKey; + } + + const existingWindowName = typeof window.name === "string" ? window.name : ""; + const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER); + if (markerIndex >= 0) { + const end = existingWindowName.indexOf("|", markerIndex); + birdSessionKey = end >= 0 + ? existingWindowName.slice(markerIndex, end) + : existingWindowName.slice(markerIndex); + return birdSessionKey; + } + + const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`; + + try { + window.name = existingWindowName + ? `${existingWindowName}|${birdSessionKey}` + : birdSessionKey; + } catch { + // Ignore if the page blocks changing window.name. + } + + return birdSessionKey; + } + + /** + * @param {boolean} [force] + */ + function saveBirdPosition(force = false) { + if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) { + return; + } + if (!force && !birdPositionDirty) { + return; + } + + const now = Date.now(); + const scopeKey = getBirdPositionScopeKey(); + const previous = savedBirdPositions[scopeKey]; + if (!force && previous) { + const movedX = Math.abs(previous.x - birdX); + const movedY = Math.abs(previous.y - birdY); + if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) { + birdPositionDirty = false; + return; + } + } + + savedBirdPositions[scopeKey] = { + x: birdX, + y: birdY, + updatedAt: now + }; + trimSavedBirdPositions(); + birdPositionDirty = false; + save(); + } + + /** + * @returns {boolean} + */ + function restoreBirdPosition() { + const scopeKey = getBirdPositionScopeKey(); + const saved = savedBirdPositions[scopeKey]; + if (!saved) { + holdRestoredYPosition = false; + return false; + } + + const maxX = Math.max(0, window.innerWidth - getCanvasWidth()); + const maxY = getWindowHeight() * 1.5; + birdX = Math.min(Math.max(saved.x, 0), maxX); + birdY = Math.min(Math.max(saved.y, 0), maxY); + + // Attempt to keep the bird perched if an element still exists near the saved position. + focusedElement = getElementAtPosition(birdX, birdY); + updateFocusedElementBounds(); + + holdRestoredYPosition = focusedElement === null; + birdPositionDirty = false; + lastTrackedBirdX = birdX; + lastTrackedBirdY = birdY; + + setState(States.IDLE); + birb.setX(birdX); + birb.setY(birdY); + return true; + } + + /** + * @param {number} x + * @param {number} y + * @returns {HTMLElement|null} + */ + function getElementAtPosition(x, y) { + const desiredTop = getWindowHeight() - y; + let bestElement = null; + let bestScore = Number.POSITIVE_INFINITY; + const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + continue; + } + const xDistance = Math.abs((rect.left + rect.right) / 2 - x); + const yDistance = Math.abs(rect.top - desiredTop); + const score = xDistance + yDistance * 1.5; + if (score < bestScore) { + bestScore = score; + bestElement = element; + } + } + if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) { + return null; + } + return bestElement; + } + // Helper functions /** @@ -1256,4 +1477,3 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { init(); draw(); } -