feat: add plugin system

This commit is contained in:
SethBurkart123
2025-03-18 07:51:12 +11:00
parent da3a680455
commit 587aa5eb89
9 changed files with 885 additions and 199 deletions
+207
View File
@@ -0,0 +1,207 @@
# BetterSEQTA+ Plugin System
## Overview
The BetterSEQTA+ plugin system is designed to provide a clean, type-safe, and developer-friendly way to extend the functionality of BetterSEQTA+. While initially focused on built-in plugins, the architecture is designed to potentially support external plugins in the future.
## Core Concepts
### Plugin Structure
Each plugin is a simple object that contains metadata and a run function:
```typescript
const examplePlugin = {
id: 'example',
name: 'Example Plugin',
description: 'Does something cool',
version: '1.0.0',
settings: {
enabled: { type: 'boolean', default: true },
color: { type: 'string', default: '#ff0000' }
},
run: (api) => {
// Plugin logic here
}
};
```
### Plugin API
Plugins receive a powerful API object that provides access to:
- **Settings**: Type-safe settings management with direct property access
- **SEQTA Integration**: React component mounting and state management
- **Storage**: Persistent storage capabilities
- **Events**: Communication system
### Settings System
Settings are defined with TypeScript types for safety and accessed like regular properties:
```typescript
// In your plugin
api.settings.myOption = true;
const value = api.settings.myOption;
// Watch for changes
api.settings.onChange('myOption', (newValue) => {
console.log('Option changed:', newValue);
});
```
### SEQTA Integration
Plugins can interact with SEQTA's React components:
```typescript
// Listen for component mounting
api.seqta.onMount('.timetable-view', (element) => {
// Access the DOM element directly
console.log('Timetable mounted:', element);
// If you need React access, use getFiber
const fiber = api.seqta.getFiber('.timetable-view');
fiber.setState(prevState => ({
...prevState,
someValue: true
}));
});
// Get specific component
const fiber = api.seqta.getFiber('.timetable-cell');
const props = await fiber.getProps();
// Listen for page changes
api.seqta.onPageChange((page) => {
if (page === 'timetable') {
// Handle timetable page
}
});
```
## Implementation Status
### Phase 1: Core Infrastructure ✅
- [x] Create basic plugin type definitions
- [x] Implement plugin manager
- [x] Set up basic API structure
- [x] Create plugin loading system
### Phase 2: Settings System ✅
- [x] Design settings storage structure
- [x] Implement settings proxy system
- [x] Add settings change notifications
- [x] Create settings validation
### Phase 3: SEQTA Integration ✅
- [x] Implement component mount detection
- [x] Create ReactFiber wrapper
- [x] Add page change detection
- [x] Create component state utilities
### Phase 4: Plugin API Features ✅
- [x] Storage system
- [x] Event system
- [x] Error handling
- [ ] Plugin lifecycle hooks
### Phase 5: Migration & Testing 🚧
- [ ] Convert existing features to plugins
- [ ] Create plugin testing utilities
- [ ] Add plugin documentation
- [ ] Create example plugins
### Phase 6: Future Enhancements 📝
- [ ] Plugin dependencies system
- [ ] Plugin hot-reloading
- [ ] External plugin support
- [ ] Plugin marketplace infrastructure
## Plugin Example
```typescript
const timetablePlugin = {
id: 'timetable',
name: 'Timetable Enhancer',
description: 'Adds extra features to the timetable view',
version: '1.0.0',
settings: {
showWeekends: {
type: 'boolean',
default: false,
description: 'Show weekend days in the timetable'
},
theme: {
type: 'select',
options: ['light', 'dark', 'auto'],
default: 'auto',
description: 'Timetable theme'
}
},
run: async (api) => {
// Listen for timetable mount
api.seqta.onMount('.timetable-view', (element) => {
// Get React access since we need to modify state
const fiber = api.seqta.getFiber('.timetable-view');
// Apply settings
if (api.settings.showWeekends) {
fiber.setState(prevState => ({
...prevState,
showWeekends: true
}));
}
});
// Watch for settings changes
api.settings.onChange('theme', async (newTheme) => {
const timetable = api.seqta.getFiber('.timetable-view');
if (newTheme !== 'auto') {
await timetable.setProp('theme', newTheme);
}
});
}
};
```
## Directory Structure
```
src/
plugins/
core/
types.ts # Core type definitions
createAPI.ts # API implementation
manager.ts # Plugin manager
built-in/ # Built-in plugins
timetable/
assessments/
etc...
```
## API Type Definitions
```typescript
interface BSAPI<TSettings> {
seqta: {
onMount: (selector: string, callback: (fiber: ReactFiber) => void) => void;
getFiber: (selector: string) => ReactFiber;
getCurrentPage: () => string;
onPageChange: (callback: (page: string) => void) => void;
};
settings: TSettings & {
onChange: <K extends keyof TSettings>(
key: K,
callback: (value: TSettings[K]) => void
) => void;
};
storage: {
get: (key: string) => Promise<any>;
set: (key: string, value: any) => Promise<void>;
};
events: {
on: (event: string, callback: (...args: any[]) => void) => void;
emit: (event: string, ...args: any[]) => void;
};
}
```
+10 -3
View File
@@ -1,4 +1,3 @@
import {
settingsState,
} from "@/seqta/utils/listeners/SettingsState"
@@ -41,10 +40,18 @@ async function init() {
await main()
if (settingsState.onoff) {
Object.values(plugins).forEach(plugin => {
// Initialize legacy plugins
const legacyPlugins = [plugins.Monofile, plugins.Themes];
legacyPlugins.forEach(plugin => {
if (typeof plugin === 'function') {
plugin();
})
}
});
// Initialize new plugin system
await plugins.initializePlugins();
}
console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
)
@@ -0,0 +1,98 @@
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
import type { Plugin, PluginSettings } from '../../core/types';
interface NotificationCollectorSettings extends PluginSettings {
enabled: {
type: 'boolean';
default: boolean;
title: string;
description: string;
};
}
const notificationCollectorPlugin: Plugin<NotificationCollectorSettings> = {
id: 'notificationCollector',
name: 'Notification Collector',
description: 'Collects and displays SEQTA notifications',
version: '1.0.0',
settings: {
enabled: {
type: 'boolean',
default: true,
title: 'Notification Collector',
description: 'Uncaps the 9+ limit for notifications, showing the real number.',
}
},
run: async (api) => {
let pollInterval: number | null = null;
const checkNotifications = async () => {
try {
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify({
timestamp: "1970-01-01 00:00:00.0",
hash: "#?page=/home",
})
});
const data = await response.json();
const alertDiv = document.querySelector(".notifications__bubble___1EkSQ") as HTMLElement;
if (alertDiv) {
alertDiv.textContent = data.payload.notifications.length.toString();
} else {
console.info("[BetterSEQTA+] No notifications currently");
}
} catch (error) {
console.error("[BetterSEQTA+] Error fetching notifications:", error);
}
};
// Start polling when enabled
const startPolling = () => {
if (pollInterval) return; // Already polling
checkNotifications();
pollInterval = window.setInterval(checkNotifications, 30000);
};
// Stop polling when disabled
const stopPolling = () => {
if (pollInterval) {
window.clearInterval(pollInterval);
pollInterval = null;
}
};
// Start/stop based on initial enabled state
if (settingsState.notificationcollector) {
api.seqta.onMount(".notifications__bubble___1EkSQ", (_) => {
startPolling();
});
}
// Store callbacks for cleanup
const enabledCallback = (enabled: boolean) => {
if (enabled) {
startPolling();
} else {
stopPolling();
}
};
// Handle settings changes
api.settings.onChange('enabled', enabledCallback);
// Return cleanup function
return () => {
stopPolling();
api.settings.offChange('enabled', enabledCallback);
};
}
};
export default notificationCollectorPlugin;
+196
View File
@@ -0,0 +1,196 @@
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
import type { Plugin } from '../../core/types';
import { convertTo12HourFormat } from '@/seqta/utils/convertTo12HourFormat';
import { waitForElm } from '@/seqta/utils/waitForElm';
const timetablePlugin: Plugin = {
id: 'timetable',
name: 'Timetable Enhancer',
description: 'Adds extra features to the timetable view',
version: '1.0.0',
settings: {},
run: async (api) => {
api.seqta.onMount('.timetablepage', handleTimetable)
}
};
async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10)
// Store original heights when timetable loads
const lessons = document.querySelectorAll(".dailycal .lesson")
lessons.forEach((lesson: Element) => {
const lessonEl = lesson as HTMLElement
lessonEl.setAttribute(
"data-original-height",
lessonEl.offsetHeight.toString(),
)
})
// Existing time format code
if (settingsState.timeFormat == "12") {
const times = document.querySelectorAll(".timetablepage .times .time")
for (const time of times) {
if (!time.textContent) continue
time.textContent = convertTo12HourFormat(time.textContent, true)
}
}
handleTimetableZoom()
handleTimetableAssessmentHide()
}
function handleTimetableZoom(): void {
console.log("Initializing timetable zoom controls")
// Lazy initialize state variables only when function is first called
let timetableZoomLevel = 1
let baseContainerHeight: number | null = null
const originalEntryPositions = new Map<
Element,
{ topRatio: number; heightRatio: number }
>()
// Create zoom controls
const zoomControls = document.createElement("div")
zoomControls.className = "timetable-zoom-controls"
const zoomIn = document.createElement("button")
zoomIn.className = "uiButton timetable-zoom iconFamily"
zoomIn.innerHTML = "&#xed93;" // Using unicode for zoom in icon
const zoomOut = document.createElement("button")
zoomOut.className = "uiButton timetable-zoom iconFamily"
zoomOut.innerHTML = "&#xed94;" // Using unicode for zoom out icon
zoomControls.appendChild(zoomOut)
zoomControls.appendChild(zoomIn)
const toolbar = document.getElementById("toolbar")
toolbar?.appendChild(zoomControls)
const initializePositions = () => {
// Get the base container height from the first TD
const firstDayColumn = document.querySelector(
".dailycal .content .days td",
) as HTMLElement
if (!firstDayColumn) return false
baseContainerHeight =
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
// Store original ratios
const entries = document.querySelectorAll(".entriesWrapper .entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
// Calculate ratios relative to detected base height
if (baseContainerHeight === null) return
const topRatio = parseInt(entryEl.style.top) / baseContainerHeight
const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight
originalEntryPositions.set(entry, { topRatio, heightRatio })
})
return true
}
const updateZoom = () => {
// Initialize positions if not already done
if (baseContainerHeight === null && !initializePositions()) {
console.error("Failed to initialize positions")
return
}
console.debug(`Updating zoom level to: ${timetableZoomLevel}`)
// Calculate new container height
if (baseContainerHeight === null) return
const newContainerHeight = baseContainerHeight * timetableZoomLevel
// Update all day columns (TDs)
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
dayColumns.forEach((td: Element) => {
(td as HTMLElement).style.height = `${newContainerHeight}px`
})
// Update all entries using stored ratios
const entries = document.querySelectorAll(".entriesWrapper .entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
const originalRatios = originalEntryPositions.get(entry)
if (originalRatios) {
// Calculate new positions from original ratios
const newTop = originalRatios.topRatio * newContainerHeight
const newHeight = originalRatios.heightRatio * newContainerHeight
// Apply new values
entryEl.style.top = `${Math.round(newTop)}px`
entryEl.style.height = `${Math.round(newHeight)}px`
}
})
// Update time column to match
const timeColumn = document.querySelector(".times")
if (timeColumn) {
const times = timeColumn.querySelectorAll(".time")
const timeHeight = newContainerHeight / times.length
times.forEach((time: Element) => {
(time as HTMLElement).style.height = `${timeHeight}px`
})
}
entries[Math.round((entries.length - 1) / 2)].scrollIntoView({
behavior: "instant",
block: "center",
})
}
zoomIn.addEventListener("click", () => {
if (timetableZoomLevel < 2) {
timetableZoomLevel += 0.2
updateZoom()
}
})
zoomOut.addEventListener("click", () => {
if (timetableZoomLevel > 0.6) {
timetableZoomLevel -= 0.2
updateZoom()
}
})
}
function handleTimetableAssessmentHide(): void {
const hideControls = document.createElement("div")
hideControls.className = "timetable-hide-controls"
const hideOn = document.createElement("button")
hideOn.className = "uiButton timetable-hide iconFamily"
hideOn.innerHTML = "&#128065;"
hideControls.appendChild(hideOn)
const toolbar = document.getElementById("toolbar")
toolbar?.appendChild(hideControls)
function hideElements(): void {
const entries = document.querySelectorAll(".entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
if (!entryEl.classList.contains("assessment") && !(entryEl.style.opacity === "0.3")) {
entryEl.style.opacity = "0.3"
} else {
entryEl.style.opacity = "1"
}
})
}
hideOn.addEventListener("click", () => {
hideElements()
})
}
export default timetablePlugin;
+116
View File
@@ -0,0 +1,116 @@
import type { Plugin, PluginAPI, PluginSettings, SEQTAAPI, SettingsAPI, StorageAPI, EventsAPI } from './types';
import { eventManager } from '@/seqta/utils/listeners/EventManager';
import ReactFiber from '@/seqta/utils/ReactFiber';
import browser from 'webextension-polyfill';
function createSEQTAAPI(): SEQTAAPI {
return {
onMount: (selector, callback) => {
eventManager.register(
`${selector}Added`,
{
customCheck: (element) => element.matches(selector),
},
callback
);
},
getFiber: (selector) => {
return ReactFiber.find(selector);
},
getCurrentPage: () => {
const path = window.location.hash.split('?page=/')[1] || '';
return path.split('/')[0];
},
onPageChange: (callback) => {
window.addEventListener('hashchange', () => {
const page = window.location.hash.split('?page=/')[1] || '';
callback(page.split('/')[0]);
});
},
};
}
function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): SettingsAPI<T> {
const storageKey = `plugin.${plugin.id}.settings`;
const listeners = new Map<keyof T, Set<(value: any) => void>>();
let settings: { [K in keyof T]: T[K]['default'] };
// Initialize settings with defaults
settings = Object.entries(plugin.settings).reduce((acc, [key, setting]) => {
acc[key as keyof T] = setting.default;
return acc;
}, {} as { [K in keyof T]: T[K]['default'] });
// Load saved settings
browser.storage.local.get(storageKey).then((stored) => {
if (stored[storageKey]) {
Object.assign(settings, stored[storageKey]);
}
});
// Create a proxy to handle direct property access
const proxy = new Proxy(settings, {
get(target, prop: string) {
if (prop === 'onChange') {
return (key: keyof T, callback: (value: any) => void) => {
if (!listeners.has(key)) {
listeners.set(key, new Set());
}
listeners.get(key)!.add(callback);
};
}
return target[prop as keyof T];
},
set(target, prop: string, value: any) {
if (prop === 'onChange') return false;
target[prop as keyof T] = value;
browser.storage.local.set({ [storageKey]: target });
listeners.get(prop as keyof T)?.forEach(callback => callback(value));
return true;
},
}) as SettingsAPI<T>;
return proxy;
}
function createStorageAPI(pluginId: string): StorageAPI {
const prefix = `plugin.${pluginId}.storage.`;
return {
get: async <T>(key: string) => {
const result = await browser.storage.local.get(prefix + key);
return result[prefix + key] as T || null;
},
set: async <T>(key: string, value: T) => {
await browser.storage.local.set({ [prefix + key]: value });
},
};
}
function createEventsAPI(pluginId: string): EventsAPI {
const prefix = `plugin.${pluginId}.`;
return {
on: (event, callback) => {
document.addEventListener(prefix + event, ((e: CustomEvent) => {
callback(...(e.detail || []));
}) as EventListener);
},
emit: (event, ...args) => {
document.dispatchEvent(
new CustomEvent(prefix + event, {
detail: args.length > 0 ? args : null
})
);
},
};
}
export function createPluginAPI<T extends PluginSettings>(plugin: Plugin<T>): PluginAPI<T> {
return {
seqta: createSEQTAAPI(),
settings: createSettingsAPI(plugin),
storage: createStorageAPI(plugin.id),
events: createEventsAPI(plugin.id),
};
}
+142
View File
@@ -0,0 +1,142 @@
import type { Plugin, PluginSettings } from './types';
import { createPluginAPI } from './createAPI';
export class PluginManager {
private static instance: PluginManager;
private plugins: Map<string, Plugin<any>> = new Map();
private runningPlugins: Map<string, boolean> = new Map();
private eventBacklog: Map<string, any[]> = new Map();
private cleanupFunctions: Map<string, () => void> = new Map();
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private constructor() {}
public static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
return PluginManager.instance;
}
public dispatchPluginEvent(pluginId: string, event: string, args?: any) {
const fullEventName = `plugin.${pluginId}.${event}`;
if (this.runningPlugins.get(pluginId)) {
// If plugin is running, dispatch immediately
document.dispatchEvent(new CustomEvent(fullEventName, { detail: args }));
} else {
// Otherwise queue it
const key = `${pluginId}:${event}`;
if (!this.eventBacklog.has(key)) {
this.eventBacklog.set(key, []);
}
this.eventBacklog.get(key)!.push(args);
}
}
private async processBackloggedEvents(pluginId: string) {
for (const [key, argsList] of this.eventBacklog.entries()) {
const [eventPluginId, event] = key.split(':');
if (eventPluginId === pluginId) {
for (const args of argsList) {
this.dispatchPluginEvent(pluginId, event, args);
}
this.eventBacklog.delete(key);
}
}
}
public registerPlugin<T extends PluginSettings>(plugin: Plugin<T>): void {
if (this.plugins.has(plugin.id)) {
throw new Error(`Plugin with id "${plugin.id}" is already registered`);
}
this.plugins.set(plugin.id, plugin);
}
public async startPlugin(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" not found`);
}
if (this.runningPlugins.get(pluginId)) {
console.warn(`Plugin "${pluginId}" is already running`);
return;
}
try {
const api = createPluginAPI(plugin);
const result = await plugin.run(api);
if (typeof result === 'function') {
this.cleanupFunctions.set(plugin.id, result);
}
this.runningPlugins.set(pluginId, true);
console.info(`Plugin "${pluginId}" started successfully`);
// Process any backlogged events
await this.processBackloggedEvents(pluginId);
} catch (error) {
console.error(`[BetterSEQTA+] Failed to start plugin ${pluginId}:`, error);
throw error;
}
}
public async startAllPlugins(): Promise<void> {
const startPromises = Array.from(this.plugins.keys()).map(id =>
this.startPlugin(id).catch(error => {
console.error(`Failed to start plugin "${id}":`, error);
return Promise.reject(error);
})
);
await Promise.allSettled(startPromises);
}
public async stopPlugin(pluginId: string): Promise<void> {
const cleanup = this.cleanupFunctions.get(pluginId);
if (cleanup) {
cleanup();
this.cleanupFunctions.delete(pluginId);
}
this.runningPlugins.set(pluginId, false);
console.info(`Plugin "${pluginId}" stopped`);
this.emit('plugin.stopped', pluginId);
}
public stopAllPlugins(): void {
Array.from(this.plugins.keys()).forEach(id => this.stopPlugin(id));
}
public getPlugin(pluginId: string): Plugin | undefined {
return this.plugins.get(pluginId);
}
public getAllPlugins(): Plugin[] {
return Array.from(this.plugins.values());
}
public isPluginRunning(pluginId: string): boolean {
return this.runningPlugins.get(pluginId) || false;
}
private emit(event: string, ...args: any[]): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.forEach(listener => listener(...args));
}
}
public on(event: string, callback: (...args: any[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
public off(event: string, callback: (...args: any[]) => void): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.delete(callback);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import ReactFiber from '@/seqta/utils/ReactFiber';
interface BooleanSetting {
type: 'boolean';
default: boolean;
title: string;
description?: string;
}
interface StringSetting {
type: 'string';
default: string;
title: string;
description?: string;
}
interface NumberSetting {
type: 'number';
default: number;
title: string;
description?: string;
}
interface SelectSetting<T extends string> {
type: 'select';
options: readonly T[];
default: T;
title: string;
description?: string;
}
type PluginSetting = BooleanSetting | StringSetting | NumberSetting | SelectSetting<string>;
// Plugin settings configuration
export type PluginSettings = {
[key: string]: PluginSetting;
}
// Helper type to extract the actual value type from a setting
type SettingValue<T extends PluginSetting> = T extends BooleanSetting ? boolean :
T extends StringSetting ? string :
T extends NumberSetting ? number :
T extends SelectSetting<infer O> ? O :
never;
// Settings API interface
export type SettingsAPI<T extends PluginSettings> = {
[K in keyof T]: SettingValue<T[K]>;
} & {
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => void;
offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => void;
}
// SEQTA API interface
export interface SEQTAAPI {
onMount: (selector: string, callback: (element: Element) => void) => void;
getFiber: (selector: string) => ReactFiber;
getCurrentPage: () => string;
onPageChange: (callback: (page: string) => void) => void;
}
// Storage API interface
export interface StorageAPI {
get: <T>(key: string) => Promise<T | null>;
set: <T>(key: string, value: T) => Promise<void>;
}
// Events API interface
export interface EventsAPI {
on: (event: string, callback: (...args: any[]) => void) => void;
emit: (event: string, ...args: any[]) => void;
}
// Complete Plugin API interface
export interface PluginAPI<T extends PluginSettings> {
seqta: SEQTAAPI;
settings: SettingsAPI<T>;
storage: StorageAPI;
events: EventsAPI;
}
// Plugin interface
export interface Plugin<T extends PluginSettings = PluginSettings> {
id: string;
name: string;
description: string;
version: string;
settings: T;
run: (api: PluginAPI<T>) => void | Promise<void> | (() => void) | Promise<() => void>;
}
+22 -2
View File
@@ -1,2 +1,22 @@
export { init as Monofile } from './monofile'
export { init as Themes } from './themes'
import { PluginManager } from './core/manager';
import timetablePlugin from './built-in/timetable';
import notificationCollectorPlugin from './built-in/notificationCollector';
// Initialize plugin manager
const pluginManager = PluginManager.getInstance();
// Register built-in plugins
pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin);
// Legacy plugin exports
export { init as Monofile } from './monofile';
export { init as Themes } from './themes';
// New plugin system initialization
export async function initializePlugins(): Promise<void> {
await pluginManager.startAllPlugins();
}
// Re-export plugin manager for direct access if needed
export { pluginManager };
+3 -193
View File
@@ -13,11 +13,9 @@ import {
settingsState,
} from "@/seqta/utils/listeners/SettingsState"
import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges"
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"
import { eventManager } from "@/seqta/utils/listeners/EventManager"
// UI and theme management
import { enableNotificationCollector } from "@/seqta/utils/CreateEnable/EnableNotificationCollector"
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners"
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements"
import { updateAllColors } from "@/seqta/ui/colors/Manager"
@@ -240,14 +238,14 @@ async function LoadPageElements(): Promise<void> {
handleReports,
)
eventManager.register(
/* eventManager.register(
"timetableAdded",
{
elementType: "div",
className: "timetablepage",
},
handleTimetable,
)
) */
eventManager.register(
"noticesAdded",
@@ -274,159 +272,6 @@ async function LoadPageElements(): Promise<void> {
await handleSublink(sublink)
}
function handleTimetableZoom(): void {
console.log("Initializing timetable zoom controls")
// Lazy initialize state variables only when function is first called
let timetableZoomLevel = 1
let baseContainerHeight: number | null = null
const originalEntryPositions = new Map<
Element,
{ topRatio: number; heightRatio: number }
>()
// Create zoom controls
const zoomControls = document.createElement("div")
zoomControls.className = "timetable-zoom-controls"
const zoomIn = document.createElement("button")
zoomIn.className = "uiButton timetable-zoom iconFamily"
zoomIn.innerHTML = "&#xed93;" // Using unicode for zoom in icon
const zoomOut = document.createElement("button")
zoomOut.className = "uiButton timetable-zoom iconFamily"
zoomOut.innerHTML = "&#xed94;" // Using unicode for zoom out icon
zoomControls.appendChild(zoomOut)
zoomControls.appendChild(zoomIn)
const toolbar = document.getElementById("toolbar")
toolbar?.appendChild(zoomControls)
const initializePositions = () => {
// Get the base container height from the first TD
const firstDayColumn = document.querySelector(
".dailycal .content .days td",
) as HTMLElement
if (!firstDayColumn) return false
baseContainerHeight =
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
// Store original ratios
const entries = document.querySelectorAll(".entriesWrapper .entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
// Calculate ratios relative to detected base height
if (baseContainerHeight === null) return
const topRatio = parseInt(entryEl.style.top) / baseContainerHeight
const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight
originalEntryPositions.set(entry, { topRatio, heightRatio })
})
return true
}
const updateZoom = () => {
// Initialize positions if not already done
if (baseContainerHeight === null && !initializePositions()) {
console.error("Failed to initialize positions")
return
}
console.debug(`Updating zoom level to: ${timetableZoomLevel}`)
// Calculate new container height
if (baseContainerHeight === null) return
const newContainerHeight = baseContainerHeight * timetableZoomLevel
// Update all day columns (TDs)
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
dayColumns.forEach((td: Element) => {
(td as HTMLElement).style.height = `${newContainerHeight}px`
})
// Update all entries using stored ratios
const entries = document.querySelectorAll(".entriesWrapper .entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
const originalRatios = originalEntryPositions.get(entry)
if (originalRatios) {
// Calculate new positions from original ratios
const newTop = originalRatios.topRatio * newContainerHeight
const newHeight = originalRatios.heightRatio * newContainerHeight
// Apply new values
entryEl.style.top = `${Math.round(newTop)}px`
entryEl.style.height = `${Math.round(newHeight)}px`
}
})
// Update time column to match
const timeColumn = document.querySelector(".times")
if (timeColumn) {
const times = timeColumn.querySelectorAll(".time")
const timeHeight = newContainerHeight / times.length
times.forEach((time: Element) => {
(time as HTMLElement).style.height = `${timeHeight}px`
})
}
entries[Math.round((entries.length - 1) / 2)].scrollIntoView({
behavior: "instant",
block: "center",
})
}
zoomIn.addEventListener("click", () => {
if (timetableZoomLevel < 2) {
timetableZoomLevel += 0.2
updateZoom()
}
})
zoomOut.addEventListener("click", () => {
if (timetableZoomLevel > 0.6) {
timetableZoomLevel -= 0.2
updateZoom()
}
})
}
function handleTimetableAssessmentHide(): void {
const hideControls = document.createElement("div") // Creates the div element which houses the eye icon
hideControls.className = "timetable-hide-controls"
const hideOn = document.createElement("button") // Creates the actual button which is clicked
hideOn.className = "uiButton timetable-hide iconFamily"
hideOn.innerHTML = "&#128065;" // Using unicode for hide icon
hideControls.appendChild(hideOn)
const toolbar = document.getElementById("toolbar") // Appends the new button to the toolbar
toolbar?.appendChild(hideControls)
function hideElements(): void {
const entries = document.querySelectorAll(".entry") // Gets all the timetables entries on the page, and loops through
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
if (!entryEl.classList.contains("assessment") && !(entryEl.style.opacity === "0.3")) { // If the entry is not an assessment, and hasn't already been hidden, hide it.
entryEl.style.opacity = "0.3"
} else { // Otherwise, it should be shown.
entryEl.style.opacity = "1"
}
})
}
hideOn.addEventListener("click", () => { // Listen for when the button is pressed
hideElements()
})
}
async function handleNotices(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return
if (!settingsState.animations) return
@@ -454,11 +299,8 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
await handleNewsPage()
break
case undefined:
window.location.replace(
`${location.origin}/#?page=/${settingsState.defaultPage}`,
)
window.location.replace(`${location.origin}/#?page=/${settingsState.defaultPage}`)
if (settingsState.defaultPage === "home") loadHomePage()
if (settingsState.defaultPage === "timetable") handleTimetable()
if (settingsState.defaultPage === "documents")
handleDocuments(document.querySelector(".documents")!)
if (settingsState.defaultPage === "reports")
@@ -481,48 +323,16 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
}
}
async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10)
// Store original heights when timetable loads
const lessons = document.querySelectorAll(".dailycal .lesson")
lessons.forEach((lesson: Element) => {
const lessonEl = lesson as HTMLElement
lessonEl.setAttribute(
"data-original-height",
lessonEl.offsetHeight.toString(),
)
})
// Existing time format code
if (settingsState.timeFormat == "12") {
const times = document.querySelectorAll(".timetablepage .times .time")
for (const time of times) {
if (!time.textContent) continue
time.textContent = convertTo12HourFormat(time.textContent, true)
}
}
handleTimetableZoom()
handleTimetableAssessmentHide()
}
async function handleNewsPage(): Promise<void> {
console.info("[BetterSEQTA+] Started Init")
if (settingsState.onoff) {
SendNewsPage()
if (settingsState.notificationcollector) {
enableNotificationCollector()
}
finishLoad()
}
}
async function handleDefault(): Promise<void> {
finishLoad()
if (settingsState.notificationcollector) {
enableNotificationCollector()
}
}
async function handleMessages(node: Element): Promise<void> {