From 67cc236108a2e4ff8564cc8d4736a848524540aa Mon Sep 17 00:00:00 2001 From: luandro Date: Wed, 17 Sep 2025 19:44:46 -0300 Subject: [PATCH] feat(docs): eliminate zoom top gap --- TASK.md | 147 +++++++++++++++++++++ bun.lock | 16 +++ docusaurus.config.ts | 15 +++ package.json | 2 + scripts/rehype-image-figure.ts | 55 ++++++++ src/components/HomepageFeatures/index.tsx | 53 ++++---- src/components/sidebars.ts | 4 +- src/css/custom.css | 102 +++++++++++++++ src/theme/DocSidebar/index.tsx | 8 +- src/theme/DocSidebarItem/index.tsx | 30 +++-- src/theme/Root.tsx | 152 ++++++++++++++++++++++ 11 files changed, 539 insertions(+), 45 deletions(-) create mode 100644 TASK.md create mode 100644 scripts/rehype-image-figure.ts create mode 100644 src/theme/Root.tsx diff --git a/TASK.md b/TASK.md new file mode 100644 index 00000000..e5e30be3 --- /dev/null +++ b/TASK.md @@ -0,0 +1,147 @@ +# Task: Fix Image Zoom Top Gap While Preserving Proportions + +## Summary +- Problem: When zooming images in docs (using `docusaurus-plugin-image-zoom`), a large white gap appears above the image. In some iterations, images also got distorted or click-to-close broke. +- Goal: Maximize image area during zoom without ever distorting proportions. Images should fully fit within the viewport (either width or height) and not overflow; the top gap should be eliminated for tall images. +- Current status: Distortion is fixed. Top gap persists in multiple edge cases. + +## Environment +- Docusaurus v3 +- Plugin: `docusaurus-plugin-image-zoom` (with `medium-zoom` under the hood) +- Site has a fixed navbar; Docusaurus typically adds top padding to content equal to navbar height + +## Desired Behavior +- Zoom mode should: + - Maintain image aspect ratio, always (no stretch/compress) + - Fill the viewport height for tall images (no top gap), or fill viewport width for wide images (vertical space is acceptable) + - Enable closing via second click on the image and via overlay + - Optionally provide a subtle close button in the top-right + +## Current Config and CSS Hooks +- `docusaurus.config.ts` + - Plugin enabled: `'docusaurus-plugin-image-zoom'` + - `themeConfig.zoom`: + - `selector: '.theme-doc-markdown img:not(.no-zoom)'` + - `background`: theme-aware + - `config`: `{ margin: 0, scrollOffset: 0 }` (set to remove plugin margins) +- `src/css/custom.css` + - Base images in docs capped to `max-height: 50vh` (outside zoom) + - Navbar hidden and scroll locked during zoom using body classes: + - `body.zooming`, `body.zoom-open`, and `:has(img.medium-zoom-image--opened)` + - Various attempts to control zoomed image sizing and positioning +- `src/theme/Root.tsx` (client-only enhancer) + - Adds body class `zooming` on `pointerdown` for zoomable images so navbar is hidden before plugin measures + - Observes DOM mutations to toggle `zoom-open` while an image has `medium-zoom-image--opened` + - Renders a subtle top-right close button during zoom, which clicks the overlay + +## What We Tried (with pros/cons) + +1) Cap images globally during zoom via CSS +- CSS (earlier attempt): + - `img.medium-zoom-image--opened { max-height: 98svh; max-width: 98vw; width: auto; height: auto; margin: 0; cursor: zoom-out; }` +- Result: + - Some devices showed distortion or the plugin fought our sizing; occasionally broke click-to-close (due to positioning/z-index changes in other variants) + - Top gap reduced but not consistently eliminated + +2) Remove all caps in zoom; let plugin control everything +- CSS (later attempt): + - `img.medium-zoom-image--opened { max-height: none; max-width: none; width: auto; height: auto; margin: 0; }` +- Result: + - Fixed distortion reliably + - Top gap remained (plugin vertically centers; any remaining layout padding exacerbates the gap) + +3) Force top alignment with fixed positioning +- CSS (another attempt): + - Pin image to top: `position: fixed; top: 0; left: 50%; transform: translateX(-50%) !important; height: 100svh; width: auto;` +- Result: + - Removed top gap + - Introduced regressions: click-to-close sometimes failed (interfered with plugin’s transform/overlay ordering); in some cases, reintroduced distortion due to conflicts with plugin transforms + +4) Post-open transform adjustment (matrix math) +- Logic: + - Read `getComputedStyle(img).transform` after plugin opens, extract scale and translation + - Compute leftover vertical space and shift Y translation to top-align +- Result: + - Maintained proportions and plugin event handling + - Still saw top gap in cases; likely due to transform reflows, address bar changes, or plugin’s subsequent adjustments + +5) Aspect-ratio based explicit fit (JS-driven) +- Logic: + - On open, compute `imgAR` vs `vpAR` using `visualViewport` where available + - If tall: set `position: fixed; top: 0; left: 50%; transform: translateX(-50%); height: ; width: auto;` + - If wide: set `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: ; height: auto;` + - Recompute on resize/orientationchange; clean up on close +- Result: + - Proportions preserved, but in practice the top gap persisted in some environments (likely fighting plugin animations/transform order or overlay timing) + +6) Navbar and layout adjustments +- Hiding navbar before plugin measures via `pointerdown` capture (works) +- Removing padding-top on `.main-wrapper` while zooming (helps) +- Still suspect there are other offsets present during measurement (e.g., Docusaurus fixed navbar variables, safe-area insets, other wrappers) + +## Files Touched +- `src/css/custom.css` + - Base image sizing in docs + - Zoom state styles for navbar, scroll lock, and various image sizing attempts +- `docusaurus.config.ts` + - Added `'docusaurus-plugin-image-zoom'` to `plugins` + - Added `themeConfig.zoom` with `{ margin: 0, scrollOffset: 0 }` + - Added `rehype` plugin hook for figures/captions (see below) +- `scripts/rehype-image-figure.ts` + - Simple rehype: wraps standalone images in `
` and uses `alt` as `
` (not directly related to zoom sizing) +- `src/theme/Root.tsx` + - Client enhancement to coordinate navbar hiding, zoom state, close button, and multiple experimental sizing strategies + +## Observations / Hypotheses +- The plugin vertically centers images by design. With no `margin`, it still computes a centered translate. If any layout padding remains at measurement, the translate will include it, creating top gap. +- Docusaurus’ fixed navbar normally adds top offset via CSS variables (e.g., `--ifm-navbar-height`) and wrappers. We remove padding from `.main-wrapper`, but other elements may still contribute top spacing or affect the plugin’s calculation. +- On mobile, the address bar affects `innerHeight` during transitions; `visualViewport.height` is more accurate. We already use it in some attempts, but timing may still be off. +- Overriding the plugin’s transform or positioning fully removes the gap but risks fighting its click/overlay behavior unless carefully layered. + +## Repro Steps +1) Start dev server: `bun run dev` +2) Navigate to any doc with a tall image +3) Click image to zoom +4) Observe: navbar hides, image keeps proportions, but a white space remains above the image (image is not top-aligned), or in some variants closing via second click fails + +## Acceptance Criteria +- Zoomed image retains original proportions and never overflows viewport +- Tall images: no white gap above (top-aligned), fill 100% of viewport height +- Wide images: fill 100% of viewport width, centered vertically, no overflow +- Click-to-close and overlay click both work; the close button provides an additional accessible method + +## Proposed Next Steps (Precise Fixes) +1) Identify and eliminate all layout offsets in zoom-open state before measurement: + - Inspect Docusaurus fixed navbar CSS; during `zooming/zoom-open`, override `:root { --ifm-navbar-height: 0 !important; }` and remove any `padding-top` that depends on it (e.g., `.navbar--fixed + .main-wrapper`). + - Ensure no other wrappers (e.g., `.container`, `.main-wrapper`, page headers) add `margin-top`/`padding-top`. + +2) Sync timing with plugin measurement: + - On `pointerdown`, add `zooming` class and in the next animation frame, trigger a synthetic `click` programmatically on the image to ensure the plugin measures only after layout changes. + - Alternatively, listen for overlay insertion and immediately re-open after forcing layout (close if opened prematurely). + +3) Controlled top-aligned sizing without breaking close: + - After open, set only `position: fixed` + explicit `height: visualViewport.height` for tall images (width auto), with `transform: translateX(-50%)` — do not change z-index or pointer events; rely on overlay for closing. + - For wide images, set only `width: visualViewport.width` (height auto) and center vertically via `top: 50%; transform: translate(-50%, -50%)`. + - Reapply on `visualViewport.resize`. + +4) Safety fallbacks: + - If any click-to-close regression appears, ensure overlay remains above the image in z-order (or let image stay above overlay but attach a click handler to call `overlay.click()`). + - Consider disabling any prior CSS that targets `img.medium-zoom-image--opened` except for `margin: 0` and `cursor: zoom-out` to avoid conflicts. + +5) Measure and log (temporary): + - Log computed `transform`, `boundingClientRect`, and any padding/margins for the opened image and parents to understand remaining offsets. Remove logs before commit. + +## Optional Enhancements (Once Fixed) +- Add an opt-in class `zoom-top` for any images that must always top-align (applied during zoom) +- Add safe-area insets handling for iOS: subtract `env(safe-area-inset-top/bottom)` from viewport height when computing fits +- Add lightbox controls (prev/next) if/when multiple images are in series + +## References +- Plugin: https://github.com/gabrielcsapo/docusaurus-plugin-image-zoom +- Medium Zoom internals: https://github.com/francoischalifour/medium-zoom + +## Appendix: Key Code Locations +- Config: `docusaurus.config.ts` (plugin and themeConfig.zoom) +- CSS: `src/css/custom.css` (navbar hiding, zoom overrides, base image sizing) +- Client enhancer: `src/theme/Root.tsx` (pointerdown timing, state classes, close button, experimental sizing logic) +- Rehype (optional): `scripts/rehype-image-figure.ts` (figures/captions; not directly tied to zoom sizing) diff --git a/bun.lock b/bun.lock index 634fdedd..faeb2ba2 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,8 @@ "@docusaurus/preset-classic": "3.8.1", "@mdx-js/react": "^3.1.1", "clsx": "^2.1.1", + "docusaurus-plugin-image-zoom": "^3.0.1", + "medium-zoom": "^1.1.0", "openai": "^5.20.1", "prism-react-renderer": "^2.4.1", "react": "^19.1.1", @@ -1342,6 +1344,8 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "docusaurus-plugin-image-zoom": ["docusaurus-plugin-image-zoom@3.0.1", "", { "dependencies": { "medium-zoom": "^1.1.0", "validate-peer-dependencies": "^2.2.0" }, "peerDependencies": { "@docusaurus/theme-classic": ">=3.0.0" } }, "sha512-mQrqA99VpoMQJNbi02qkWAMVNC4+kwc6zLLMNzraHAJlwn+HrlUmZSEDcTwgn+H4herYNxHKxveE2WsYy73eGw=="], + "docusaurus-prince-pdf": ["docusaurus-prince-pdf@1.2.1", "", { "dependencies": { "got": "13.0.0", "jsdom": "22.1.0", "tough-cookie": "4.1.3", "yargs": "17.7.2" }, "bin": { "docusaurus-prince-pdf": "index.js" } }, "sha512-8/ssMwm60bDP9MSsFIlcnKPXVpclLh/VPRA01dosx3/1Pt1OcFfy5fkRSL2WBOSxEoVZcWr+oPzbeimlRJqfNA=="], "dom-converter": ["dom-converter@0.2.0", "", { "dependencies": { "utila": "~0.4" } }, "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA=="], @@ -2094,6 +2098,8 @@ "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "medium-zoom": ["medium-zoom@1.1.0", "", {}, "sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ=="], + "memfs": ["memfs@3.6.0", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-EGowvkkgbMcIChjMTMkESFDbZeSh8xZ7kNSF0hAiAN4Jh6jgHCRS0Ga/+C8y6Au+oqpezRHCfPsmJ2+DwAgiwQ=="], "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], @@ -2360,6 +2366,10 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-root": ["path-root@0.1.1", "", { "dependencies": { "path-root-regex": "^0.1.0" } }, "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg=="], + + "path-root-regex": ["path-root-regex@0.1.2", "", {}, "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ=="], + "path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="], "path-to-regexp": ["path-to-regexp@1.9.0", "", { "dependencies": { "isarray": "0.0.1" } }, "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g=="], @@ -2682,6 +2692,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-package-path": ["resolve-package-path@4.0.3", "", { "dependencies": { "path-root": "^0.1.1" } }, "sha512-SRpNAPW4kewOaNUt8VPqhJ0UMxawMwzJD8V7m1cJfdSTK9ieZwS6K7Dabsm4bmLFM96Z5Y/UznrpG5kt1im8yA=="], + "resolve-pathname": ["resolve-pathname@3.0.0", "", {}, "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -3078,6 +3090,8 @@ "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "validate-peer-dependencies": ["validate-peer-dependencies@2.2.0", "", { "dependencies": { "resolve-package-path": "^4.0.3", "semver": "^7.3.8" } }, "sha512-8X1OWlERjiUY6P6tdeU9E0EwO8RA3bahoOVG7ulOZT5MqgNDUO/BQoVjYiHPcNe+v8glsboZRIw9iToMAA2zAA=="], + "value-equal": ["value-equal@1.0.1", "", {}, "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -3834,6 +3848,8 @@ "url-loader/schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + "validate-peer-dependencies/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], diff --git a/docusaurus.config.ts b/docusaurus.config.ts index a0c22fba..1a28cca5 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -3,6 +3,7 @@ import type { Config } from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; import dotenv from 'dotenv'; import remarkFixImagePaths from './scripts/remark-fix-image-paths'; +import rehypeImageFigure from './scripts/rehype-image-figure'; // Load environment variables from .env file dotenv.config(); @@ -95,6 +96,7 @@ const config: Config = { ], }, ], + 'docusaurus-plugin-image-zoom', // [ // '@docusaurus/preset-classic', // { @@ -140,6 +142,7 @@ const config: Config = { path: 'docs', sidebarPath: './src/components/sidebars.ts', remarkPlugins: [remarkFixImagePaths], + rehypePlugins: [rehypeImageFigure], // Please change this to your repo. // Remove this to remove the "edit this page" links. editUrl: @@ -161,6 +164,18 @@ const config: Config = { themeConfig: { // Replace with your project's social card image: 'img/comapeo-social-card.jpg', + zoom: { + selector: '.theme-doc-markdown img:not(.no-zoom)', + background: { + light: 'rgba(255, 255, 255, 0.9)', + dark: 'rgba(20, 20, 20, 0.9)', + }, + config: { + margin: 0, + scrollOffset: 0, + // No container override - let plugin use full viewport + }, + }, navbar: { // title: 'CoMapeo', logo: { diff --git a/package.json b/package.json index 4c9bee04..57ebff99 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "@docusaurus/preset-classic": "3.8.1", "@mdx-js/react": "^3.1.1", "clsx": "^2.1.1", + "docusaurus-plugin-image-zoom": "^3.0.1", + "medium-zoom": "^1.1.0", "openai": "^5.20.1", "prism-react-renderer": "^2.4.1", "react": "^19.1.1", diff --git a/scripts/rehype-image-figure.ts b/scripts/rehype-image-figure.ts new file mode 100644 index 00000000..7702e86d --- /dev/null +++ b/scripts/rehype-image-figure.ts @@ -0,0 +1,55 @@ +// Lightweight rehype plugin to wrap standalone images in
with an optional
+// Applies when a paragraph contains a single element. + +function isElement(node: any, tag?: string): boolean { + return node && node.type === 'element' && (!tag || node.tagName === tag); +} + +export default function rehypeImageFigure() { + return (tree: any) => { + if (!tree || !('children' in tree)) return; + + const children = (tree as any).children; + + function transform(parent: any) { + if (!parent.children) return; + for (let i = 0; i < parent.children.length; i++) { + const node = parent.children[i]; + if (isElement(node, 'p') && node.children && node.children.length === 1) { + const only = node.children[0]; + if (isElement(only, 'img')) { + const alt = (only.properties?.alt as string) || ''; + const figureChildren: any[] = [only]; + + if (alt.trim().length > 0) { + const captionText: any = { type: 'text', value: alt }; + const figcaption: any = { + type: 'element', + tagName: 'figcaption', + properties: {}, + children: [captionText], + }; + figureChildren.push(figcaption); + } + + const figure: any = { + type: 'element', + tagName: 'figure', + properties: {}, + children: figureChildren, + }; + + // Replace

with

+ parent.children.splice(i, 1, figure); + } + } + // Recurse into element children + if ((node as any).children && Array.isArray((node as any).children)) { + transform(node as any); + } + } + } + + transform(tree as any); + }; +} diff --git a/src/components/HomepageFeatures/index.tsx b/src/components/HomepageFeatures/index.tsx index 7a697ff2..5a81dff0 100644 --- a/src/components/HomepageFeatures/index.tsx +++ b/src/components/HomepageFeatures/index.tsx @@ -1,8 +1,8 @@ -import type {ReactNode} from 'react'; -import clsx from 'clsx'; -import Heading from '@theme/Heading'; -import styles from './styles.module.css'; -import {translate} from '@docusaurus/Translate'; +import type { ReactNode } from "react"; +import clsx from "clsx"; +import Heading from "@theme/Heading"; +import styles from "./styles.module.css"; +import { translate } from "@docusaurus/Translate"; type FeatureItem = { id: string; @@ -13,58 +13,61 @@ type FeatureItem = { const FeatureList: FeatureItem[] = [ { - id: 'conversational-guidance', + id: "conversational-guidance", title: translate({ - message: 'Conversational Guidance', - description: 'Feature title for voice-enabled QA bots section', + message: "Conversational Guidance", + description: "Feature title for voice-enabled QA bots section", }), - image: 'bot.jpg', + image: "bot.jpg", description: ( <> {translate({ - message: 'Get instant voice assistance through our documentation bots that listen to your questions and respond with precise answers in real-time.', - description: 'Description for voice-enabled QA bots section', + message: + "Get instant voice assistance through our documentation bots that listen to your questions and respond with precise answers in real-time.", + description: "Description for voice-enabled QA bots section", })} ), }, { - id: 'multi-lingual-documentation', + id: "multi-lingual-documentation", title: translate({ - message: 'Map in Any Language', - description: 'Feature title for multi-lingual documentation section', + message: "Map in Any Language", + description: "Feature title for multi-lingual documentation section", }), - image: 'locale.jpg', + image: "locale.jpg", description: ( <> {translate({ - message: 'Access CoMapeo documentation in multiple languages, ensuring every team member can learn and contribute regardless of their native tongue.', - description: 'Description for multi-lingual documentation section', + message: + "Access CoMapeo documentation in multiple languages, ensuring every team member can learn and contribute regardless of their native tongue.", + description: "Description for multi-lingual documentation section", })} ), }, { - id: 'comprehensive-learning-hub', + id: "comprehensive-learning-hub", title: translate({ - message: 'Your Mapping Journey Starts Here', - description: 'Feature title for comprehensive learning hub section', + message: "Your Mapping Journey Starts Here", + description: "Feature title for comprehensive learning hub section", }), - image: 'hub.jpg', + image: "hub.jpg", description: ( <> {translate({ - message: 'Everything you need to master the CoMapeo platform, from beginner tutorials to advanced techniques, all in one centralized knowledge center.', - description: 'Description for comprehensive learning hub section', + message: + "Everything you need to master the CoMapeo platform, from beginner tutorials to advanced techniques, all in one centralized knowledge center.", + description: "Description for comprehensive learning hub section", })} ), }, ]; -function Feature({title, image, description}: FeatureItem) { +function Feature({ title, image, description }: FeatureItem) { return ( -
+
{title}
diff --git a/src/components/sidebars.ts b/src/components/sidebars.ts index b79584b7..0716575b 100644 --- a/src/components/sidebars.ts +++ b/src/components/sidebars.ts @@ -1,4 +1,4 @@ -import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; +import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) @@ -14,7 +14,7 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; */ const sidebars: SidebarsConfig = { // By default, Docusaurus generates a sidebar from the docs folder structure - docsSidebar: [{ type: 'autogenerated', dirName: '.', }], + docsSidebar: [{ type: "autogenerated", dirName: "." }], }; export default sidebars; diff --git a/src/css/custom.css b/src/css/custom.css index 9a6a96dc..3d26783a 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -55,6 +55,86 @@ object-fit: contain; display: block; margin: 1rem auto; /* center images and add spacing */ + cursor: zoom-in; +} + +/* When image zoom is active, remove caps so medium-zoom can preserve + the image's natural aspect ratio without layout constraints. */ +img.medium-zoom-image--opened { + /* Ensure no distortion by removing any imposed sizing caps */ + max-width: none !important; + max-height: none !important; + width: auto !important; + height: auto !important; + margin: 0 !important; + cursor: zoom-out; +} + +/* Subtle close button shown during zoom */ +.zoom-close-btn { + position: fixed; + top: 12px; + right: 12px; + z-index: 10001; + background: rgba(0, 0, 0, 0.6); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 6px 10px; + line-height: 1; + font-size: 14px; + display: none; + backdrop-filter: saturate(120%) blur(2px); +} +body.zoom-open .zoom-close-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* Hide navbar and prevent page scroll while an image is zoomed */ +/* Hide navbar as soon as zoom is initiated; maintain during open */ +body.zooming .navbar, +body.zoom-open .navbar, +body:has(img.medium-zoom-image--opened) .navbar { + opacity: 0; + pointer-events: none; + transform: translateY(-100%); + transition: opacity 120ms ease, transform 120ms ease; +} +body.zooming, +body.zoom-open, +body:has(img.medium-zoom-image--opened) { + overflow: hidden; +} + +/* Remove all layout offsets during zoom to prevent top gap */ +body.zooming, +body.zoom-open, +body:has(img.medium-zoom-image--opened) { + /* Override Docusaurus navbar height variable */ + --ifm-navbar-height: 0 !important; +} + +body.zooming .main-wrapper, +body.zoom-open .main-wrapper, +body:has(img.medium-zoom-image--opened) .main-wrapper { + padding-top: 0 !important; + margin-top: 0 !important; +} + +/* Remove padding from all potential containers during zoom */ +body.zooming .container, +body.zoom-open .container, +body:has(img.medium-zoom-image--opened) .container { + padding-top: 0 !important; + margin-top: 0 !important; +} + +body.zooming .row, +body.zoom-open .row, +body:has(img.medium-zoom-image--opened) .row { + margin-top: 0 !important; } /* Optional helpers for images used via MDX with className */ @@ -71,6 +151,28 @@ border-radius: 6px; } +/* Optional checker background helper for transparent PNGs */ +.theme-doc-markdown img.checker-bg { + background-image: linear-gradient(45deg, #f0f0f0 25%, transparent 25%), + linear-gradient(-45deg, #f0f0f0 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f0f0f0 75%), + linear-gradient(-45deg, transparent 75%, #f0f0f0 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0px; +} + +/* Simple grid for side-by-side images in MDX */ +.theme-doc-markdown .image-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + align-items: center; +} +.theme-doc-markdown .image-grid img { + width: 100%; + height: auto; +} + /* Figure + caption styling if authors use
in MDX */ .theme-doc-markdown figure { margin: 1.25rem auto; diff --git a/src/theme/DocSidebar/index.tsx b/src/theme/DocSidebar/index.tsx index f3096638..a851ead5 100644 --- a/src/theme/DocSidebar/index.tsx +++ b/src/theme/DocSidebar/index.tsx @@ -1,7 +1,7 @@ -import React, { type ReactNode } from 'react'; -import DocSidebar from '@theme-original/DocSidebar'; -import type DocSidebarType from '@theme/DocSidebar'; -import type { WrapperProps } from '@docusaurus/types'; +import React, { type ReactNode } from "react"; +import DocSidebar from "@theme-original/DocSidebar"; +import type DocSidebarType from "@theme/DocSidebar"; +import type { WrapperProps } from "@docusaurus/types"; type Props = WrapperProps; diff --git a/src/theme/DocSidebarItem/index.tsx b/src/theme/DocSidebarItem/index.tsx index 5f7d08f8..dc9729ae 100644 --- a/src/theme/DocSidebarItem/index.tsx +++ b/src/theme/DocSidebarItem/index.tsx @@ -1,24 +1,26 @@ -import React, {type ReactNode} from 'react'; -import DocSidebarItem from '@theme-original/DocSidebarItem'; -import type DocSidebarItemType from '@theme/DocSidebarItem'; -import type {WrapperProps} from '@docusaurus/types'; -import {translate} from '@docusaurus/Translate'; +import React, { type ReactNode } from "react"; +import DocSidebarItem from "@theme-original/DocSidebarItem"; +import type DocSidebarItemType from "@theme/DocSidebarItem"; +import type { WrapperProps } from "@docusaurus/types"; +import { translate } from "@docusaurus/Translate"; type Props = WrapperProps; export default function DocSidebarItemWrapper(props: Props): ReactNode { - props.item.label = translate({message: props.item.label}) + props.item.label = translate({ message: props.item.label }); return ( <> {props.item.customProps?.title && ( -
- {translate({message:props.item.customProps.title})} +
+ {translate({ message: props.item.customProps.title })}
)} diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx new file mode 100644 index 00000000..48d52361 --- /dev/null +++ b/src/theme/Root.tsx @@ -0,0 +1,152 @@ +import React, { useEffect } from "react"; + +// Enhances image zoom UX by working WITH the medium-zoom plugin +// - Hides navbar before plugin measures layout +// - Uses CSS transforms to adjust final positioning without breaking plugin +// - Preserves all plugin functionality (animations, events, z-index) + +const ZOOM_SELECTOR = ".theme-doc-markdown img:not(.no-zoom)"; + +export default function Root({ children }: { children: React.ReactNode }) { + const [open, setOpen] = React.useState(false); + + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const target = e.target as Element | null; + if (!target) return; + const img = target.closest("img"); + if (img && (img as Element).matches(ZOOM_SELECTOR)) { + document.body.classList.add("zooming"); + } + }; + + // Get viewport dimensions + const getViewport = () => { + const vv = (window as any).visualViewport; + return { + width: vv?.width ? Math.floor(vv.width) : window.innerWidth, + height: vv?.height ? Math.floor(vv.height) : window.innerHeight, + }; + }; + + // Apply top-alignment adjustment AFTER plugin completes its positioning + const adjustImagePosition = (img: HTMLImageElement) => { + if (!img.naturalWidth || !img.naturalHeight) return; + + const { width: vpW, height: vpH } = getViewport(); + const imgAR = img.naturalWidth / img.naturalHeight; + const vpAR = vpW / vpH; + + // Only adjust tall images that would benefit from top alignment + if (imgAR <= vpAR) { + // Get the current transform from the plugin + const currentTransform = window.getComputedStyle(img).transform; + + // Parse the current transform to extract scale and translate values + const matrix = new DOMMatrix(currentTransform); + const currentScale = matrix.a; // scale from transform matrix + + // Calculate how much we need to shift up to eliminate top gap + const scaledHeight = img.naturalHeight * currentScale; + const topShift = (vpH - scaledHeight) / 2; + + // Apply additional transform to move image to top + // This works with the plugin's transform rather than replacing it + img.style.transform = `${currentTransform} translateY(${-topShift}px)`; + img.setAttribute("data-top-aligned", "true"); + } + }; + + // Cleanup function + const clearAdjustment = (img: HTMLImageElement | null) => { + if (img && img.hasAttribute("data-top-aligned")) { + img.removeAttribute("data-top-aligned"); + // Let plugin handle transform restoration + } + }; + + // Observe for zoom state changes + const observer = new MutationObserver(() => { + const opened = document.querySelector( + "img.medium-zoom-image--opened" + ) as HTMLImageElement | null; + const isOpened = Boolean(opened); + + if (isOpened && opened) { + document.body.classList.add("zoom-open"); + setOpen(true); + + // Wait for plugin's animation to complete, then apply our adjustment + const handleTransitionEnd = () => { + adjustImagePosition(opened); + opened.removeEventListener("transitionend", handleTransitionEnd); + }; + opened.addEventListener("transitionend", handleTransitionEnd); + + // Fallback timeout in case transitionend doesn't fire + setTimeout(() => adjustImagePosition(opened), 300); + } else { + // Clear any adjustments on close + const prevAdjusted = document.querySelector( + "img[data-top-aligned]" + ) as HTMLImageElement | null; + clearAdjustment(prevAdjusted); + + document.body.classList.remove("zoom-open"); + document.body.classList.remove("zooming"); + setOpen(false); + } + }); + + document.addEventListener("pointerdown", onPointerDown, { + capture: true, + passive: true, + }); + + observer.observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ["class"], + }); + + return () => { + document.removeEventListener("pointerdown", onPointerDown, true as any); + observer.disconnect(); + }; + }, []); + + const handleClose = () => { + // Use plugin's preferred close method + const overlay = document.querySelector( + ".medium-zoom-overlay" + ) as HTMLElement | null; + if (overlay) { + overlay.click(); + return; + } + + // Fallback to image click + const opened = document.querySelector( + "img.medium-zoom-image--opened" + ) as HTMLElement | null; + if (opened) { + opened.click(); + } + }; + + return ( + <> + {children} + {open ? ( + + ) : null} + + ); +}