diff --git a/api/Project.js b/api/Project.js
index a7c9738a..8a0ec6a8 100644
--- a/api/Project.js
+++ b/api/Project.js
@@ -258,7 +258,7 @@ export default class Project {
return response
} catch (error) {
console.error("Error updating roles:", error)
- alert("Failed to update roles. Please try again.")
+ eventDispatcher.dispatch('tpen-alert', { message: "Failed to update roles. Please try again." })
}
}
diff --git a/components/annotorious-annotator/line-parser.js b/components/annotorious-annotator/line-parser.js
index 79d39bf0..1df9e3a1 100644
--- a/components/annotorious-annotator/line-parser.js
+++ b/components/annotorious-annotator/line-parser.js
@@ -20,6 +20,7 @@ import { CleanupRegistry } from '../../utilities/CleanupRegistry.js'
import { onProjectReady } from '../../utilities/projectReady.js'
import vault from '../../js/vault.js'
import '../page-selector/index.js'
+import { confirmAction } from '../../utilities/confirmAction.js'
class AnnotoriousAnnotator extends HTMLElement {
#osd
@@ -466,7 +467,7 @@ class AnnotoriousAnnotator extends HTMLElement {
}, 500)
this.#pendingTimeouts.add(timeoutId)
})
- this.renderCleanup.onElement(deleteAllBtn, "click", async (e) => await this.deleteAllAnnotations(e))
+ this.renderCleanup.onElement(deleteAllBtn, "click", (e) => this.deleteAllAnnotations(e))
this.renderCleanup.onWindow('beforeunload', (ev) => {
if (this.#resolvedAnnotationPage?.$isDirty) {
ev.preventDefault()
@@ -715,8 +716,14 @@ class AnnotoriousAnnotator extends HTMLElement {
srcDown: "../interfaces/annotator/images/transcribe.png",
onClick: (e) => {
if (this.#resolvedAnnotationPage?.$isDirty) {
- if (confirm("Stop identifying lines and go transcribe? Unsaved changes will be lost."))
- location.href = `/transcribe?projectID=${TPEN.activeProject._id}&pageID=${this.#annotationPageID}`
+ confirmAction(
+ "Stop identifying lines and go transcribe? Unsaved changes will be lost.",
+ () => {
+ location.href = `/transcribe?projectID=${TPEN.activeProject._id}&pageID=${this.#annotationPageID}`
+ },
+ null,
+ { positiveButtonText: "Go Transcribe", negativeButtonText: "Keep Editing" }
+ )
}
else {
location.href = `/transcribe?projectID=${TPEN.activeProject._id}&pageID=${this.#annotationPageID}`
@@ -808,13 +815,15 @@ class AnnotoriousAnnotator extends HTMLElement {
_this.#pendingTimeouts.delete(timeoutId)
// Timeout required in order to allow the click-and-focus native functionality to complete.
// Also stops the goofy UX for naturally slow clickers.
- let c = confirm("Are you sure you want to remove this?")
- if (c) {
- _this.#annotoriousInstance.removeAnnotation(originalAnnotation)
- _this.#resolvedAnnotationPage.$isDirty = true
- } else {
- _this.#annotoriousInstance.cancelSelected()
- }
+ confirmAction(
+ "Are you sure you want to remove this?",
+ () => {
+ _this.#annotoriousInstance.removeAnnotation(originalAnnotation)
+ _this.#resolvedAnnotationPage.$isDirty = true
+ },
+ () => _this.#annotoriousInstance.cancelSelected(),
+ { positiveButtonText: "Delete", negativeButtonText: "Cancel" }
+ )
}, 500)
_this.#pendingTimeouts.add(timeoutId)
}
@@ -1139,30 +1148,37 @@ class AnnotoriousAnnotator extends HTMLElement {
return this.#modifiedAnnotationPage
}
+
/**
* Use Annotorious to delete all known Annotations
* https://annotorious.dev/api-reference/openseadragon-annotator/#clearannotations
*/
- async deleteAllAnnotations() {
- if (!confirm('This will remove all Annotations and will take effect immediately. This action cannot be undone.')) return
- const deleteAllBtn = this.shadowRoot.getElementById("deleteAllBtn")
- deleteAllBtn.setAttribute("disabled", "true")
- deleteAllBtn.textContent = "deleting. please wait..."
- this.#annotoriousInstance.clearAnnotations()
- this.#resolvedAnnotationPage.$isDirty = true
- try {
- await this.saveAnnotations()
- await this.clearColumnsServerSide()
- } catch (err) {
- console.error("Could not delete all annotations.", err)
- TPEN.eventDispatcher.dispatch("tpen-toast", {
- message: "Could not delete all annotations.",
- status: "error"
- })
- } finally {
- deleteAllBtn.removeAttribute("disabled")
- deleteAllBtn.textContent = "Delete All Annotations"
- }
+ deleteAllAnnotations() {
+ confirmAction(
+ "This will remove all Annotations and will take effect immediately. This action cannot be undone.",
+ () => {
+ const deleteAllBtn = this.shadowRoot.getElementById("deleteAllBtn")
+ deleteAllBtn.setAttribute("disabled", "true")
+ deleteAllBtn.textContent = "deleting. please wait..."
+ this.#annotoriousInstance.clearAnnotations()
+ this.#resolvedAnnotationPage.$isDirty = true
+ this.saveAnnotations()
+ .then(() => this.clearColumnsServerSide())
+ .catch(err => {
+ console.error("Could not delete all annotations.", err)
+ TPEN.eventDispatcher.dispatch("tpen-toast", {
+ message: "Could not delete all annotations.",
+ status: "error"
+ })
+ })
+ .finally(() => {
+ deleteAllBtn.removeAttribute("disabled")
+ deleteAllBtn.textContent = "Delete All Annotations"
+ })
+ },
+ null,
+ { positiveButtonText: "Delete All", negativeButtonText: "Cancel" }
+ )
}
/**
diff --git a/components/annotorious-annotator/plain.js b/components/annotorious-annotator/plain.js
index b73b95f8..ebf2bc35 100644
--- a/components/annotorious-annotator/plain.js
+++ b/components/annotorious-annotator/plain.js
@@ -14,6 +14,7 @@
import TPEN from '../../api/TPEN.js'
import User from '../../api/User.js'
import { CleanupRegistry } from '../../utilities/CleanupRegistry.js'
+import { confirmAction } from '../../utilities/confirmAction.js'
class AnnotoriousAnnotator extends HTMLElement {
#osd
@@ -126,7 +127,7 @@ class AnnotoriousAnnotator extends HTMLElement {
}
this.#annotationPageURI = TPEN.screen.pageInQuery
if(!this.#annotationPageURI) {
- alert("You must provide a ?pageID=theid in the URL. The value should be the URI of an existing AnnotationPage.")
+ TPEN.eventDispatcher.dispatch('tpen-alert', { message: "You must provide a ?pageID=theid in the URL. The value should be the URI of an existing AnnotationPage." })
return
}
this.setAttribute("annotationpage", this.#annotationPageURI)
@@ -257,13 +258,12 @@ class AnnotoriousAnnotator extends HTMLElement {
_this.#eraseConfirmTimeout = null
// Timeout required in order to allow the click-and-focus native functionality to complete.
// Also stops the goofy UX for naturally slow clickers.
- let c = confirm("Are you sure you want to remove this?")
- if(c) {
- _this.#annotoriousInstance.removeAnnotation(annotation)
- }
- else{
- _this.#annotoriousInstance.cancelSelected()
- }
+ confirmAction(
+ "Are you sure you want to remove this?",
+ () => _this.#annotoriousInstance.removeAnnotation(annotation),
+ () => _this.#annotoriousInstance.cancelSelected(),
+ { positiveButtonText: "Delete", negativeButtonText: "Cancel" }
+ )
}, 500)
}
})
diff --git a/components/decline-project/index.js b/components/decline-project/index.js
index 6596fa62..9ab96f03 100644
--- a/components/decline-project/index.js
+++ b/components/decline-project/index.js
@@ -1,5 +1,6 @@
import TPEN from "../../api/TPEN.js"
import { CleanupRegistry } from '../../utilities/CleanupRegistry.js'
+import { confirmAction } from '../../utilities/confirmAction.js'
/**
* DeclineInvite - Allows invited users to decline a project invitation.
@@ -87,45 +88,51 @@ class DeclineInvite extends HTMLElement {
}
declineInvitation(collaboratorID, projectID) {
- if (!confirm("You are declining a chance to be a part of this TPEN3 project.")) return
- let redir = true
- const declineBtn = this.shadowRoot.getElementById("declineBtn")
- declineBtn.setAttribute("disabled", "disabled")
- declineBtn.setAttribute("value", "declining...")
- fetch(`${TPEN.servicesURL}/project/${this.#project}/collaborator/${this.#user}/decline`)
- .then(resp => {
- if (resp.ok) return resp.text()
- redir = false
- return resp.json()
- })
- .then(message => {
- let userMessage = (typeof message === "string") ? message : message?.message
- if (redir) {
- this.shadowRoot.innerHTML = `
-
${userMessage}
- `
- setTimeout(() => {
- document.location.href = TPEN.TPEN3URL
- }, 3000)
- return
- }
- this.shadowRoot.innerHTML = `
- There was an error declining the invitation.
-
- The message below has more details.
- Refresh the page to try again or contact the TPEN3 Administrators.
-
- ${userMessage}
- `
- })
- .catch(err => {
- this.shadowRoot.innerHTML = `
-
- There was an error declining the invitation. Refresh the page to try again
- or contact the TPEN3 Administrators.
-
- `
- })
+ confirmAction(
+ "You are declining a chance to be a part of this TPEN3 project.",
+ () => {
+ let redir = true
+ const declineBtn = this.shadowRoot.getElementById("declineBtn")
+ declineBtn.setAttribute("disabled", "disabled")
+ declineBtn.setAttribute("value", "declining...")
+ fetch(`${TPEN.servicesURL}/project/${this.#project}/collaborator/${this.#user}/decline`)
+ .then(resp => {
+ if (resp.ok) return resp.text()
+ redir = false
+ return resp.json()
+ })
+ .then(message => {
+ let userMessage = (typeof message === "string") ? message : message?.message
+ if (redir) {
+ this.shadowRoot.innerHTML = `
+ ${userMessage}
+ `
+ setTimeout(() => {
+ document.location.href = TPEN.TPEN3URL
+ }, 3000)
+ return
+ }
+ this.shadowRoot.innerHTML = `
+ There was an error declining the invitation.
+
+ The message below has more details.
+ Refresh the page to try again or contact the TPEN3 Administrators.
+
+ ${userMessage}
+ `
+ })
+ .catch(err => {
+ this.shadowRoot.innerHTML = `
+
+ There was an error declining the invitation. Refresh the page to try again
+ or contact the TPEN3 Administrators.
+
+ `
+ })
+ },
+ null,
+ { positiveButtonText: "Decline", negativeButtonText: "Cancel" }
+ )
}
}
diff --git a/components/gui/alert/Alert.js b/components/gui/alert/Alert.js
index 6724402d..1779de14 100644
--- a/components/gui/alert/Alert.js
+++ b/components/gui/alert/Alert.js
@@ -1,5 +1,6 @@
import { eventDispatcher } from '../../../api/events.js'
import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js'
+import { openModalHost } from '../../../utilities/modalHost.js'
/**
* Alert - A modal alert dialog that requires user acknowledgement.
@@ -25,11 +26,11 @@ class Alert extends HTMLElement {
* Have them appear with a dropdown effect.
*/
show() {
- this.closest(".alert-area").style.display = "grid"
+ const alertArea = this.closest('.alert-area')
+ openModalHost(alertArea)
const showTimer = setTimeout(() => {
- this.closest(".alert-area").classList.add("show")
+ alertArea?.classList.add('show')
this.classList.add('show')
- document.querySelector("body").style.overflow = "hidden"
}, 1)
this.cleanup.add(() => clearTimeout(showTimer))
eventDispatcher.dispatch("tpen-alert-activated")
@@ -41,8 +42,7 @@ class Alert extends HTMLElement {
*/
dismiss() {
this.classList.remove('show')
- this.closest(".alert-area")?.classList.remove("show")
- document.querySelector("body").style.overflow = "auto"
+ this.closest('.alert-area')?.classList.remove('show')
const removeTimer = setTimeout(() => {
this.remove()
}, 500)
diff --git a/components/gui/alert/AlertContainer.js b/components/gui/alert/AlertContainer.js
index c52dd788..acd07083 100644
--- a/components/gui/alert/AlertContainer.js
+++ b/components/gui/alert/AlertContainer.js
@@ -1,6 +1,8 @@
import './Alert.js'
import { eventDispatcher } from '../../../api/events.js'
import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js'
+import { closeModalHostWhenEmpty } from '../../../utilities/modalHost.js'
+import { sharedModalStyles } from '../modal.css.js'
/**
* AlertContainer - Global container for displaying alert dialogs.
@@ -9,6 +11,8 @@ import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js'
*/
class AlertContainer extends HTMLElement {
#screenLockingSection
+ #alertQueue = []
+ #keydownHandler
/** @type {CleanupRegistry} Registry for cleanup handlers */
cleanup = new CleanupRegistry()
@@ -29,6 +33,7 @@ class AlertContainer extends HTMLElement {
/**
* Add the alert dialogue with acknowledgement button.
+ * Alerts are queued - only one is shown at a time.
*
* @params message {String} A message to show in the alert.
* @params buttonText {String} The textual label for the acknowledgement button.
@@ -36,117 +41,119 @@ class AlertContainer extends HTMLElement {
addAlert(message, buttonText) {
if (!message || typeof message !== 'string') return
if (!buttonText || typeof buttonText !== 'string') buttonText = 'OK'
- const { matches: motionOK } = window.matchMedia('(prefers-reduced-motion: no-preference)')
+
const alertElem = document.createElement('tpen-alert')
const okButton = document.createElement('button')
const buttonContainer = document.createElement('div')
- buttonContainer.classList.add("button-container")
+ buttonContainer.classList.add('button-container')
okButton.textContent = buttonText
alertElem.textContent = message
- const handleOk = (e) => {
- alertElem.dismiss()
- }
+
+ const handleOk = () => this.dismissCurrent()
okButton.addEventListener('click', handleOk)
buttonContainer.appendChild(okButton)
alertElem.appendChild(buttonContainer)
- this.#screenLockingSection.appendChild(alertElem)
- alertElem.show()
+
+ const alertEntry = { elem: alertElem, button: okButton }
+ this.#alertQueue.push(alertEntry)
+
+ // If this is the first alert, show it immediately
+ if (this.#alertQueue.length === 1) {
+ this.#showCurrent()
+ }
+ }
+
+ /**
+ * Add a pre-built custom alert element to the global alert host.
+ * @param {HTMLElement} alertElem - Custom alert element, typically tpen-alert.
+ */
+ addCustomAlert(alertElem) {
+ if (!alertElem) return
+
+ const alertEntry = { elem: alertElem, button: alertElem.querySelector('button') }
+ this.#alertQueue.push(alertEntry)
+
+ if (this.#alertQueue.length === 1) {
+ this.#showCurrent()
+ }
+ }
+
+ /**
+ * Display the current (first) alert in the queue and set focus.
+ */
+ #showCurrent() {
+ if (this.#alertQueue.length === 0) return
+
+ const current = this.#alertQueue[0]
+ this.#screenLockingSection.appendChild(current.elem)
+ current.elem.show?.()
+
+ // Set focus to button
+ current.button?.focus?.()
+
+ // Use native dialog cancel event for Escape key (cleaner than document keydown)
+ const cancelHandler = (e) => {
+ e.preventDefault()
+ this.dismissCurrent()
+ }
+ this.#screenLockingSection.addEventListener('cancel', cancelHandler)
+
+ // Attach keyboard handler for Enter key
+ if (this.#keydownHandler) {
+ document.removeEventListener('keydown', this.#keydownHandler)
+ }
+ this.#keydownHandler = (e) => this.#handleKeydown(e)
+ document.addEventListener('keydown', this.#keydownHandler)
+
+ // Store cancel handler so it can be removed on dismiss
+ current.cancelHandler = cancelHandler
+ }
+
+ /**
+ * Handle keyboard events for alerts.
+ * - Enter: dismiss current alert
+ * (Escape is handled via native dialog cancel event)
+ */
+ #handleKeydown(e) {
+ const current = this.#alertQueue[0]
+ if (!current) return
+ }
+
+ /**
+ * Dismiss the current alert and show the next one in queue.
+ */
+ dismissCurrent() {
+ if (this.#alertQueue.length === 0) return
+
+ const current = this.#alertQueue.shift()
+
+ // Remove the cancel event listener
+ if (current.cancelHandler) {
+ this.#screenLockingSection.removeEventListener('cancel', current.cancelHandler)
+ }
+
+ current.elem.dismiss()
+
+ // Remove keyboard handler
+ if (this.#keydownHandler) {
+ document.removeEventListener('keydown', this.#keydownHandler)
+ this.#keydownHandler = null
+ }
+
+ // Show next alert in queue if available
+ if (this.#alertQueue.length > 0) {
+ setTimeout(() => this.#showCurrent(), 600) // Wait for dismiss animation
+ return
+ }
+
+ closeModalHostWhenEmpty(this.#screenLockingSection, 'tpen-alert')
}
render() {
const style = document.createElement('style')
- // We copied the :root rules from /components/gui/site/index.css. Importing it was too much.
- style.textContent = `
- :host {
- --primary-color: hsl(186, 84%, 40%);
- --primary-light: hsl(186, 84%, 60%);
- --light-color : hsl(186, 84%, 90%);
- --dark : #2d2d2d;
- --white : hsl(0, 0%, 100%);
- --gray : hsl(0, 0%, 60%);
- --light-gray : hsl(0, 0%, 90%);
- }
- .alert-area {
- position: fixed;
- display: grid;
- z-index: 16;
- inset-block-start: 0;
- inset-inline: 0;
- justify-items: center;
- justify-content: center;
- height: 0vh;
- background-color: rgba(0,0,0,0.7);
- opacity: 0;
- transition: all 0.5s ease-in-out;
- }
- .alert-area.show {
- opacity: 1;
- height: 100vh;
- }
- tpen-alert {
- z-index: 16;
- display: block;
- position: relative;
- background-color: #333;
- color: #fff;
- padding: 10px 20px;
- border-radius: 5px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
- opacity: 0.0;
- height: fit-content;
- min-width: 25vw;
- max-width: 35vw;
- transition: all 0.3s ease-in-out;
- font-size: 14pt;
- }
- tpen-alert a {
- color: var(--primary-color);
- text-decoration: underline;
- }
- .alert-area tpen-alert {
- top: 0px;
- right: 0px;
- }
- @media (prefers-reduced-motion) {
- .alert-area tpen-alert {
- opacity: 1.0;
- height: fit-content;
- top: 5vh;
- }
- }
- .alert-area .button-container {
- position: relative;
- display: block;
- text-align: right;
- margin-top: 1vh;
- }
- .alert-area tpen-alert.show {
- opacity: 1.0;
- height: fit-content;
- top: 5vh;
- }
- .alert-area button {
- position: relative;
- display: inline-block;
- cursor: pointer;
- border: none;
- padding: 10px 20px;
- background-color: var(--primary-color);
- outline: var(--primary-light) 1px solid;
- outline-offset: -3.5px;
- color: var(--white);
- border-radius: 5px;
- transition: all 0.3s;
- font-size: 12pt;
- }
- .alert-area button:hover {
- background-color: var(--primary-light);
- outline: var(--primary-color) 1px solid;
- outline-offset: -1.5px;
- }
- `
+ style.textContent = sharedModalStyles
// This section will take over the screen and lock down screen interaction. It lives at the top of the viewport.
- const screenLockingSection = document.createElement('section')
+ const screenLockingSection = document.createElement('dialog')
screenLockingSection.classList.add('alert-area')
this.shadowRoot.innerHTML = ''
@@ -159,5 +166,5 @@ class AlertContainer extends HTMLElement {
// Guard against duplicate registration when module is loaded via different URL paths
if (!customElements.get('tpen-alert-container')) {
customElements.define('tpen-alert-container', AlertContainer)
- document?.body.after(new AlertContainer())
+ document.body.appendChild(new AlertContainer())
}
diff --git a/components/gui/confirm/Confirm.js b/components/gui/confirm/Confirm.js
index 5bb7b58b..a5f1a984 100644
--- a/components/gui/confirm/Confirm.js
+++ b/components/gui/confirm/Confirm.js
@@ -1,5 +1,6 @@
import { eventDispatcher } from '../../../api/events.js'
import { CleanupRegistry } from '../../../utilities/CleanupRegistry.js'
+import { openModalHost } from '../../../utilities/modalHost.js'
/**
* Confirm - A modal confirmation dialog with positive/negative options.
@@ -14,7 +15,7 @@ class Confirm extends HTMLElement {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = `
-