diff --git a/app/components/Article/Contents.tsx b/app/components/Article/Contents.tsx
index 30e3ef8f..8fe0ef56 100644
--- a/app/components/Article/Contents.tsx
+++ b/app/components/Article/Contents.tsx
@@ -25,20 +25,75 @@ const footnoteHTML = (el: HTMLDivElement, e: HTMLAnchorElement): string | null =
return elem.firstElementChild?.innerHTML || null
}
-const addPopup = (e: HTMLElement, id: string, contents: string, mobile: boolean): HTMLElement => {
+// TODO: Potential memory leak - event listeners are not cleaned up.
+// Consider refactoring to React components with proper cleanup in useEffect.
+const addPopup = (
+ e: HTMLElement,
+ id: string,
+ contents: string,
+ mobile: boolean,
+ layout?: string
+): void => {
const preexisting = document.getElementById(id)
- if (preexisting) return preexisting
+ if (preexisting) return
const popup = document.createElement('div')
popup.className = 'link-popup bordered small background'
+ if (layout) {
+ popup.classList.add(`${layout}-image-layout`)
+ }
popup.innerHTML = contents
popup.id = id
e.insertAdjacentElement('afterend', popup)
+ // Timeout management for show/hide delays
+ let showTimeout: number | null = null
+ let hideTimeout: number | null = null
+
+ const clearTimeouts = () => {
+ if (showTimeout) {
+ clearTimeout(showTimeout)
+ showTimeout = null
+ }
+ if (hideTimeout) {
+ clearTimeout(hideTimeout)
+ hideTimeout = null
+ }
+ }
+
const toggle = () => popup.classList.toggle('shown')
- const show = () => popup.classList.add('shown')
- const hide = () => popup.classList.remove('shown')
+
+ const show = () => {
+ clearTimeouts()
+ showTimeout = window.setTimeout(() => {
+ // Position popup above if it would not fit in viewport
+ const elementRect = e.getBoundingClientRect()
+ const viewportHeight = window.innerHeight
+
+ // Check if popup would fit below the element
+ const estimatedPopupHeight = 250
+ const wouldFitBelow = elementRect.bottom + estimatedPopupHeight <= viewportHeight - 50
+
+ // If it doesn't fit below, position it above
+ if (!wouldFitBelow) {
+ popup.classList.add('position-above')
+ } else {
+ popup.classList.remove('position-above')
+ }
+
+ popup.classList.add('shown')
+ showTimeout = null
+ }, 500)
+ }
+
+ const hide = () => {
+ clearTimeouts()
+ hideTimeout = window.setTimeout(() => {
+ popup.classList.remove('shown')
+ hideTimeout = null
+ }, 100)
+ }
if (!mobile) {
e.addEventListener('mouseover', show)
@@ -50,8 +105,6 @@ const addPopup = (e: HTMLElement, id: string, contents: string, mobile: boolean)
e.addEventListener('click', togglePopup(toggle, e))
popup.children[0].addEventListener('click', (e) => e.stopPropagation())
}
-
- return popup
}
/*
@@ -193,23 +246,56 @@ const insertGlossary = (
linkedQuestionIsViewable &&
`View full definition`
const isGoogleDrive = entry.image && entry.image.includes('drive.google.com/file/d/')
- const image = entry.image
+ const imageHtml = entry.image
? isGoogleDrive
? ``
: `
`
: ''
+
+ // Determine layout based on pre-computed dimensions
+ let layout: 'right' | 'top' = 'right'
+ if (entry.image && !isGoogleDrive && entry.imageDimensions) {
+ const aspectRatio = entry.imageDimensions.width / entry.imageDimensions.height
+ layout = aspectRatio > 2.0 ? 'top' : 'right'
+ }
+
+ // Create popup with pre-calculated layout
+ const popupContent = imageHtml
+ ? layout === 'top'
+ ? `
`
+ : ``
+ : ``
+
addPopup(
e as HTMLSpanElement,
`glossary-${entry.term}`,
- ``,
- mobile
+ popupContent,
+ mobile,
+ imageHtml ? layout : undefined
)
})
diff --git a/app/components/Article/article.css b/app/components/Article/article.css
index 212bbb0a..4924d2ce 100644
--- a/app/components/Article/article.css
+++ b/app/components/Article/article.css
@@ -155,7 +155,86 @@ article .glossary-entry {
article .link-popup {
visibility: hidden;
z-index: 4;
- width: 512px;
+ width: auto;
+ min-width: 350px;
+}
+
+/* Right image layout - image on the right side */
+article .glossary-popup.right-image-layout {
+ display: flex;
+ flex-direction: row;
+ min-width: 530px; /* Minimum width to contain text + image (350 + 180) */
+ width: fit-content;
+}
+
+/* Top image layout - image on top */
+article .glossary-popup.top-image-layout {
+ display: flex;
+ flex-direction: column;
+}
+
+/* Right image layout - image container */
+article .glossary-popup.right-image-layout .image-container {
+ flex: 0 0 180px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-8);
+ overflow: hidden;
+}
+
+/* Top image layout - image container */
+article .glossary-popup.top-image-layout .image-container {
+ flex: 0 0 100px; /* Fixed height for top images */
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-8) var(--spacing-16);
+}
+
+/* Right image layout - text content */
+article .glossary-popup.right-image-layout .text-content {
+ flex: 0 0 350px;
+ padding: var(--spacing-24);
+}
+
+/* Top image layout - text content */
+article .glossary-popup.top-image-layout .text-content {
+ flex: 1;
+ padding: var(--spacing-24);
+}
+
+/* Image styling for all glossary popup images */
+article .glossary-popup .image-container img {
+ border-radius: var(--border-radius);
+}
+
+/* Right image layout - resize images to fit in fixed container */
+article .glossary-popup.right-image-layout .image-container img {
+ width: auto;
+ height: auto;
+ max-width: 164px;
+ max-height: 250px;
+}
+
+/* Top image layout - resize images to fit in full width container */
+article .glossary-popup.top-image-layout .image-container img {
+ max-height: 100px;
+ width: auto;
+ max-width: 100%;
+ object-fit: scale-down;
+}
+
+/* Text content for popups without images */
+article .glossary-popup .text-content.full-width {
+ padding: var(--spacing-24);
+}
+
+/* Remove margins from buttons in glossary popups */
+article .glossary-popup .button {
+ margin: 0;
+ margin-top: var(--spacing-8);
}
article .link-popup .footnote {
@@ -179,6 +258,7 @@ article .footnote-ref {
article .glossary-popup {
border-radius: var(--border-radius);
overflow: hidden;
+
height: 304px;
/* Reset any inherited formatting from the glossary term */
font-style: normal !important;
@@ -193,15 +273,15 @@ article .link-popup .glossary-popup > .contents {
padding: var(--spacing-24) var(--spacing-40) var(--spacing-24);
}
article .definition {
- height: 140px;
+ max-height: 300px;
display: -webkit-box;
/* These are webkit specific things, so might not work in all browsers (firefox handles them fine) */
-webkit-box-orient: vertical;
-webkit-line-clamp: 5; /* Number of lines you want to display */
overflow: hidden;
text-overflow: ellipsis;
- margin-bottom: var(--spacing-32);
}
+
article .link-popup .glossary-popup > img {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
@@ -289,14 +369,33 @@ article blockquote + p {
@media (min-width: 1136px) {
article .link-popup {
position: absolute;
- max-width: 480px;
+ max-width: 400px;
display: inline-block;
z-index: 2;
left: calc(50% - 200px);
transform: translateY(var(--spacing-40));
transition:
- visibility 0s 300ms,
- opacity cubic-bezier(1, 0, 1, 1) 300ms;
+ visibility 0s 100ms,
+ opacity cubic-bezier(1, 0, 1, 1) 100ms;
+ }
+
+ /* Right-image layout popups - wider to accommodate image */
+ article .link-popup.right-image-layout {
+ max-width: 530px; /* 350px text + 180px image */
+ left: calc(50% - 265px);
+ transform: translateY(var(--spacing-40));
+ }
+
+ /* Top-image layout popups - standard width */
+ article .link-popup.top-image-layout {
+ max-width: 400px; /* Same as text-only popups */
+ left: calc(50% - 200px);
+ transform: translateY(var(--spacing-40));
+ }
+
+ /* Position above adjustments */
+ article .link-popup.position-above {
+ transform: translateY(-100%) translateY(-40px);
}
}
@@ -373,50 +472,3 @@ article .contents tbody tr:nth-child(odd) {
article .contents tbody tr:nth-child(even) {
background-color: var(--background);
}
-
-/* Share dropdown menu */
-.share-dropdown {
- position: absolute;
- top: 100%;
- right: 0;
- margin-top: var(--spacing-8);
- background-color: var(--card-background);
- border: 1px solid var(--border);
- border-radius: var(--spacing-8);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- z-index: 1000;
- min-width: 160px;
- overflow: hidden;
-}
-
-html.dark .share-dropdown {
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
-}
-
-.share-menu-item {
- display: flex;
- align-items: center;
- gap: var(--spacing-12);
- padding: var(--spacing-12) var(--spacing-16);
- color: var(--text);
- text-decoration: none;
- font-size: 14px;
- border: none;
- background: none;
- width: 100%;
- text-align: left;
- cursor: pointer;
- font-family: inherit;
-}
-
-.share-menu-item:hover {
- background-color: var(--hover-background);
-}
-
-.share-menu-item-icon {
- display: flex;
- align-items: center;
- width: var(--spacing-16);
- height: var(--spacing-16);
- color: var(--text-secondary);
-}
diff --git a/app/server-utils/stampy.ts b/app/server-utils/stampy.ts
index 0ccd5ff3..ce5e9a90 100644
--- a/app/server-utils/stampy.ts
+++ b/app/server-utils/stampy.ts
@@ -60,6 +60,7 @@ export type GlossaryEntry = {
pageid: PageId
contents: string
image: string
+ imageDimensions?: {width: number; height: number}
}
export type Glossary = {
[key: string]: GlossaryEntry
@@ -163,7 +164,7 @@ type GlossaryRow = CodaRowCommon & {
phrase: string
aliases: string
'UI ID': string
- image: Entity
+ image: string
}
}
type BannersRow = CodaRowCommon & {
@@ -375,10 +376,13 @@ export const loadGlossary = withCache('loadGlossary', async () => {
.map((v) => v.trim())
.filter(Boolean),
]
+ const img = values.image && JSON.parse(values.image)
const item = {
pageid,
term: extractText(values.phrase),
- image: values.image?.url,
+ image: img?.url,
+ ...(img?.width &&
+ img?.height && {imageDimensions: {width: img.width, height: img.height}}),
contents: renderText(pageid, extractText(values.definition)).html,
}
return phrases