Add browser-specific entry point

This commit is contained in:
Idrees Hassan
2025-11-16 09:51:46 -05:00
parent a5e81e4265
commit 6ee9efd5a8
14 changed files with 1441 additions and 2343 deletions

View File

@@ -24,7 +24,8 @@ const OBSIDIAN_DIR = DIST_DIR + "/obsidian";
const VENCORD_DIR = DIST_DIR + "/vencord"; const VENCORD_DIR = DIST_DIR + "/vencord";
const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css"; const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css";
const APPLICATION_ENTRY = SRC_DIR + "/application.js"; // const APPLICATION_ENTRY = SRC_DIR + "/application.js";
const APPLICATION_ENTRY = SRC_DIR + "/platforms/browser.js";
const BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js"; const BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js";
const BIRB_OUTPUT = DIST_DIR + "/birb.js"; const BIRB_OUTPUT = DIST_DIR + "/birb.js";

696
dist/birb.js vendored
View File

@@ -1,4 +1,4 @@
(function () { (function (exports) {
'use strict'; 'use strict';
const Directions = { const Directions = {
@@ -7,6 +7,7 @@
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -22,6 +23,17 @@
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -214,6 +226,139 @@
return document.documentElement.clientHeight; return document.documentElement.clientHeight;
} }
const SAVE_KEY = "birbSaveData";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
// isContextActive() {
// throw new Error("Method not implemented");
// }
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
/**
* Determines and returns the current context
* @returns {Context}
*/
// export function getContext() {
// if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
// return new CONTEXTS_BY_KEY[SET_CONTEXT]();
// }
// for (const context of contextProcessingOrder) {
// if (context.isContextActive()) {
// return context;
// }
// }
// error("No applicable context found");
// // return new LocalContext();
// return null;
// }
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** Indicators for parts of the base bird sprite sheet */ /** Indicators for parts of the base bird sprite sheet */
const Sprite = { const Sprite = {
THEME_HIGHLIGHT: "theme-highlight", THEME_HIGHLIGHT: "theme-highlight",
@@ -845,373 +990,6 @@
} }
} }
const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined" && typeof chrome.storage !== "undefined" && typeof chrome.storage.sync !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const contextProcessingOrder = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
const CONTEXTS_BY_KEY = {
"local": LocalContext,
"userscript": UserScriptContext,
"browser-extension": BrowserExtensionContext,
"obsidian": ObsidianContext
};
/**
* Determines and returns the current context
* @returns {Context}
*/
function getContext() {
if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
return new CONTEXTS_BY_KEY[SET_CONTEXT]();
}
for (const context of contextProcessingOrder) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote
* @property {string} id * @property {string} id
@@ -1940,68 +1718,24 @@
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2062,7 +1796,7 @@
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.15", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.15"); }, false), new MenuItem("2025.11.16", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.16"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2678,16 +2412,11 @@
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2827,8 +2556,99 @@
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
});
})(); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
/**
* @typedef {import('../application.js').BirbSaveData} BirbSaveData
*/
class LocalContext extends Context {
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
initializeApplication(new LocalContext());
exports.LocalContext = LocalContext;
return exports;
})({});

BIN
dist/extension.zip vendored

Binary file not shown.

696
dist/extension/birb.js vendored
View File

