diff --git a/doc/changelog.d/109.fixed.md b/doc/changelog.d/109.fixed.md new file mode 100644 index 00000000..a8a06727 --- /dev/null +++ b/doc/changelog.d/109.fixed.md @@ -0,0 +1 @@ +Visor dash styling diff --git a/src/ansys/visor/dash/src/lib/components/Visordash.react.js b/src/ansys/visor/dash/src/lib/components/Visordash.react.js index c9b0e073..a6f68b69 100644 --- a/src/ansys/visor/dash/src/lib/components/Visordash.react.js +++ b/src/ansys/visor/dash/src/lib/components/Visordash.react.js @@ -96,7 +96,7 @@ const Visordash = (props) => { }, [fireSnapshot]); return ( -
+
); diff --git a/src/ansys/visor/visor-client/index.html b/src/ansys/visor/visor-client/index.html index c4655b80..283475de 100644 --- a/src/ansys/visor/visor-client/index.html +++ b/src/ansys/visor/visor-client/index.html @@ -1,5 +1,5 @@ - + @@ -9,6 +9,7 @@
diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx index 30e873fb..d59d5131 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx @@ -2,6 +2,7 @@ import { FC, RefObject, useEffect, useRef } from 'react'; import { AwcIcons } from '../AwcIcons'; import { VisorFrontend } from '../../VisorFrontend.tsx'; import { makeTooltip } from '../../utils/Tooltip'; +import { appendToPortal, removeFromPortal } from '../../utils/portal'; export type Panel_BottomMiddle_Util = {}; @@ -112,7 +113,7 @@ export const Panel_BottomMiddle: FC<{ z-index: 1000; white-space: nowrap; `; - document.body.appendChild(popup); + appendToPortal(popup); const subModes: SelectionMode[] = ['edge', 'face', 'vertex']; const popupCleanupFuncs: Array<() => void> = []; @@ -180,7 +181,7 @@ export const Panel_BottomMiddle: FC<{ cleanupFuncs.push(...popupCleanupFuncs); cleanupFuncs.push(() => document.removeEventListener('click', closePopupOnOutsideClick)); - cleanupFuncs.push(() => popup.remove()); + cleanupFuncs.push(() => removeFromPortal(popup)); ////////////////////////// onLoad({}); ////////////////////////// diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx index 5f3bf8a8..4d4a1a94 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx @@ -45,7 +45,11 @@ export const Panel_TopLeft: FC<{ await visorState.render(); })(); }); - const fontSize: number = getRootFontSize(14); + const fontSize: number = getRootFontSize( + 14, + (reactComponentContainer.closest('.visor-embed-style') ?? + document.documentElement) as HTMLElement + ); const collapseButton = AwcIcons.getArrowHeadDownIcon(fontSize * 1.2, 3, true, [ 'theme-hover-background-3', ]) as HTMLButtonElement; diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx index 631d4647..42f45b92 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopRight.tsx @@ -218,7 +218,11 @@ export const Panel_TopRight: FC<{ legendPanelApplyRangeButtonId ) as HTMLButtonElement; - const fontSize: number = getRootFontSize(14); + const fontSize: number = getRootFontSize( + 14, + (componentContainer.closest('.visor-embed-style') ?? + document.documentElement) as HTMLElement + ); let collapsePanel: () => void; let expandPanel: () => void; diff --git a/src/ansys/visor/visor-client/src/jest-tests/JsHelpers.test.js b/src/ansys/visor/visor-client/src/jest-tests/JsHelpers.test.js index 305e41a0..3800c057 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/JsHelpers.test.js +++ b/src/ansys/visor/visor-client/src/jest-tests/JsHelpers.test.js @@ -17,8 +17,9 @@ import { } from '../utils/JsHelpers.js'; /** - * getRootFontSize caches module-level state, so each test needs a fresh - * instance of the module. + * getRootFontSize caches per-element state at module scope, so each test + * needs a fresh instance of the module to avoid leaking cached values + * between tests. * * @returns {Promise<(fallback: number) => number>} */ @@ -126,6 +127,24 @@ describe('getRootFontSize', () => { expect(getRootFontSize(20)).toBe(20); expect(getComputedStyleSpy).toHaveBeenCalledTimes(1); }); + + test('caches independently per element, regardless of call order', async () => { + const getRootFontSize = await loadFreshGetRootFontSize(); + const htmlElem = document.documentElement; + const embedRootElem = document.createElement('div'); + + jest.spyOn(globalThis, 'getComputedStyle').mockImplementation((element) => ({ + getPropertyValue: () => (element === embedRootElem ? '14px' : '16px'), + })); + + // The host resolves first (e.g. a panel without an embed root yet)... + expect(getRootFontSize(16, htmlElem)).toBe(16); + // ...but a later call with the embed root must not reuse that cached value. + expect(getRootFontSize(14, embedRootElem)).toBe(14); + // Subsequent calls for each element still return their own cached value. + expect(getRootFontSize(16, htmlElem)).toBe(16); + expect(getRootFontSize(14, embedRootElem)).toBe(14); + }); }); describe('hashString', () => { diff --git a/src/ansys/visor/visor-client/src/styles/index.css b/src/ansys/visor/visor-client/src/styles/index.css index aad782cd..e2a58177 100644 --- a/src/ansys/visor/visor-client/src/styles/index.css +++ b/src/ansys/visor/visor-client/src/styles/index.css @@ -1,6 +1,10 @@ -*, -*::before, -*::after { +/* Scope the global reset to `.visor-embed-style` so it does not + affect the host application when Visor is embedded. Wrap Visor content + in a container with class `visor-embed-style`. */ +.visor-embed-style, +.visor-embed-style *, +.visor-embed-style *::before, +.visor-embed-style *::after { margin: 0; padding: 0; line-height: 1; @@ -12,27 +16,31 @@ visibility: inherit; } -html { +html, +body { + /* Baseline reset kept global (not scoped): a non-zero default body + margin causes a viewport scrollbar and a resized/clipped full-page + canvas in standalone/Dash-hosted pages. This is standard boilerplate + (unlike font-family/line-height) and does not visibly affect a host + app's existing content when Visor is truly embedded as a widget. */ height: 100%; + margin: 0; } @font-face { /* The "Source Sans 3" font is used by the Ansys AWC React library, - and was imported manually from there for use in Visor. + and is served by the Visor backend for embedded usage. */ font-family: 'Source Sans 3'; src: url('/fonts/source-sans-3.woff2') format('woff2'); + font-display: swap; } -body { +/* Scoped styles: set height and the AWC font only within the Visor embed root + so these font and sizing rules do not leak into host applications. */ +.visor-embed-style { height: 100%; - /* - This font list is used by the Ansys AWC React library, - and was imported manually from there for use in Visor. - This font list can be found in CSS file "@ansys\awc-react\styles\themes\css\default.css" - after installing the AWC React npm package. - */ font-family: 'Source Sans 3', -apple-system, @@ -50,6 +58,15 @@ body { 'Noto Color Emoji'; } +/* Ensure Visor typography matches standalone appearance while keeping + styles scoped to the embed root container so they don't leak into host apps. */ +.visor-embed-style, +.visor-embed-style *, +.visor-embed-style * { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + a, a:active, a:link, diff --git a/src/ansys/visor/visor-client/src/styles/themes/visor-theme-rules.css b/src/ansys/visor/visor-client/src/styles/themes/visor-theme-rules.css index 911acc5a..6067617c 100644 --- a/src/ansys/visor/visor-client/src/styles/themes/visor-theme-rules.css +++ b/src/ansys/visor/visor-client/src/styles/themes/visor-theme-rules.css @@ -4,8 +4,11 @@ border-color: rgba(var(--theme-color-borders), 1); } -html, -body { +/* Theme scoping: apply these base rules only inside `.visor-embed-style` to avoid + leaking layout and color changes into the host application when Visor is + embedded as a component. Wrap the Visor content in a container with class + `visor-embed-style`. */ +.visor-embed-style { font-size: 14px; color: rgba(var(--theme-color-text), 1); } diff --git a/src/ansys/visor/visor-client/src/treeview/TreeView.tsx b/src/ansys/visor/visor-client/src/treeview/TreeView.tsx index fa797b47..067649ca 100644 --- a/src/ansys/visor/visor-client/src/treeview/TreeView.tsx +++ b/src/ansys/visor/visor-client/src/treeview/TreeView.tsx @@ -160,10 +160,17 @@ export const TreeView = >(props: TreeViewProps) => if (sceneGraph == null) { return; } - const fontSize: number = getRootFontSize(14); const componentContainerElem = document.getElementById( componentContainerElemId ) as HTMLDivElement; + // Read the base font size from the nearest `.visor-embed-style` root + // rather than `document.documentElement`: when embedded, only the + // Visor root (not the host page's ) is scoped to Visor's font size. + const embedRoot = componentContainerElem.closest('.visor-embed-style'); + const fontSize: number = getRootFontSize( + 14, + (embedRoot ?? document.documentElement) as HTMLElement + ); const headerContainer = document.createElement('div'); const treeContainer = document.createElement('div'); componentContainerElem.appendChild(headerContainer); diff --git a/src/ansys/visor/visor-client/src/utils/JsHelpers.js b/src/ansys/visor/visor-client/src/utils/JsHelpers.js index 9b0c3a44..a0faefe7 100644 --- a/src/ansys/visor/visor-client/src/utils/JsHelpers.js +++ b/src/ansys/visor/visor-client/src/utils/JsHelpers.js @@ -1,34 +1,49 @@ -/** @type {number|null} */ -let rootFontSize = null; +/** + * Per-element cache for computed root font sizes. Keyed by element (rather + * than a single module-level value) so callers passing different elements + * (e.g. `document.documentElement` vs. a `.visor-embed-style` root) don't + * clobber each other's cached result regardless of call order. + * @type {WeakMap} + */ +const rootFontSizeCache = new WeakMap(); /** - * Returns the root element's computed font size in pixels. + * Returns the given element's computed font size in pixels. * * If the computed font size is not expressed in pixels, the supplied fallback * value is returned. The computed value, or the failure to obtain one, is - * cached for subsequent calls. + * cached per-element for subsequent calls. * * @param {number} fallback - Value to return when the root font size cannot be * determined in pixels. Must be zero or greater. + * @param {Element} [element] - Element whose computed font size is read. + * Defaults to `document.documentElement`. Pass the `.visor-embed-style` root + * when available, since that is where Visor's base font size is scoped to + * (the host page's `` element is not modified when embedded). * @returns {number} The root font size in pixels, or the fallback value. * @throws {Error} If `fallback` is not a non-negative number. */ -export const getRootFontSize = (fallback) => { +export const getRootFontSize = (fallback, element = document.documentElement) => { if (typeof fallback !== 'number' || fallback < 0) { throw new Error('fallback must be a number 0 or greater'); } + let rootFontSize = rootFontSizeCache.get(element); + if (rootFontSize == null) { - const str = getComputedStyle(document.documentElement, null).getPropertyValue('font-size'); + const str = getComputedStyle(element, null).getPropertyValue('font-size'); if (/^(?:-?\d+|-?\d*[.,]\d+)px$/i.test(str)) { - return (rootFontSize = parseFloat(str.slice(0, -2))); + rootFontSize = parseFloat(str.slice(0, -2)); + rootFontSizeCache.set(element, rootFontSize); + return rootFontSize; } const msg = `TreeView warning: computed root element font size '${str}'`; console.warn(`${msg} is not a pixel value. Using fallback.`); rootFontSize = -1; + rootFontSizeCache.set(element, rootFontSize); } if (rootFontSize < 0) { diff --git a/src/ansys/visor/visor-client/src/utils/portal.js b/src/ansys/visor/visor-client/src/utils/portal.js new file mode 100644 index 00000000..f8e2b7e1 --- /dev/null +++ b/src/ansys/visor/visor-client/src/utils/portal.js @@ -0,0 +1,53 @@ +/** + * Helpers to resolve a scoped portal root for UI elements that would + * otherwise be appended to `document.body` (tooltips, popups, invisible + * DOM containers used by wasm). Prefer an element with the + * `.visor-embed-style` class (added by host integrations), then fall back + * to the client container id `VisorContainer`, and finally to a dedicated + * `visor-portal-root` element created under `document.body`. + * + * A single JS implementation is kept (no separate .ts version) so there is + * one source of truth; JSDoc types below give TS/TSX consumers proper typing. + * + * @returns {HTMLElement} + */ +export function getPortalRoot() { + const doc = typeof document !== 'undefined' ? document : null; + if (!doc) { + // Non-browser environment (SSR). Return a harmless stub to avoid + // throwing in places that call this helper during static analysis. + // Callers should guard when used outside the browser. + return /** @type {HTMLElement} */ ({}); + } + + const el = doc.querySelector('.visor-embed-style') || doc.getElementById('VisorContainer'); + if (el) return /** @type {HTMLElement} */ (el); + + let portalRoot = doc.getElementById('visor-portal-root'); + if (!portalRoot) { + portalRoot = doc.createElement('div'); + portalRoot.id = 'visor-portal-root'; + portalRoot.setAttribute('data-visor-portal', '1'); + doc.body.appendChild(portalRoot); + } + + return portalRoot; +} + +/** + * @param {HTMLElement} el + * @returns {void} + */ +export function appendToPortal(el) { + getPortalRoot().appendChild(el); +} + +/** + * @param {HTMLElement} el + * @returns {void} + */ +export function removeFromPortal(el) { + if (el && el.parentElement) { + el.parentElement.removeChild(el); + } +}