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' + ? `
+
+ ${imageHtml} +
+
+
${entry.term}
+
${entry.contents}
+ ${link || ''} +
+
` + : `
+
+
${entry.term}
+
${entry.contents}
+ ${link || ''} +
+
+ ${imageHtml} +
+
` + : `
+
+
${entry.term}
+
${entry.contents}
+ ${link || ''} +
+
` + addPopup( e as HTMLSpanElement, `glossary-${entry.term}`, - `
-
-
${entry.term}
-
${entry.contents}
- ${link || ''} -
- ${image || ''} -
`, - 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