Compare commits
5
Commits
v0.9.1
...
v0.10.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51edff809a | ||
|
|
94a82eac07 | ||
|
|
7692b1c9dd | ||
|
|
02a62cce31 | ||
|
|
2802d780f3 |
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Build and release output
|
||||||
|
releases/*.zip
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
release/
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
Hudra
|
||||||
|
Purpose
|
||||||
|
|
||||||
|
Hudra is a browser extension that turns structured QLearn lesson pages into a clean, classroom-friendly presentation.
|
||||||
|
|
||||||
|
Hudra presents existing QLearn content in a slide-oriented or presentation-oriented format.
|
||||||
|
|
||||||
|
It is not a QLearn page editor.
|
||||||
|
|
||||||
|
Core product promise
|
||||||
|
|
||||||
|
Hudra should allow a teacher to open an existing QLearn lesson and present it clearly without rebuilding the content in separate presentation software.
|
||||||
|
|
||||||
|
The original QLearn page remains the source of truth.
|
||||||
|
|
||||||
|
Responsibilities
|
||||||
|
|
||||||
|
Hudra may:
|
||||||
|
|
||||||
|
detect supported QLearn lesson pages;
|
||||||
|
identify presentation sections from page structure;
|
||||||
|
interpret supported headings as presentation boundaries;
|
||||||
|
present page content in a slide-like view;
|
||||||
|
provide previous and next navigation;
|
||||||
|
support keyboard navigation;
|
||||||
|
provide fullscreen or classroom presentation modes;
|
||||||
|
display progress through the presentation;
|
||||||
|
preserve embedded content where practical;
|
||||||
|
provide graceful fallback behaviour for unsupported elements;
|
||||||
|
restore the normal QLearn page when presentation mode closes;
|
||||||
|
remember appropriate user preferences.
|
||||||
|
Product boundaries
|
||||||
|
|
||||||
|
Hudra must not:
|
||||||
|
|
||||||
|
edit or permanently rewrite QLearn page content;
|
||||||
|
save changes into the QLearn editor;
|
||||||
|
become a general page-formatting tool;
|
||||||
|
add teacher notes to the source page;
|
||||||
|
become a general distraction-removal extension;
|
||||||
|
duplicate Pendra’s editing features;
|
||||||
|
duplicate Lildra’s general focus-mode features;
|
||||||
|
require users to rebuild lessons specifically for Hudra unless a documented structure is necessary.
|
||||||
|
Relationship to other Napper applications
|
||||||
|
Lildra
|
||||||
|
|
||||||
|
Lildra reduces surrounding QLearn clutter while leaving the content in its original continuous-page form.
|
||||||
|
|
||||||
|
Hudra provides a presentation experience.
|
||||||
|
|
||||||
|
A feature belongs in Hudra when it is specifically useful while presenting lesson content.
|
||||||
|
|
||||||
|
Pendra
|
||||||
|
|
||||||
|
Pendra helps teachers edit, structure and enhance QLearn pages.
|
||||||
|
|
||||||
|
Hudra may interpret structure created by Pendra, but it must not perform Pendra’s editing work.
|
||||||
|
|
||||||
|
Content interpretation
|
||||||
|
|
||||||
|
Hudra should rely on semantic page structure wherever possible.
|
||||||
|
|
||||||
|
Preferred signals include:
|
||||||
|
|
||||||
|
valid heading hierarchy;
|
||||||
|
clearly defined content sections;
|
||||||
|
predictable QLearn content containers;
|
||||||
|
explicit Hudra-compatible markers only where normal semantic structure is insufficient.
|
||||||
|
|
||||||
|
Do not rely unnecessarily on fragile visual selectors or exact class names.
|
||||||
|
|
||||||
|
QLearn may change its DOM structure. Detection should therefore be defensive and easy to update.
|
||||||
|
|
||||||
|
Behaviour principles
|
||||||
|
Never permanently alter lesson content.
|
||||||
|
Presentation mode must be reversible.
|
||||||
|
Closing Hudra should restore the original page state.
|
||||||
|
Missing elements must not cause the entire extension to fail.
|
||||||
|
Unsupported content should remain visible where possible.
|
||||||
|
Avoid hiding important content silently.
|
||||||
|
Provide clear feedback when a page cannot be presented reliably.
|
||||||
|
Do not activate on unrelated pages.
|
||||||
|
Avoid interfering with normal QLearn controls when Hudra is inactive.
|
||||||
|
User experience
|
||||||
|
|
||||||
|
Hudra should feel calm, predictable and suitable for classroom use.
|
||||||
|
|
||||||
|
Prioritise:
|
||||||
|
|
||||||
|
large readable content;
|
||||||
|
clear navigation;
|
||||||
|
minimal controls;
|
||||||
|
obvious presentation state;
|
||||||
|
reliable keyboard interaction;
|
||||||
|
smooth restoration of the normal page;
|
||||||
|
minimal setup.
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
crowded toolbars;
|
||||||
|
unnecessary configuration;
|
||||||
|
distracting animation;
|
||||||
|
ambiguous icons;
|
||||||
|
features unrelated to presenting content.
|
||||||
|
Accessibility
|
||||||
|
Preserve meaningful heading and document structure where practical.
|
||||||
|
Maintain keyboard navigation.
|
||||||
|
Ensure controls have accessible names.
|
||||||
|
Use sufficient colour contrast.
|
||||||
|
Avoid relying on colour alone to convey state.
|
||||||
|
Preserve alternative text for images.
|
||||||
|
Do not remove captions or transcripts from embedded content.
|
||||||
|
Avoid trapping keyboard focus.
|
||||||
|
Technical principles
|
||||||
|
Prefer plain JavaScript, HTML and CSS.
|
||||||
|
Avoid unnecessary frameworks and dependencies.
|
||||||
|
Keep the extension independently installable.
|
||||||
|
Request only the permissions Hudra genuinely needs.
|
||||||
|
Do not include remote executable code.
|
||||||
|
Do not include API keys, passwords or private credentials.
|
||||||
|
Validate DOM elements before accessing them.
|
||||||
|
Isolate QLearn selectors and detection logic where practical.
|
||||||
|
Avoid duplicated selectors across multiple files.
|
||||||
|
Use clear, descriptive names.
|
||||||
|
Keep functions focused.
|
||||||
|
Add comments only when behaviour is not obvious.
|
||||||
|
Remove temporary debugging output before release.
|
||||||
|
QLearn safety
|
||||||
|
|
||||||
|
Hudra operates inside an external platform that may change without notice.
|
||||||
|
|
||||||
|
Code must:
|
||||||
|
|
||||||
|
check that expected elements exist;
|
||||||
|
handle missing or changed selectors;
|
||||||
|
avoid assumptions about a single page layout;
|
||||||
|
fail without breaking the underlying QLearn page;
|
||||||
|
avoid deleting source elements;
|
||||||
|
avoid destructive DOM operations;
|
||||||
|
retain enough state to restore the original page;
|
||||||
|
document selectors that are especially fragile.
|
||||||
|
Permissions and privacy
|
||||||
|
|
||||||
|
Hudra should request the smallest possible set of browser permissions.
|
||||||
|
|
||||||
|
Any stored data should normally be limited to local preferences, such as:
|
||||||
|
|
||||||
|
display options;
|
||||||
|
presentation preferences;
|
||||||
|
last-used settings.
|
||||||
|
|
||||||
|
Do not collect or transmit:
|
||||||
|
|
||||||
|
student information;
|
||||||
|
lesson content;
|
||||||
|
QLearn credentials;
|
||||||
|
browsing history;
|
||||||
|
analytics;
|
||||||
|
personal data;
|
||||||
|
|
||||||
|
unless the repository documentation and privacy policy are deliberately updated to reflect verified behaviour.
|
||||||
|
|
||||||
|
Repository expectations
|
||||||
|
|
||||||
|
The repository should contain:
|
||||||
|
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
|
CHANGELOG.md
|
||||||
|
manifest.json
|
||||||
|
source files
|
||||||
|
icons
|
||||||
|
release tooling
|
||||||
|
relevant documentation
|
||||||
|
tests where practical
|
||||||
|
|
||||||
|
Generated release packages should not be committed unless there is a deliberate reason.
|
||||||
|
|
||||||
|
Release output should remain ignored through .gitignore.
|
||||||
|
|
||||||
|
Versioning
|
||||||
|
|
||||||
|
Hudra uses its own version number.
|
||||||
|
|
||||||
|
Use semantic versioning where practical:
|
||||||
|
|
||||||
|
patch: fixes without new user-facing capability;
|
||||||
|
minor: backwards-compatible features;
|
||||||
|
major: substantial or potentially disruptive changes.
|
||||||
|
|
||||||
|
The version in manifest.json must match the intended release.
|
||||||
|
|
||||||
|
Use release tags such as:
|
||||||
|
|
||||||
|
v0.9.3
|
||||||
|
v1.0.0
|
||||||
|
v1.1.0
|
||||||
|
|
||||||
|
Release packages should use names such as:
|
||||||
|
|
||||||
|
Hudra-v1.0.0.zip
|
||||||
|
Release requirements
|
||||||
|
|
||||||
|
Before preparing a release:
|
||||||
|
|
||||||
|
Confirm the manifest version.
|
||||||
|
Review requested permissions.
|
||||||
|
Run available checks and tests.
|
||||||
|
Test Hudra manually on representative QLearn pages.
|
||||||
|
Test activation and deactivation.
|
||||||
|
Test keyboard navigation.
|
||||||
|
Test fullscreen behaviour if supported.
|
||||||
|
Confirm the normal QLearn page is restored correctly.
|
||||||
|
Check the extension package contains only required runtime files.
|
||||||
|
Exclude development files, previous releases and generated folders.
|
||||||
|
Update CHANGELOG.md.
|
||||||
|
Update documentation when behaviour changes.
|
||||||
|
Identify remaining manual testing requirements.
|
||||||
|
Codex working rules
|
||||||
|
|
||||||
|
When asked to implement a feature:
|
||||||
|
|
||||||
|
Inspect the relevant implementation before editing.
|
||||||
|
Confirm that the feature belongs within Hudra’s scope.
|
||||||
|
Prefer the smallest reliable change.
|
||||||
|
Do not broaden permissions without explaining why.
|
||||||
|
Do not silently change public behaviour.
|
||||||
|
Preserve compatibility with existing structured pages where possible.
|
||||||
|
Update documentation and changelog entries when appropriate.
|
||||||
|
Run available validation.
|
||||||
|
Summarise changes clearly.
|
||||||
|
State what still requires testing inside QLearn.
|
||||||
|
|
||||||
|
When asked to review the repository:
|
||||||
|
|
||||||
|
distinguish confirmed defects from possible risks;
|
||||||
|
do not rewrite functioning code solely for stylistic preference;
|
||||||
|
identify fragile QLearn selectors;
|
||||||
|
identify destructive or irreversible DOM changes;
|
||||||
|
identify unnecessary permissions;
|
||||||
|
identify files accidentally included in release packages;
|
||||||
|
identify differences between documented and actual behaviour.
|
||||||
+80
-13
@@ -1,16 +1,83 @@
|
|||||||
# Hudra changelog
|
# Hudra
|
||||||
|
|
||||||
## 0.9.0
|
Turn HTML lessons into classroom presentations.
|
||||||
|
|
||||||
- Removed the startup fade and wordmark so presentations begin immediately.
|
## Hudra 0.10 Beta 1 — 2026-07-26
|
||||||
- Added live audience zoom controls from 70% to 150%.
|
|
||||||
- Added audience scroll up/down controls with press-and-hold support.
|
|
||||||
- Added mouse-wheel audience scrolling and Shift + wheel zooming over the current preview.
|
|
||||||
- Added Reset view and live view status.
|
|
||||||
- Kept the product name simply **Hudra**.
|
|
||||||
- Retained dual-screen presenter view, slide previews, timing, clicker support, audience blanking and video controls.
|
|
||||||
|
|
||||||
## 0.9.1
|
- Restrict presentation startup to supported QLearn and Instructure lesson pages.
|
||||||
- Added one narrowly scoped feature: slides containing exactly one image now fit that image to the full audience viewport using `object-fit: contain`.
|
- Show an accessible in-page message when Hudra cannot start.
|
||||||
- Landscape images fit to width and portrait images fit to height without cropping or distortion.
|
- Restore active presenter sessions after the extension service worker restarts.
|
||||||
- All other slide types retain the original Hudra 0.9 behaviour.
|
- Remove the unnecessary `tabs` permission.
|
||||||
|
- Preserve rich paragraph markup during slide pagination.
|
||||||
|
- Package only browser-extension runtime files in release builds.
|
||||||
|
|
||||||
|
Build once.
|
||||||
|
Present everywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Hudra?
|
||||||
|
|
||||||
|
Teachers transitioning from OneNote and PowerPoint to QLearn often maintain two versions of the same lesson.
|
||||||
|
|
||||||
|
Hudra removes that duplication by turning the existing QLearn lesson into the presentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
✔ Automatic slide generation
|
||||||
|
|
||||||
|
✔ Presenter dashboard
|
||||||
|
|
||||||
|
✔ Dual-screen presentation
|
||||||
|
|
||||||
|
✔ Timer & clock
|
||||||
|
|
||||||
|
✔ Audience controls
|
||||||
|
|
||||||
|
✔ Keyboard navigation
|
||||||
|
|
||||||
|
✔ Image optimisation
|
||||||
|
|
||||||
|
✔ Browser extension
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
The HTML lesson is the source of truth.
|
||||||
|
|
||||||
|
Hudra doesn't replace your LMS.
|
||||||
|
|
||||||
|
It simply gives it Presentation Mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Current beta release
|
||||||
|
|
||||||
|
Hudra 0.10 Beta 1
|
||||||
|
|
||||||
|
See ROADMAP.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Vision
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
|
||||||
|
Development Decisions
|
||||||
|
|
||||||
|
Roadmap
|
||||||
|
|
||||||
|
Contributing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contributing to Hudra
|
||||||
|
|
||||||
|
Hudra is currently an early-stage project.
|
||||||
|
|
||||||
|
## Development principles
|
||||||
|
|
||||||
|
Changes should be:
|
||||||
|
|
||||||
|
- focused
|
||||||
|
- easy to understand
|
||||||
|
- independently testable
|
||||||
|
- backwards compatible where practical
|
||||||
|
|
||||||
|
## Before making a change
|
||||||
|
|
||||||
|
1. Create or identify an issue.
|
||||||
|
2. Describe the problem being solved.
|
||||||
|
3. Define clear acceptance criteria.
|
||||||
|
4. Make the smallest practical change.
|
||||||
|
5. Test against the stable lesson examples.
|
||||||
|
6. Confirm existing presentation behaviour still works.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Use short, descriptive commit messages, for example:
|
||||||
|
|
||||||
|
- Fix audience zoom reset
|
||||||
|
- Add presenter keyboard shortcut
|
||||||
|
- Improve image-only slide sizing
|
||||||
|
- Update installation documentation
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Stable releases should include:
|
||||||
|
|
||||||
|
- an updated manifest version
|
||||||
|
- changelog entry
|
||||||
|
- Git commit
|
||||||
|
- Git tag
|
||||||
|
- release ZIP
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Kaden Napper
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -3,6 +3,9 @@ Hudra
|
|||||||
|
|
||||||
Hudra transforms structured Canvas/QLearn pages into responsive presentations. All lesson processing remains local in the browser.
|
Hudra transforms structured Canvas/QLearn pages into responsive presentations. All lesson processing remains local in the browser.
|
||||||
|
|
||||||
|
CURRENT BETA
|
||||||
|
Hudra 0.10 Beta 1
|
||||||
|
|
||||||
INSTALL
|
INSTALL
|
||||||
1. Extract this ZIP file.
|
1. Extract this ZIP file.
|
||||||
2. Open edge://extensions, chrome://extensions or brave://extensions.
|
2. Open edge://extensions, chrome://extensions or brave://extensions.
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# Hudra Roadmap
|
||||||
|
|
||||||
|
Hudra is developed incrementally, with stability and simplicity prioritised
|
||||||
|
over large feature releases.
|
||||||
|
|
||||||
|
## Current beta release
|
||||||
|
|
||||||
|
### Hudra 0.10 Beta 1
|
||||||
|
|
||||||
|
- Supported-page detection for QLearn and Canvas
|
||||||
|
- Resilient presenter controls across service-worker restarts
|
||||||
|
- Rich paragraph markup preserved during pagination
|
||||||
|
- Reduced browser permissions
|
||||||
|
- Runtime-only release packaging
|
||||||
|
|
||||||
|
## Current stable release
|
||||||
|
|
||||||
|
### Hudra 0.9.1
|
||||||
|
|
||||||
|
- Automatic slide generation from HTML headings
|
||||||
|
- Presenter dashboard
|
||||||
|
- Dual-screen presentation
|
||||||
|
- Current and next slide previews
|
||||||
|
- Timer and clock
|
||||||
|
- Audience zoom and scrolling
|
||||||
|
- Keyboard and presentation remote support
|
||||||
|
- Image-only slide optimisation
|
||||||
|
|
||||||
|
## Near-term priorities
|
||||||
|
|
||||||
|
### Hudra 0.10
|
||||||
|
|
||||||
|
- Presenter interface refinement
|
||||||
|
- Improved reliability
|
||||||
|
- Documentation
|
||||||
|
- Classroom testing
|
||||||
|
- Accessibility review
|
||||||
|
|
||||||
|
## Future exploration
|
||||||
|
|
||||||
|
- Video-focused slide layouts
|
||||||
|
- Presenter annotations
|
||||||
|
- Laser pointer
|
||||||
|
- Improved media handling
|
||||||
|
- Wider LMS compatibility
|
||||||
|
- Managed browser deployment
|
||||||
|
|
||||||
|
Future features will be developed and tested individually to avoid regressions.
|
||||||
+215
-45
@@ -2,6 +2,80 @@ const sessions = new Map();
|
|||||||
const tabToSession = new Map();
|
const tabToSession = new Map();
|
||||||
const windowToSession = new Map();
|
const windowToSession = new Map();
|
||||||
const previousWindowStates = new Map();
|
const previousWindowStates = new Map();
|
||||||
|
const sessionStorageKey = "hudraActiveSessions";
|
||||||
|
let persistStatePromise = Promise.resolve();
|
||||||
|
|
||||||
|
function rebuildSessionIndexes() {
|
||||||
|
tabToSession.clear();
|
||||||
|
windowToSession.clear();
|
||||||
|
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
if (session.audienceTabId != null) {
|
||||||
|
tabToSession.set(session.audienceTabId, session.id);
|
||||||
|
}
|
||||||
|
if (session.audienceWindowId != null) {
|
||||||
|
windowToSession.set(session.audienceWindowId, session.id);
|
||||||
|
}
|
||||||
|
if (session.controlWindowId != null) {
|
||||||
|
windowToSession.set(session.controlWindowId, session.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSessionState() {
|
||||||
|
const stored = await chrome.storage.session.get(sessionStorageKey);
|
||||||
|
const state = stored[sessionStorageKey];
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
for (const storedSession of state.sessions || []) {
|
||||||
|
if (!storedSession?.id) continue;
|
||||||
|
sessions.set(storedSession.id, {
|
||||||
|
...storedSession,
|
||||||
|
pageTitle: "Hudra",
|
||||||
|
current: 0,
|
||||||
|
count: 0,
|
||||||
|
audienceHidden: false,
|
||||||
|
audienceZoom: 100,
|
||||||
|
audienceScrollY: 0,
|
||||||
|
currentPreview: "",
|
||||||
|
nextPreview: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [windowId, windowState] of state.previousWindowStates || []) {
|
||||||
|
previousWindowStates.set(Number(windowId), windowState);
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildSessionIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionStateReady = restoreSessionState().catch((error) => {
|
||||||
|
console.warn("Hudra could not restore its active sessions:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function ensureSessionStateReady() {
|
||||||
|
await sessionStateReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSessionState() {
|
||||||
|
const state = {
|
||||||
|
sessions: [...sessions.values()].map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
mode: session.mode,
|
||||||
|
sourceTabId: session.sourceTabId,
|
||||||
|
audienceTabId: session.audienceTabId,
|
||||||
|
audienceWindowId: session.audienceWindowId,
|
||||||
|
controlWindowId: session.controlWindowId
|
||||||
|
})),
|
||||||
|
previousWindowStates: [...previousWindowStates.entries()]
|
||||||
|
};
|
||||||
|
|
||||||
|
persistStatePromise = persistStatePromise
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => chrome.storage.session.set({ [sessionStorageKey]: state }));
|
||||||
|
|
||||||
|
return persistStatePromise;
|
||||||
|
}
|
||||||
|
|
||||||
function makeSessionId() {
|
function makeSessionId() {
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
@@ -32,6 +106,57 @@ function chooseSecondaryDisplay(displays) {
|
|||||||
return { primary, secondary };
|
return { primary, secondary };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSupportedLessonUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
return url.protocol === "https:" && (
|
||||||
|
hostname === "qlearn.eq.edu.au" ||
|
||||||
|
hostname.endsWith(".qlearn.eq.edu.au") ||
|
||||||
|
hostname.endsWith(".instructure.com")
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showPageNotice(tabId, message) {
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
files: ["page-detection.js"]
|
||||||
|
});
|
||||||
|
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: (noticeMessage) => window.__hudraPageDetector.showNotice(noticeMessage),
|
||||||
|
args: [message]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectLessonPage(tabId) {
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
files: ["page-detection.js"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () => window.__hudraPageDetector.inspect()
|
||||||
|
});
|
||||||
|
|
||||||
|
return results[0]?.result || { supported: false, reason: "INSPECTION_FAILED" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageForUnsupportedPage(reason) {
|
||||||
|
if (reason === "NO_LESSON_TITLE") {
|
||||||
|
return "Hudra couldn’t start because this lesson has no H1 title.";
|
||||||
|
}
|
||||||
|
if (reason === "EMPTY_LESSON") {
|
||||||
|
return "Hudra couldn’t start because this lesson has no presentable content.";
|
||||||
|
}
|
||||||
|
return "Hudra couldn’t start because this page does not contain a supported lesson.";
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForTabComplete(tabId, timeoutMs = 20000) {
|
async function waitForTabComplete(tabId, timeoutMs = 20000) {
|
||||||
const existing = await chrome.tabs.get(tabId).catch(() => null);
|
const existing = await chrome.tabs.get(tabId).catch(() => null);
|
||||||
if (existing?.status === "complete") return;
|
if (existing?.status === "complete") return;
|
||||||
@@ -57,7 +182,7 @@ async function waitForTabComplete(tabId, timeoutMs = 20000) {
|
|||||||
async function injectPresenter(tabId, config) {
|
async function injectPresenter(tabId, config) {
|
||||||
await chrome.scripting.executeScript({
|
await chrome.scripting.executeScript({
|
||||||
target: { tabId },
|
target: { tabId },
|
||||||
files: ["presenter.js"]
|
files: ["page-detection.js", "presenter.js"]
|
||||||
});
|
});
|
||||||
|
|
||||||
await chrome.tabs.sendMessage(tabId, {
|
await chrome.tabs.sendMessage(tabId, {
|
||||||
@@ -67,6 +192,7 @@ async function injectPresenter(tabId, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startSingleScreen(tab) {
|
async function startSingleScreen(tab) {
|
||||||
|
await ensureSessionStateReady();
|
||||||
if (!tab.id || tab.windowId == null) return;
|
if (!tab.id || tab.windowId == null) return;
|
||||||
|
|
||||||
const sessionId = makeSessionId();
|
const sessionId = makeSessionId();
|
||||||
@@ -93,12 +219,14 @@ async function startSingleScreen(tab) {
|
|||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
tabToSession.set(tab.id, sessionId);
|
tabToSession.set(tab.id, sessionId);
|
||||||
windowToSession.set(tab.windowId, sessionId);
|
windowToSession.set(tab.windowId, sessionId);
|
||||||
|
await persistSessionState();
|
||||||
|
|
||||||
await injectPresenter(tab.id, { mode: "single", sessionId });
|
await injectPresenter(tab.id, { mode: "single", sessionId });
|
||||||
await chrome.windows.update(tab.windowId, { state: "fullscreen" });
|
await chrome.windows.update(tab.windowId, { state: "fullscreen" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDualScreen(tab, primary, secondary) {
|
async function startDualScreen(tab, primary, secondary) {
|
||||||
|
await ensureSessionStateReady();
|
||||||
if (!tab.id || !tab.url) return;
|
if (!tab.id || !tab.url) return;
|
||||||
|
|
||||||
const sessionId = makeSessionId();
|
const sessionId = makeSessionId();
|
||||||
@@ -153,6 +281,7 @@ async function startDualScreen(tab, primary, secondary) {
|
|||||||
tabToSession.set(audienceTab.id, sessionId);
|
tabToSession.set(audienceTab.id, sessionId);
|
||||||
windowToSession.set(audienceWindow.id, sessionId);
|
windowToSession.set(audienceWindow.id, sessionId);
|
||||||
if (controlWindow.id != null) windowToSession.set(controlWindow.id, sessionId);
|
if (controlWindow.id != null) windowToSession.set(controlWindow.id, sessionId);
|
||||||
|
await persistSessionState();
|
||||||
|
|
||||||
await waitForTabComplete(audienceTab.id);
|
await waitForTabComplete(audienceTab.id);
|
||||||
await injectPresenter(audienceTab.id, { mode: "audience", sessionId });
|
await injectPresenter(audienceTab.id, { mode: "audience", sessionId });
|
||||||
@@ -180,6 +309,7 @@ async function startDualScreen(tab, primary, secondary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function endSession(sessionId, reason = "ended") {
|
async function endSession(sessionId, reason = "ended") {
|
||||||
|
await ensureSessionStateReady();
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
@@ -197,10 +327,12 @@ async function endSession(sessionId, reason = "ended") {
|
|||||||
|
|
||||||
const previousState = previousWindowStates.get(session.audienceWindowId) || "maximized";
|
const previousState = previousWindowStates.get(session.audienceWindowId) || "maximized";
|
||||||
previousWindowStates.delete(session.audienceWindowId);
|
previousWindowStates.delete(session.audienceWindowId);
|
||||||
|
await persistSessionState();
|
||||||
await chrome.windows.update(session.audienceWindowId, { state: previousState }).catch(async () => {
|
await chrome.windows.update(session.audienceWindowId, { state: previousState }).catch(async () => {
|
||||||
await chrome.windows.update(session.audienceWindowId, { state: "maximized" }).catch(() => {});
|
await chrome.windows.update(session.audienceWindowId, { state: "maximized" }).catch(() => {});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
await persistSessionState();
|
||||||
if (session.audienceWindowId != null) {
|
if (session.audienceWindowId != null) {
|
||||||
await chrome.windows.remove(session.audienceWindowId).catch(() => {});
|
await chrome.windows.remove(session.audienceWindowId).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -211,6 +343,7 @@ async function endSession(sessionId, reason = "ended") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendCommand(sessionId, command, value) {
|
async function sendCommand(sessionId, command, value) {
|
||||||
|
await ensureSessionStateReady();
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session?.audienceTabId) return;
|
if (!session?.audienceTabId) return;
|
||||||
|
|
||||||
@@ -227,6 +360,7 @@ async function sendCommand(sessionId, command, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleHudraForTab(tab) {
|
async function toggleHudraForTab(tab) {
|
||||||
|
await ensureSessionStateReady();
|
||||||
if (!tab?.id) return;
|
if (!tab?.id) return;
|
||||||
|
|
||||||
const windowSessionId = tab.windowId != null ? windowToSession.get(tab.windowId) : null;
|
const windowSessionId = tab.windowId != null ? windowToSession.get(tab.windowId) : null;
|
||||||
@@ -241,6 +375,30 @@ async function toggleHudraForTab(tab) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSupportedLessonUrl(tab.url)) {
|
||||||
|
await showPageNotice(
|
||||||
|
tab.id,
|
||||||
|
"Hudra works on QLearn and Canvas lesson pages hosted by Instructure."
|
||||||
|
).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspection;
|
||||||
|
try {
|
||||||
|
inspection = await inspectLessonPage(tab.id);
|
||||||
|
} catch (error) {
|
||||||
|
await showPageNotice(
|
||||||
|
tab.id,
|
||||||
|
"Hudra couldn’t inspect this page. Wait for it to finish loading and try again."
|
||||||
|
).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inspection.supported) {
|
||||||
|
await showPageNotice(tab.id, messageForUnsupportedPage(inspection.reason)).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const displays = await getUsableDisplays();
|
const displays = await getUsableDisplays();
|
||||||
const { primary, secondary } = chooseSecondaryDisplay(displays);
|
const { primary, secondary } = chooseSecondaryDisplay(displays);
|
||||||
@@ -270,31 +428,33 @@ chrome.commands.onCommand.addListener(async (command) => {
|
|||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (message?.type === "QLEARN_PRESENTER_STATE") {
|
if (message?.type === "QLEARN_PRESENTER_STATE") {
|
||||||
const session = sessions.get(message.sessionId);
|
ensureSessionStateReady().then(() => {
|
||||||
if (!session) return;
|
const session = sessions.get(message.sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
session.current = Number(message.current) || 0;
|
session.current = Number(message.current) || 0;
|
||||||
session.count = Number(message.count) || 0;
|
session.count = Number(message.count) || 0;
|
||||||
session.pageTitle = message.pageTitle || session.pageTitle;
|
session.pageTitle = message.pageTitle || session.pageTitle;
|
||||||
session.audienceHidden = Boolean(message.audienceHidden);
|
session.audienceHidden = Boolean(message.audienceHidden);
|
||||||
session.audienceZoom = Number(message.audienceZoom) || 100;
|
session.audienceZoom = Number(message.audienceZoom) || 100;
|
||||||
session.audienceScrollY = Number(message.audienceScrollY) || 0;
|
session.audienceScrollY = Number(message.audienceScrollY) || 0;
|
||||||
session.currentPreview = message.currentPreview || "";
|
session.currentPreview = message.currentPreview || "";
|
||||||
session.nextPreview = message.nextPreview || "";
|
session.nextPreview = message.nextPreview || "";
|
||||||
|
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
type: "QLEARN_PRESENTER_STATE_UPDATE",
|
type: "QLEARN_PRESENTER_STATE_UPDATE",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
current: session.current,
|
current: session.current,
|
||||||
count: session.count,
|
count: session.count,
|
||||||
pageTitle: session.pageTitle,
|
pageTitle: session.pageTitle,
|
||||||
mode: session.mode,
|
mode: session.mode,
|
||||||
audienceHidden: session.audienceHidden,
|
audienceHidden: session.audienceHidden,
|
||||||
audienceZoom: session.audienceZoom,
|
audienceZoom: session.audienceZoom,
|
||||||
audienceScrollY: session.audienceScrollY,
|
audienceScrollY: session.audienceScrollY,
|
||||||
currentPreview: session.currentPreview,
|
currentPreview: session.currentPreview,
|
||||||
nextPreview: session.nextPreview
|
nextPreview: session.nextPreview
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,31 +469,41 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message?.type === "QLEARN_PRESENTER_GET_SESSION") {
|
if (message?.type === "QLEARN_PRESENTER_GET_SESSION") {
|
||||||
const session = sessions.get(message.sessionId);
|
ensureSessionStateReady().then(() => {
|
||||||
sendResponse(session ? {
|
const session = sessions.get(message.sessionId);
|
||||||
sessionId: session.id,
|
if (session?.audienceTabId != null) {
|
||||||
mode: session.mode,
|
chrome.tabs.sendMessage(session.audienceTabId, {
|
||||||
current: session.current,
|
type: "QLEARN_PRESENTER_COMMAND",
|
||||||
count: session.count,
|
command: "reportState"
|
||||||
pageTitle: session.pageTitle,
|
}).catch(() => {});
|
||||||
audienceHidden: session.audienceHidden,
|
}
|
||||||
audienceZoom: session.audienceZoom,
|
sendResponse(session ? {
|
||||||
audienceScrollY: session.audienceScrollY,
|
sessionId: session.id,
|
||||||
currentPreview: session.currentPreview,
|
mode: session.mode,
|
||||||
nextPreview: session.nextPreview
|
current: session.current,
|
||||||
} : null);
|
count: session.count,
|
||||||
|
pageTitle: session.pageTitle,
|
||||||
|
audienceHidden: session.audienceHidden,
|
||||||
|
audienceZoom: session.audienceZoom,
|
||||||
|
audienceScrollY: session.audienceScrollY,
|
||||||
|
currentPreview: session.currentPreview,
|
||||||
|
nextPreview: session.nextPreview
|
||||||
|
} : null);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
chrome.windows.onRemoved.addListener((windowId) => {
|
chrome.windows.onRemoved.addListener((windowId) => {
|
||||||
const sessionId = windowToSession.get(windowId);
|
ensureSessionStateReady().then(() => {
|
||||||
if (!sessionId) return;
|
const sessionId = windowToSession.get(windowId);
|
||||||
const session = sessions.get(sessionId);
|
if (!sessionId) return;
|
||||||
if (!session) return;
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
// Closing either dual-screen window ends the whole presentation.
|
// Closing either dual-screen window ends the whole presentation.
|
||||||
if (session.mode === "dual") {
|
if (session.mode === "dual") {
|
||||||
endSession(sessionId, "window-closed").catch(() => {});
|
endSession(sessionId, "window-closed").catch(() => {});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Hudra Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Hudra is a Manifest V3 browser extension that presents the existing DOM of an
|
||||||
|
HTML lesson.
|
||||||
|
|
||||||
|
It does not convert the lesson into a proprietary format.
|
||||||
|
|
||||||
|
## Slide detection
|
||||||
|
|
||||||
|
The current presentation model uses HTML headings:
|
||||||
|
|
||||||
|
- H1 identifies the lesson title
|
||||||
|
- H2 identifies the beginning of a new slide
|
||||||
|
- H3 identifies the beginning of a new slide
|
||||||
|
- H4 identifies a subsection within the current slide
|
||||||
|
- content following an H2 or H3 belongs to that slide until the next H2 or H3
|
||||||
|
|
||||||
|
Pages without H2 or H3 headings are presented as a single slide.
|
||||||
|
|
||||||
|
## Supported pages
|
||||||
|
|
||||||
|
Hudra starts only on secure QLearn pages at `qlearn.eq.edu.au` (including
|
||||||
|
subdomains) and Canvas pages on an `instructure.com` subdomain. A supported
|
||||||
|
page must also contain a recognised lesson-content container and an H1 lesson
|
||||||
|
title. Unsupported pages receive a temporary in-page notice and are not put
|
||||||
|
into presentation mode.
|
||||||
|
|
||||||
|
## Main components
|
||||||
|
|
||||||
|
### background.js
|
||||||
|
|
||||||
|
Handles extension-level behaviour and presentation launch logic.
|
||||||
|
Active session routing and window-restoration metadata is kept in
|
||||||
|
`chrome.storage.session` so presenter controls survive service-worker restarts.
|
||||||
|
Lesson content and slide previews are not persisted.
|
||||||
|
|
||||||
|
### controls.html
|
||||||
|
|
||||||
|
Provides the presenter dashboard interface.
|
||||||
|
|
||||||
|
### controls.css
|
||||||
|
|
||||||
|
Styles the presenter controls.
|
||||||
|
|
||||||
|
### controls.js
|
||||||
|
|
||||||
|
Manages presenter navigation, controls and audience communication.
|
||||||
|
|
||||||
|
### presenter.js
|
||||||
|
|
||||||
|
Creates and manages the audience presentation experience.
|
||||||
|
|
||||||
|
### page-detection.js
|
||||||
|
|
||||||
|
Contains the shared lesson-container and title detection used before launch
|
||||||
|
and when presentation mode starts.
|
||||||
|
|
||||||
|
### manifest.json
|
||||||
|
|
||||||
|
Defines the extension metadata, permissions and version.
|
||||||
|
|
||||||
|
## Design constraints
|
||||||
|
|
||||||
|
Hudra should avoid:
|
||||||
|
|
||||||
|
- cloning complex media unnecessarily
|
||||||
|
- fragile LMS-specific DOM assumptions
|
||||||
|
- large content-detection systems
|
||||||
|
- destructive modification of the original lesson
|
||||||
|
|
||||||
|
New media features should be implemented and tested independently.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Hudra Development Decisions
|
||||||
|
|
||||||
|
## Decision 001 — HTML is the source of truth
|
||||||
|
|
||||||
|
Hudra presents the existing lesson rather than requiring teachers to maintain
|
||||||
|
a separate presentation.
|
||||||
|
|
||||||
|
## Decision 002 — No proprietary lesson format
|
||||||
|
|
||||||
|
Teachers should continue authoring normal HTML in QLearn, Canvas or another
|
||||||
|
web-based platform.
|
||||||
|
|
||||||
|
## Decision 003 — Simplicity over automation
|
||||||
|
|
||||||
|
Hudra should avoid complicated content heuristics unless they are reliable and
|
||||||
|
provide a clear classroom benefit.
|
||||||
|
|
||||||
|
## Decision 004 — Stable features are added incrementally
|
||||||
|
|
||||||
|
Media-specific improvements should be developed and tested one at a time.
|
||||||
|
|
||||||
|
## Decision 005 — Hudra 0.9.1 is the stable baseline
|
||||||
|
|
||||||
|
Hudra 0.9.1 is the known-good version and is tagged in Git as v0.9.1.
|
||||||
|
|
||||||
|
## Decision 006 — Public and internal versions may differ
|
||||||
|
|
||||||
|
The visible product branding may show a major/minor version such as Hudra 0.9.
|
||||||
|
|
||||||
|
The browser extension manifest may use a patch version such as 0.9.1 for
|
||||||
|
maintenance and debugging.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Hudra Vision
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Hudra turns an existing HTML lesson into a classroom presentation.
|
||||||
|
|
||||||
|
It is designed primarily for QLearn and Canvas, while remaining compatible
|
||||||
|
with ordinary HTML pages.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Teachers transitioning from OneNote and PowerPoint to QLearn often maintain
|
||||||
|
two versions of the same lesson:
|
||||||
|
|
||||||
|
- the QLearn page used by students
|
||||||
|
- the PowerPoint used for classroom presentation
|
||||||
|
|
||||||
|
This creates duplicated work and version-control problems.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
The HTML lesson should be the single source of truth.
|
||||||
|
|
||||||
|
Teachers should build the lesson once and use that same lesson:
|
||||||
|
|
||||||
|
- in the classroom
|
||||||
|
- online
|
||||||
|
- for students learning from home
|
||||||
|
- as a presentation
|
||||||
|
|
||||||
|
## Core message
|
||||||
|
|
||||||
|
> Build once. Present everywhere.
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
Hudra should be:
|
||||||
|
|
||||||
|
- simple
|
||||||
|
- fast
|
||||||
|
- reliable
|
||||||
|
- lightweight
|
||||||
|
- predictable
|
||||||
|
- easy for teachers to understand
|
||||||
|
|
||||||
|
Hudra should not introduce a proprietary lesson format.
|
||||||
|
|
||||||
|
It should enhance existing lesson content rather than rebuild or convert it.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
+15
-3
@@ -1,20 +1,32 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hudra",
|
"name": "Hudra",
|
||||||
"version": "0.9.1",
|
"version": "0.10.0",
|
||||||
|
"version_name": "0.10 Beta 1",
|
||||||
"description": "Hudra turns Canvas/QLearn pages into responsive presentations with automatic pagination, dual-screen presenter view, audience controls, clicker support and local-only processing.",
|
"description": "Hudra turns Canvas/QLearn pages into responsive presentations with automatic pagination, dual-screen presenter view, audience controls, clicker support and local-only processing.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"scripting",
|
"scripting",
|
||||||
"tabs",
|
"storage",
|
||||||
"system.display"
|
"system.display"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://*.instructure.com/*",
|
"https://*.instructure.com/*",
|
||||||
"https://*.qlearn.eq.edu.au/*"
|
"https://*.qlearn.eq.edu.au/*"
|
||||||
],
|
],
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/HudraLogo16.png",
|
||||||
|
"32": "icons/HudraLogo32.png",
|
||||||
|
"48": "icons/HudraLogo48.png",
|
||||||
|
"128": "icons/HudraLogo128.png"
|
||||||
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_title": "Start or exit Hudra"
|
"default_title": "Start or exit Hudra",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/HudraLogo16.png",
|
||||||
|
"32": "icons/HudraLogo32.png",
|
||||||
|
"48": "icons/HudraLogo48.png"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
(() => {
|
||||||
|
if (window.__hudraPageDetector) return;
|
||||||
|
|
||||||
|
const contentRootSelectors = [
|
||||||
|
"#content .user_content",
|
||||||
|
".show-content .user_content",
|
||||||
|
".wiki-page-content .user_content",
|
||||||
|
".user_content",
|
||||||
|
"[role='main'] .page-content",
|
||||||
|
"[role='main'] .content"
|
||||||
|
];
|
||||||
|
|
||||||
|
const titleSelectors = [
|
||||||
|
"h1.page-title",
|
||||||
|
"h1.wiki-page-title",
|
||||||
|
"#content h1"
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasMeaningfulContent(element) {
|
||||||
|
return Boolean(
|
||||||
|
element?.textContent?.trim() ||
|
||||||
|
element?.querySelector("img, video, iframe, svg, canvas, picture, table")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve() {
|
||||||
|
let contentRoot = null;
|
||||||
|
let contentRootSelector = null;
|
||||||
|
|
||||||
|
for (const selector of contentRootSelectors) {
|
||||||
|
const candidate = document.querySelector(selector);
|
||||||
|
if (candidate) {
|
||||||
|
contentRoot = candidate;
|
||||||
|
contentRootSelector = selector;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentRoot) {
|
||||||
|
return { supported: false, reason: "NO_LESSON_CONTAINER" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageTitleElement =
|
||||||
|
contentRoot.querySelector("h1") ||
|
||||||
|
document.querySelector(titleSelectors.join(", "));
|
||||||
|
|
||||||
|
if (!pageTitleElement) {
|
||||||
|
return { supported: false, reason: "NO_LESSON_TITLE" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMeaningfulContent(contentRoot)) {
|
||||||
|
return { supported: false, reason: "EMPTY_LESSON" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
reason: null,
|
||||||
|
contentRoot,
|
||||||
|
contentRootSelector,
|
||||||
|
pageTitleElement
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspect() {
|
||||||
|
const result = resolve();
|
||||||
|
return {
|
||||||
|
supported: result.supported,
|
||||||
|
reason: result.reason,
|
||||||
|
contentRootSelector: result.contentRootSelector || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotice(message) {
|
||||||
|
document.getElementById("hudra-page-notice")?.remove();
|
||||||
|
|
||||||
|
const notice = document.createElement("div");
|
||||||
|
notice.id = "hudra-page-notice";
|
||||||
|
notice.setAttribute("role", "status");
|
||||||
|
notice.setAttribute("aria-live", "polite");
|
||||||
|
notice.style.cssText = [
|
||||||
|
"position:fixed",
|
||||||
|
"z-index:2147483647",
|
||||||
|
"right:20px",
|
||||||
|
"bottom:20px",
|
||||||
|
"max-width:420px",
|
||||||
|
"box-sizing:border-box",
|
||||||
|
"padding:16px 48px 16px 18px",
|
||||||
|
"border:1px solid #bac4d2",
|
||||||
|
"border-radius:10px",
|
||||||
|
"background:#fff",
|
||||||
|
"color:#1f2937",
|
||||||
|
"box-shadow:0 8px 28px rgba(15,23,42,.2)",
|
||||||
|
"font:16px/1.4 system-ui,sans-serif"
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = message;
|
||||||
|
notice.appendChild(text);
|
||||||
|
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.type = "button";
|
||||||
|
close.setAttribute("aria-label", "Dismiss Hudra message");
|
||||||
|
close.textContent = "×";
|
||||||
|
close.style.cssText = [
|
||||||
|
"position:absolute",
|
||||||
|
"top:8px",
|
||||||
|
"right:10px",
|
||||||
|
"border:0",
|
||||||
|
"background:transparent",
|
||||||
|
"color:#374151",
|
||||||
|
"font:24px/1 system-ui,sans-serif",
|
||||||
|
"cursor:pointer"
|
||||||
|
].join(";");
|
||||||
|
close.addEventListener("click", () => notice.remove());
|
||||||
|
notice.appendChild(close);
|
||||||
|
|
||||||
|
document.body.appendChild(notice);
|
||||||
|
window.setTimeout(() => notice.remove(), 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__hudraPageDetector = { inspect, resolve, showNotice };
|
||||||
|
})();
|
||||||
+12
-47
@@ -10,36 +10,19 @@
|
|||||||
const presenterMode = config.mode || "single";
|
const presenterMode = config.mode || "single";
|
||||||
const sessionId = config.sessionId || "standalone";
|
const sessionId = config.sessionId || "standalone";
|
||||||
|
|
||||||
const contentRoot =
|
const detectedPage = window.__hudraPageDetector?.resolve();
|
||||||
document.querySelector(
|
|
||||||
"#content .user_content, " +
|
|
||||||
".show-content .user_content, " +
|
|
||||||
".wiki-page-content .user_content, " +
|
|
||||||
".user_content"
|
|
||||||
) ||
|
|
||||||
document.querySelector(
|
|
||||||
"[role='main'] .page-content, " +
|
|
||||||
"[role='main'] .content"
|
|
||||||
) ||
|
|
||||||
document.querySelector("#content") ||
|
|
||||||
document.body;
|
|
||||||
|
|
||||||
const pageTitleElement =
|
if (!detectedPage?.supported) {
|
||||||
contentRoot.querySelector("h1") ||
|
window.__hudraPageDetector?.showNotice(
|
||||||
document.querySelector(
|
"Hudra couldn’t start because this page does not contain a supported lesson."
|
||||||
"#content h1, .page-title, .wiki-page-title, h1.page-title"
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contentRoot, pageTitleElement } = detectedPage;
|
||||||
|
|
||||||
let pageTitle = pageTitleElement?.textContent?.trim() || "";
|
let pageTitle = pageTitleElement?.textContent?.trim() || "";
|
||||||
|
|
||||||
if (!pageTitle) {
|
|
||||||
pageTitle = document.title.replace(/\s*:\s*[^:]+$/, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pageTitle) {
|
|
||||||
pageTitle = "Lesson";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHidden(element) {
|
function isHidden(element) {
|
||||||
if (!element || element.hidden) return true;
|
if (!element || element.hidden) return true;
|
||||||
|
|
||||||
@@ -101,11 +84,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!slideDefinitions.length) {
|
|
||||||
alert("No presentable page content was found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const presentationLayer = document.createElement("div");
|
const presentationLayer = document.createElement("div");
|
||||||
presentationLayer.id = "qlearn-presenter-layer";
|
presentationLayer.id = "qlearn-presenter-layer";
|
||||||
document.body.appendChild(presentationLayer);
|
document.body.appendChild(presentationLayer);
|
||||||
@@ -699,20 +677,6 @@
|
|||||||
return heading;
|
return heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitParagraph(paragraph) {
|
|
||||||
const text = paragraph.textContent.trim();
|
|
||||||
const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text];
|
|
||||||
if (sentences.length < 2) return [paragraph.cloneNode(true)];
|
|
||||||
const midpoint = Math.ceil(sentences.length / 2);
|
|
||||||
return [sentences.slice(0, midpoint), sentences.slice(midpoint)]
|
|
||||||
.filter((part) => part.length)
|
|
||||||
.map((part) => {
|
|
||||||
const copy = paragraph.cloneNode(false);
|
|
||||||
copy.textContent = part.join(" ").trim();
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitList(list) {
|
function splitList(list) {
|
||||||
const items = [...list.children].filter((item) => item.tagName === "LI");
|
const items = [...list.children].filter((item) => item.tagName === "LI");
|
||||||
if (items.length < 2) return [list.cloneNode(true)];
|
if (items.length < 2) return [list.cloneNode(true)];
|
||||||
@@ -726,9 +690,6 @@
|
|||||||
|
|
||||||
function divideOversizedBlock(block) {
|
function divideOversizedBlock(block) {
|
||||||
if (block.matches("ul, ol")) return splitList(block);
|
if (block.matches("ul, ol")) return splitList(block);
|
||||||
if (block.matches("p") && block.textContent.trim().length > 220) {
|
|
||||||
return splitParagraph(block);
|
|
||||||
}
|
|
||||||
return [block.cloneNode(true)];
|
return [block.cloneNode(true)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,6 +1332,9 @@
|
|||||||
},
|
},
|
||||||
getSlideCount() {
|
getSlideCount() {
|
||||||
return slideWrappers.length;
|
return slideWrappers.length;
|
||||||
|
},
|
||||||
|
reportState() {
|
||||||
|
sendPresenterState();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1399,6 +1363,7 @@
|
|||||||
if (command === "scrollBy") window.__qlearnPresenter.scrollBy(message.value);
|
if (command === "scrollBy") window.__qlearnPresenter.scrollBy(message.value);
|
||||||
if (command === "resetView") window.__qlearnPresenter.resetView();
|
if (command === "resetView") window.__qlearnPresenter.resetView();
|
||||||
if (command === "goTo") window.__qlearnPresenter.goTo(message.value);
|
if (command === "goTo") window.__qlearnPresenter.goTo(message.value);
|
||||||
|
if (command === "reportState") window.__qlearnPresenter.reportState();
|
||||||
if (command === "exit") window.__qlearnPresenter.exit(false);
|
if (command === "exit") window.__qlearnPresenter.exit(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
# Hudra release builder
|
||||||
|
# Reads the version from manifest.json, creates a clean release folder,
|
||||||
|
# and produces a Chrome Web Store-ready ZIP.
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Project locations
|
||||||
|
$ProjectRoot = $PSScriptRoot
|
||||||
|
$ManifestPath = Join-Path $ProjectRoot "manifest.json"
|
||||||
|
$ReleaseRoot = Join-Path $ProjectRoot "release"
|
||||||
|
|
||||||
|
# Check that manifest.json exists
|
||||||
|
if (-not (Test-Path $ManifestPath)) {
|
||||||
|
Write-Host "ERROR: manifest.json was not found." -ForegroundColor Red
|
||||||
|
Write-Host "Place release.ps1 in the same folder as manifest.json."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read extension name and version
|
||||||
|
try {
|
||||||
|
$Manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR: manifest.json is not valid JSON." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$ExtensionName = $Manifest.name
|
||||||
|
$Version = $Manifest.version
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ExtensionName)) {
|
||||||
|
$ExtensionName = "Hudra"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
Write-Host "ERROR: No version was found in manifest.json." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$SafeName = $ExtensionName -replace '[^a-zA-Z0-9_-]', '-'
|
||||||
|
$ReleaseName = "$SafeName-v$Version"
|
||||||
|
|
||||||
|
$StagingFolder = Join-Path $ReleaseRoot $ReleaseName
|
||||||
|
$ZipPath = Join-Path $ReleaseRoot "$ReleaseName.zip"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Building $ExtensionName $Version..." -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Only files required by the browser extension at runtime belong in the ZIP.
|
||||||
|
# Everything else is excluded by default, including release output, previous
|
||||||
|
# releases, scripts, repository metadata, documentation and development files.
|
||||||
|
$IncludedItems = @(
|
||||||
|
"manifest.json",
|
||||||
|
"background.js",
|
||||||
|
"page-detection.js",
|
||||||
|
"presenter.js",
|
||||||
|
"controls.html",
|
||||||
|
"controls.css",
|
||||||
|
"controls.js",
|
||||||
|
"icons"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recreate the staging folder
|
||||||
|
if (Test-Path $StagingFolder) {
|
||||||
|
Remove-Item $StagingFolder -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $StagingFolder -Force | Out-Null
|
||||||
|
|
||||||
|
# Copy only the allowlisted runtime files into the clean staging folder
|
||||||
|
$IncludedItems | ForEach-Object {
|
||||||
|
$SourcePath = Join-Path $ProjectRoot $_
|
||||||
|
|
||||||
|
if (-not (Test-Path $SourcePath)) {
|
||||||
|
Write-Host "ERROR: Required runtime item was not found: $_" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item `
|
||||||
|
-Path $SourcePath `
|
||||||
|
-Destination $StagingFolder `
|
||||||
|
-Recurse `
|
||||||
|
-Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Confirm that the copied release contains manifest.json
|
||||||
|
$CopiedManifest = Join-Path $StagingFolder "manifest.json"
|
||||||
|
|
||||||
|
if (-not (Test-Path $CopiedManifest)) {
|
||||||
|
Write-Host "ERROR: manifest.json was not copied into the release folder." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove any previous ZIP for this version
|
||||||
|
if (Test-Path $ZipPath) {
|
||||||
|
Remove-Item $ZipPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# ZIP the contents, ensuring manifest.json is at the ZIP root
|
||||||
|
$FilesToZip = Join-Path $StagingFolder "*"
|
||||||
|
Compress-Archive `
|
||||||
|
-Path $FilesToZip `
|
||||||
|
-DestinationPath $ZipPath `
|
||||||
|
-CompressionLevel Optimal `
|
||||||
|
-Force
|
||||||
|
|
||||||
|
# Verify the ZIP was created
|
||||||
|
if (-not (Test-Path $ZipPath)) {
|
||||||
|
Write-Host "ERROR: The release ZIP could not be created." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$ZipSize = [math]::Round((Get-Item $ZipPath).Length / 1KB, 1)
|
||||||
|
|
||||||
|
Write-Host "Release created successfully!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Release folder:"
|
||||||
|
Write-Host " $StagingFolder" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "ZIP file:"
|
||||||
|
Write-Host " $ZipPath" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "ZIP size: $ZipSize KB"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Ready for Gitea and the Chrome Web Store." -ForegroundColor Green
|
||||||
Reference in New Issue
Block a user