@@ -1,4 +1,4 @@
(function () { (function (exports) {
'use strict'; 'use strict';
const Directions = { const Directions = {
@@ -7,6 +7,7 @@
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -22,6 +23,17 @@
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -214,6 +226,139 @@
return document.documentElement.clientHeight; return document.documentElement.clientHeight;
} }
const SAVE_KEY = "birbSaveData";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
// isContextActive() {
// throw new Error("Method not implemented");
// }
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
/**
* Determines and returns the current context
* @returns {Context}
*/
// export function getContext() {
// if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
// return new CONTEXTS_BY_KEY[SET_CONTEXT]();
// }
// for (const context of contextProcessingOrder) {
// if (context.isContextActive()) {
// return context;
// }
// }
// error("No applicable context found");
// // return new LocalContext();
// return null;
// }
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** Indicators for parts of the base bird sprite sheet */ /** Indicators for parts of the base bird sprite sheet */
const Sprite = { const Sprite = {
THEME_HIGHLIGHT: "theme-highlight", THEME_HIGHLIGHT: "theme-highlight",
@@ -845,373 +990,6 @@
} }
} }
const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined" && typeof chrome.storage !== "undefined" && typeof chrome.storage.sync !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const contextProcessingOrder = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
const CONTEXTS_BY_KEY = {
"local": LocalContext,
"userscript": UserScriptContext,
"browser-extension": BrowserExtensionContext,
"obsidian": ObsidianContext
};
/**
* Determines and returns the current context
* @returns {Context}
*/
function getContext() {
if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
return new CONTEXTS_BY_KEY[SET_CONTEXT]();
}
for (const context of contextProcessingOrder) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote
* @property {string} id * @property {string} id
@@ -1940,68 +1718,24 @@
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2062,7 +1796,7 @@
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.15", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.15"); }, false), new MenuItem("2025.11.16", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.16"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2678,16 +2412,11 @@
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2827,8 +2556,99 @@
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
});
})(); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
/**
* @typedef {import('../application.js').BirbSaveData} BirbSaveData
*/
class LocalContext extends Context {
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
initializeApplication(new LocalContext());
exports.LocalContext = LocalContext;
return exports;
})({});

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Pocket Bird", "name": "Pocket Bird",
"description": "It's a pet bird in your browser, what more could you want?", "description": "It's a pet bird in your browser, what more could you want?",
"version": "2025.11.15", "version": "2025.11.16",
"homepage_url": "https://idreesinc.com", "homepage_url": "https://idreesinc.com",
"icons": { "icons": {
"48": "images/icons/transparent/48x48x1.png", "48": "images/icons/transparent/48x48x1.png",

698
dist/obsidian/main.js vendored
View File

@@ -1,9 +1,9 @@
const { Plugin, Notice } = require('obsidian'); const { Plugin, Notice } = require('obsidian');
module.exports = class PocketBird extends Plugin { module.exports = class PocketBird extends Plugin {
onload() { onload() {
console.log("Loading Pocket Bird version 2025.11.15..."); console.log("Loading Pocket Bird version 2025.11.16...");
const OBSIDIAN_PLUGIN = this; const OBSIDIAN_PLUGIN = this;
(function () { (function (exports) {
'use strict'; 'use strict';
const Directions = { const Directions = {
@@ -12,6 +12,7 @@ module.exports = class PocketBird extends Plugin {
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -27,6 +28,17 @@ module.exports = class PocketBird extends Plugin {
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -219,6 +231,139 @@ module.exports = class PocketBird extends Plugin {
return document.documentElement.clientHeight; return document.documentElement.clientHeight;
} }
const SAVE_KEY = "birbSaveData";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
// isContextActive() {
// throw new Error("Method not implemented");
// }
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
/**
* Determines and returns the current context
* @returns {Context}
*/
// export function getContext() {
// if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
// return new CONTEXTS_BY_KEY[SET_CONTEXT]();
// }
// for (const context of contextProcessingOrder) {
// if (context.isContextActive()) {
// return context;
// }
// }
// error("No applicable context found");
// // return new LocalContext();
// return null;
// }
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** Indicators for parts of the base bird sprite sheet */ /** Indicators for parts of the base bird sprite sheet */
const Sprite = { const Sprite = {
THEME_HIGHLIGHT: "theme-highlight", THEME_HIGHLIGHT: "theme-highlight",
@@ -850,373 +995,6 @@ module.exports = class PocketBird extends Plugin {
} }
} }
const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined" && typeof chrome.storage !== "undefined" && typeof chrome.storage.sync !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const contextProcessingOrder = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
const CONTEXTS_BY_KEY = {
"local": LocalContext,
"userscript": UserScriptContext,
"browser-extension": BrowserExtensionContext,
"obsidian": ObsidianContext
};
/**
* Determines and returns the current context
* @returns {Context}
*/
function getContext() {
if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
return new CONTEXTS_BY_KEY[SET_CONTEXT]();
}
for (const context of contextProcessingOrder) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote
* @property {string} id * @property {string} id
@@ -1945,68 +1723,24 @@ module.exports = class PocketBird extends Plugin {
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2067,7 +1801,7 @@ module.exports = class PocketBird extends Plugin {
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.15", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.15"); }, false), new MenuItem("2025.11.16", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.16"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2683,16 +2417,11 @@ module.exports = class PocketBird extends Plugin {
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2832,11 +2561,102 @@ module.exports = class PocketBird extends Plugin {
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
});
})(); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
/**
* @typedef {import('../application.js').BirbSaveData} BirbSaveData
*/
class LocalContext extends Context {
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
initializeApplication(new LocalContext());
exports.LocalContext = LocalContext;
return exports;
})({});
console.log("Pocket Bird loaded!"); console.log("Pocket Bird loaded!");
} }

View File

@@ -1,7 +1,7 @@
{ {
"id": "pocket-bird", "id": "pocket-bird",
"name": "Pocket Bird", "name": "Pocket Bird",
"version": "2025.11.15", "version": "2025.11.16",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Add a pet bird to fly around your notes and keep you company!", "description": "Add a pet bird to fly around your notes and keep you company!",
"author": "Idrees Hassan", "author": "Idrees Hassan",

View File

@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Pocket Bird // @name Pocket Bird
// @namespace https://idreesinc.com // @namespace https://idreesinc.com
// @version 2025.11.15 // @version 2025.11.16
// @description It's a pet bird in your browser, what more could you want? // @description It's a pet bird in your browser, what more could you want?
// @author Idrees // @author Idrees
// @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js
@@ -12,7 +12,7 @@
// @grant GM_deleteValue // @grant GM_deleteValue
// ==/UserScript== // ==/UserScript==
(function () { (function (exports) {
'use strict'; 'use strict';
const Directions = { const Directions = {
@@ -21,6 +21,7 @@
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -36,6 +37,17 @@
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -228,6 +240,139 @@
return document.documentElement.clientHeight; return document.documentElement.clientHeight;
} }
const SAVE_KEY = "birbSaveData";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
// isContextActive() {
// throw new Error("Method not implemented");
// }
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
/**
* Determines and returns the current context
* @returns {Context}
*/
// export function getContext() {
// if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
// return new CONTEXTS_BY_KEY[SET_CONTEXT]();
// }
// for (const context of contextProcessingOrder) {
// if (context.isContextActive()) {
// return context;
// }
// }
// error("No applicable context found");
// // return new LocalContext();
// return null;
// }
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** Indicators for parts of the base bird sprite sheet */ /** Indicators for parts of the base bird sprite sheet */
const Sprite = { const Sprite = {
THEME_HIGHLIGHT: "theme-highlight", THEME_HIGHLIGHT: "theme-highlight",
@@ -859,373 +1004,6 @@
} }
} }
const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined" && typeof chrome.storage !== "undefined" && typeof chrome.storage.sync !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const contextProcessingOrder = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
const CONTEXTS_BY_KEY = {
"local": LocalContext,
"userscript": UserScriptContext,
"browser-extension": BrowserExtensionContext,
"obsidian": ObsidianContext
};
/**
* Determines and returns the current context
* @returns {Context}
*/
function getContext() {
if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
return new CONTEXTS_BY_KEY[SET_CONTEXT]();
}
for (const context of contextProcessingOrder) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote
* @property {string} id * @property {string} id
@@ -1954,68 +1732,24 @@
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2076,7 +1810,7 @@
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.15", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.15"); }, false), new MenuItem("2025.11.16", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.16"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2692,16 +2426,11 @@
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2841,8 +2570,99 @@
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
});
})(); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
/**
* @typedef {import('../application.js').BirbSaveData} BirbSaveData
*/
class LocalContext extends Context {
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
initializeApplication(new LocalContext());
exports.LocalContext = LocalContext;
return exports;
})({});

View File

@@ -1,5 +1,5 @@
export const Birb = () => { export const Birb = () => {
(function () { (function (exports) {
'use strict'; 'use strict';
const Directions = { const Directions = {
@@ -8,6 +8,7 @@ export const Birb = () => {
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -23,6 +24,17 @@ export const Birb = () => {
debugMode = value; debugMode = value;
} }
function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -215,6 +227,139 @@ export const Birb = () => {
return document.documentElement.clientHeight; return document.documentElement.clientHeight;
} }
const SAVE_KEY = "birbSaveData";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
// isContextActive() {
// throw new Error("Method not implemented");
// }
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
/**
* Determines and returns the current context
* @returns {Context}
*/
// export function getContext() {
// if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
// return new CONTEXTS_BY_KEY[SET_CONTEXT]();
// }
// for (const context of contextProcessingOrder) {
// if (context.isContextActive()) {
// return context;
// }
// }
// error("No applicable context found");
// // return new LocalContext();
// return null;
// }
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** Indicators for parts of the base bird sprite sheet */ /** Indicators for parts of the base bird sprite sheet */
const Sprite = { const Sprite = {
THEME_HIGHLIGHT: "theme-highlight", THEME_HIGHLIGHT: "theme-highlight",
@@ -846,373 +991,6 @@ export const Birb = () => {
} }
} }
const SAVE_KEY = "birbSaveData";
const ROOT_PATH = "";
const SET_CONTEXT = "local";
/**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
*/
/**
* @abstract
*/
class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
throw new Error("Method not implemented");
}
/**
* @abstract
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
throw new Error("Method not implemented");
}
/**
* @abstract
*/
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* @returns {HTMLElement} The current active page element where sticky notes can be applied
*/
getActivePage() {
// Default to root element
return document.documentElement;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from UserScript storage");
/** @type {BirbSaveData|{}} */
let saveData = {};
// @ts-expect-error
saveData = GM_getValue(SAVE_KEY, {}) ?? {};
return saveData;
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to UserScript storage");
// @ts-expect-error
GM_setValue(SAVE_KEY, saveData);
}
/** @override */
resetSaveData() {
log("Resetting save data in UserScript storage");
// @ts-expect-error
GM_deleteValue(SAVE_KEY);
}
}
class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined" && typeof chrome.storage !== "undefined" && typeof chrome.storage.sync !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from browser extension storage");
return new Promise((resolve) => {
// @ts-expect-error
chrome.storage.sync.get([SAVE_KEY], (result) => {
resolve(result[SAVE_KEY] ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to browser extension storage");
// @ts-expect-error
chrome.storage.sync.set({ [SAVE_KEY]: saveData }, function () {
// @ts-expect-error
if (chrome.runtime.lastError) {
// @ts-expect-error
console.error(chrome.runtime.lastError);
} else {
console.log("Settings saved successfully");
}
});
}
/** @override */
resetSaveData() {
log("Resetting save data in browser extension storage");
// @ts-expect-error
chrome.storage.sync.clear();
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
return new Promise((resolve) => {
// @ts-expect-error
OBSIDIAN_PLUGIN.loadData().then((data) => {
resolve(data ?? {});
});
});
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin",
".status-bar",
".mobile-navbar"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
getPath() {
// @ts-expect-error
const file = app.workspace.getActiveFile();
if (file && this.getActiveEditorElement()) {
return file.path;
} else {
return ROOT_PATH;
}
}
/** @override */
getActivePage() {
if (this.getPath() === ROOT_PATH) {
// Root page, use document element
return document.documentElement
}
return this.getActiveEditorElement() ?? document.documentElement;
}
/** @override */
isPathApplicable(path) {
return path === this.getPath();
}
/** @override */
areStickyNotesEnabled() {
return this.getPath() !== ROOT_PATH;
}
/** @returns {HTMLElement|null} */
getActiveEditorElement() {
// @ts-expect-error
const activeLeaf = app.workspace.activeLeaf;
const leafElement = activeLeaf?.view?.containerEl;
return leafElement?.querySelector(".cm-scroller") ?? null;
}
}
const contextProcessingOrder = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
const CONTEXTS_BY_KEY = {
"local": LocalContext,
"userscript": UserScriptContext,
"browser-extension": BrowserExtensionContext,
"obsidian": ObsidianContext
};
/**
* Determines and returns the current context
* @returns {Context}
*/
function getContext() {
if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
return new CONTEXTS_BY_KEY[SET_CONTEXT]();
}
for (const context of contextProcessingOrder) {
if (context.isContextActive()) {
return context;
}
}
error("No applicable context found, defaulting to LocalContext");
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote
* @property {string} id * @property {string} id
@@ -1941,68 +1719,24 @@ export const Birb = () => {
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -2063,7 +1797,7 @@ export const Birb = () => {
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem("2025.11.15", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.15"); }, false), new MenuItem("2025.11.16", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.16"); }, false),
]; ];
const styleElement = document.createElement("style"); const styleElement = document.createElement("style");
@@ -2679,16 +2413,11 @@ export const Birb = () => {
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { {
return true; return true;
} }
const style = window.getComputedStyle(el);
return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return false;
@@ -2828,10 +2557,101 @@ export const Birb = () => {
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
});
})(); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}
/**
* @typedef {import('../application.js').BirbSaveData} BirbSaveData
*/
class LocalContext extends Context {
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
initializeApplication(new LocalContext());
exports.LocalContext = LocalContext;
return exports;
})({});
}; };

View File

@@ -2,9 +2,11 @@ import Frame from './frame.js';
import Layer from './layer.js'; import Layer from './layer.js';
import Anim from './anim.js'; import Anim from './anim.js';
import { Birb, Animations } from './birb.js'; import { Birb, Animations } from './birb.js';
import { getContext, ObsidianContext } from './context.js'; import { Context, ObsidianContext } from './context.js';
import { import {
getContext,
setContext,
Directions, Directions,
isDebug, isDebug,
setDebug, setDebug,
@@ -109,68 +111,24 @@ const MIN_FOCUS_ELEMENT_WIDTH = 100;
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { export async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; startApplication(birbPixels, featherPixels);
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), */
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) function startApplication(birbPixels, featherPixels) {
]).then(([birbPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -851,10 +809,11 @@ Promise.all([
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements // Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext; // const fixedAllowed = getContext() instanceof ObsidianContext;
// TODO: FIX
const fixedAllowed = true;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { if (fixedAllowed) {
return true; return true;
@@ -1008,6 +967,60 @@ Promise.all([
// Run the birb // Run the birb
init(); init();
draw(); draw();
}).catch((e) => { }
error("Error while loading sprite sheets: ", e);
}); /**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}

View File

@@ -1,6 +1,6 @@
import { debug, log, error } from "./shared.js"; import { debug, log, error } from "./shared.js";
const SAVE_KEY = "birbSaveData"; export const SAVE_KEY = "birbSaveData";
const ROOT_PATH = ""; const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__" const SET_CONTEXT = "__CONTEXT__"
@@ -17,9 +17,9 @@ export class Context {
* @abstract * @abstract
* @returns {boolean} Whether this context is applicable * @returns {boolean} Whether this context is applicable
*/ */
isContextActive() { // isContextActive() {
throw new Error("Method not implemented"); // throw new Error("Method not implemented");
} // }
/** /**
* @abstract * @abstract
@@ -102,54 +102,8 @@ export class Context {
} }
} }
export class LocalContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
return window.location.hostname === "127.0.0.1"
|| window.location.hostname === "localhost"
|| window.location.hostname.startsWith("192.168.");
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
export class UserScriptContext extends Context { export class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -183,15 +137,6 @@ export class UserScriptContext extends Context {
class BrowserExtensionContext extends Context { class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined" && typeof chrome.storage !== "undefined" && typeof chrome.storage.sync !== "undefined";
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -234,15 +179,6 @@ class BrowserExtensionContext extends Context {
export class ObsidianContext extends Context { export class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -325,11 +261,10 @@ const contextProcessingOrder = [
new UserScriptContext(), new UserScriptContext(),
new ObsidianContext(), new ObsidianContext(),
new BrowserExtensionContext(), new BrowserExtensionContext(),
new LocalContext()
]; ];
const CONTEXTS_BY_KEY = { const CONTEXTS_BY_KEY = {
"local": LocalContext, // "local": LocalContext,
"userscript": UserScriptContext, "userscript": UserScriptContext,
"browser-extension": BrowserExtensionContext, "browser-extension": BrowserExtensionContext,
"obsidian": ObsidianContext "obsidian": ObsidianContext
@@ -339,18 +274,19 @@ const CONTEXTS_BY_KEY = {
* Determines and returns the current context * Determines and returns the current context
* @returns {Context} * @returns {Context}
*/ */
export function getContext() { // export function getContext() {
if (CONTEXTS_BY_KEY[SET_CONTEXT]) { // if (CONTEXTS_BY_KEY[SET_CONTEXT]) {
return new CONTEXTS_BY_KEY[SET_CONTEXT](); // return new CONTEXTS_BY_KEY[SET_CONTEXT]();
} // }
for (const context of contextProcessingOrder) { // for (const context of contextProcessingOrder) {
if (context.isContextActive()) { // if (context.isContextActive()) {
return context; // return context;
} // }
} // }
error("No applicable context found, defaulting to LocalContext"); // error("No applicable context found");
return new LocalContext(); // // return new LocalContext();
} // return null;
// }
/** /**
* Parse URL parameters into a key-value map * Parse URL parameters into a key-value map

36
src/platforms/browser.js Normal file
View File

@@ -0,0 +1,36 @@
import { Context, SAVE_KEY } from "../context.js";
import { log } from "../shared.js";
import { initializeApplication } from "../application";
/**
* @typedef {import('../application.js').BirbSaveData} BirbSaveData
*/
export class LocalContext extends Context {
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
log("Loading save data from localStorage");
return JSON.parse(localStorage.getItem(SAVE_KEY) ?? "{}");
}
/**
* @override
* @param {BirbSaveData} saveData
*/
async putSaveData(saveData) {
log("Saving data to localStorage");
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
}
/** @override */
resetSaveData() {
log("Resetting save data in localStorage");
localStorage.removeItem(SAVE_KEY);
}
}
initializeApplication(new LocalContext());

View File

@@ -4,6 +4,7 @@ export const Directions = {
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -19,6 +20,17 @@ export function setDebug(value) {
debugMode = value; debugMode = value;
} }
export function getContext() {
if (!context) {
throw new Error("Context requested before being set");
}
return context;
}
export function setContext(newContext) {
context = newContext;
}
/** /**
* Create an HTML element with the specified parameters * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className

View File

@@ -1,9 +1,9 @@
import { import {
getContext,
makeElement, makeElement,
makeDraggable, makeDraggable,
makeClosable makeClosable
} from './shared.js'; } from './shared.js';
import { getContext } from './context.js';
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote