From 687cad263d65c733ed7098aef268d368595dce16 Mon Sep 17 00:00:00 2001 From: Hatton Date: Wed, 25 Feb 2026 14:45:50 -0700 Subject: [PATCH] Make page gutter understandable Also, can now add tooltips to various elements on the page --- src/BloomBrowserUI/AGENTS.md | 2 + src/BloomBrowserUI/bookEdit/css/editMode.less | 71 ++++++ .../bookEdit/js/bloomEditing.ts | 9 +- .../bookEdit/js/pageHoverTooltips.ts | 207 ++++++++++++++++++ 4 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/BloomBrowserUI/bookEdit/js/pageHoverTooltips.ts diff --git a/src/BloomBrowserUI/AGENTS.md b/src/BloomBrowserUI/AGENTS.md index 0a5ec0663e44..62bb8d311e62 100644 --- a/src/BloomBrowserUI/AGENTS.md +++ b/src/BloomBrowserUI/AGENTS.md @@ -17,6 +17,8 @@ When working in the front-end, cd to src/BloomBrowserUI ## Code Style - Always use arrow functions and function components in React +- For functions, prefer typescript "function" syntax over const foo = () ==> functions. +- When writing less, use new css features supported by our current version of webview2. E.g. "is()". - Avoid removing existing comments. - Avoid adding a comment like "// add this line". diff --git a/src/BloomBrowserUI/bookEdit/css/editMode.less b/src/BloomBrowserUI/bookEdit/css/editMode.less index 064c1a701e63..a170ac2cce9e 100644 --- a/src/BloomBrowserUI/bookEdit/css/editMode.less +++ b/src/BloomBrowserUI/bookEdit/css/editMode.less @@ -68,6 +68,42 @@ body { transition: transform 20ms; } +.bloom-page-tooltip { + position: fixed; + z-index: @formatButtonZIndex; + display: none; + pointer-events: none; + white-space: nowrap; + padding: 2px 6px; + border: none; + border-radius: 3px; + background-color: black; + color: white; + font-family: @UIFontStack; + font-size: 8pt; +} + +.bloom-page-tooltip-target { + position: absolute; + pointer-events: auto; + background-color: transparent; +} + +.bloom-gutter-tooltip-target { + top: 0; + bottom: 0; + width: var(--page-gutter); + z-index: 3; +} + +.bloom-gutter-target-side-left { + right: 0; +} + +.bloom-gutter-target-side-right { + left: 0; +} + // See comments on .bloom-mediaBox in basePage.less for a description of the mediaBox. // Here, we are causing it to be visible when desired. .bloom-mediaBox { @@ -142,6 +178,41 @@ body:has(.MuiPaper-root:hover) > .qtip { outline: none !important; } +// Show the paper-page gutter (binding space) in edit mode. +// Show this only while the page is active (hover/focus) like other edit affordances. +.bloom-page:is(.side-left, .side-right):not(.outsideFrontCover):not( + .outsideBackCover + )::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + width: var(--page-gutter); + background-color: var(--page-structure-color); + box-sizing: border-box; + pointer-events: none; + z-index: 2; + opacity: 0; +} +.bloom-page.side-left:not(.outsideFrontCover):not(.outsideBackCover)::before { + right: 0; + border-left: 2px dashed white; +} +.bloom-page.side-right:not(.outsideFrontCover):not(.outsideBackCover)::before { + left: 0; + border-right: 2px dashed white; +} + +.bloom-page:is(:hover, :focus-within):is(.side-left, .side-right):not( + .outsideFrontCover + ):not(.outsideBackCover)::before, +body:has(#canvas-element-context-controls:hover) + .bloom-page:is(.side-left, .side-right):not(.outsideFrontCover):not( + .outsideBackCover + )::before { + opacity: 1; +} + // Keep together here all the effects we want when hovering (previously: also when focus-within) // the bloom-page. All of them need to also apply when the canvas element context controls are hovered, // even though that is no longer a child of the bloom-page. (That's what the second rule is for.) diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 7dfeb88281f2..970e37389510 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -40,6 +40,11 @@ import { getToolboxBundleExports, } from "./bloomFrames"; import { showInvisibles, hideInvisibles } from "./showInvisibles"; +import { + cleanupPageHoverTooltips, + getBodyInnerHtmlWithoutPageHoverTooltips, + setupPageHoverTooltips, +} from "./pageHoverTooltips"; //promise may be needed to run tests with phantomjs //import promise = require('es6-promise'); @@ -155,6 +160,7 @@ function Cleanup() { cleanupImages(); cleanupOrigami(); cleanupNiceScroll(); + cleanupPageHoverTooltips(); } //add a delete button which shows up when you hover @@ -1096,6 +1102,7 @@ export function bootstrap() { SetupElements(document.body); OneTimeSetup(); + setupPageHoverTooltips(); // configure ckeditor if (typeof CKEDITOR === "undefined") return; // this happens during unit testing @@ -1338,7 +1345,7 @@ export function getBodyContentForSavePage() { ); } - const result = document.body.innerHTML; + const result = getBodyInnerHtmlWithoutPageHoverTooltips(); if (canvasElementEditingOn) { theOneCanvasElementManager.turnOnCanvasElementEditing(); diff --git a/src/BloomBrowserUI/bookEdit/js/pageHoverTooltips.ts b/src/BloomBrowserUI/bookEdit/js/pageHoverTooltips.ts new file mode 100644 index 000000000000..0cc57852db9f --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/pageHoverTooltips.ts @@ -0,0 +1,207 @@ +import $ from "jquery"; +import theOneLocalizationManager from "../../lib/localizationManager/localizationManager"; + +const kTooltipTargetClass = "bloom-page-tooltip-target"; +const kGutterTargetClass = "bloom-gutter-tooltip-target"; +const kGutterLeftClass = "bloom-gutter-target-side-left"; +const kGutterRightClass = "bloom-gutter-target-side-right"; +const kTooltipTextAttribute = "data-bloom-page-tooltip-text"; +const kTooltipBubbleClass = "bloom-page-tooltip"; +const kTooltipUiMarkerAttribute = "data-bloom-ui"; +const kTooltipUiMarkerValue = "page-hover-tooltip"; +const kDefaultGutterTooltipText = + "Page Gutter: space for staples or other binding."; + +let pageHoverTooltip: JQuery | undefined; +let gutterTooltipText = kDefaultGutterTooltipText; +let formatDialogTitleTooltipText = "Format"; +let activeTooltipText: string | undefined; +let tooltipLocalizationInitialized = false; + +interface MousePositionEvent { + clientX: number; + clientY: number; +} + +// This module supports tooltips for page editing regions that may not have their own interactive DOM elements. +// For those cases, we create temporary "target" elements (class bloom-page-tooltip-target) and position +// them over the page region (currently, the paper gutter). These targets all get the bloom-ui class so they +// are treated as edit-time markup. Also, when saving, bloomEditing.ts uses +// getBodyInnerHtmlWithoutPageHoverTooltips() so these temporary elements are removed from the HTML that is +// written to disk, even though they remain available in the live editing DOM. + +function hidePageHoverTooltip(): void { + activeTooltipText = undefined; + pageHoverTooltip?.hide(); +} + +function ensurePageHoverTooltipBubble(): void { + if (pageHoverTooltip) { + return; + } + + pageHoverTooltip = $(`
`); + pageHoverTooltip.attr(kTooltipUiMarkerAttribute, kTooltipUiMarkerValue); + pageHoverTooltip.text(gutterTooltipText); + $("body").append(pageHoverTooltip); +} + +function removeTooltipTargets(): void { + $(`.${kTooltipTargetClass}`).remove(); +} + +function makeTooltipTarget( + page: HTMLElement, + extraClasses: string[], + tooltipText: string, +): void { + const target = document.createElement("div"); + target.classList.add("bloom-ui", kTooltipTargetClass, ...extraClasses); + target.setAttribute(kTooltipTextAttribute, tooltipText); + target.setAttribute(kTooltipUiMarkerAttribute, kTooltipUiMarkerValue); + page.appendChild(target); +} + +function addGutterTooltipTargets(): void { + document.querySelectorAll("div.bloom-page").forEach((pageElement) => { + const page = pageElement as HTMLElement; + if ( + page.classList.contains("outsideFrontCover") || + page.classList.contains("outsideBackCover") + ) { + return; + } + + if (page.classList.contains("side-left")) { + makeTooltipTarget( + page, + [kGutterTargetClass, kGutterLeftClass], + gutterTooltipText, + ); + return; + } + + if (page.classList.contains("side-right")) { + makeTooltipTarget( + page, + [kGutterTargetClass, kGutterRightClass], + gutterTooltipText, + ); + } + }); +} + +function updateGutterTooltipTexts(): void { + document + .querySelectorAll(`.${kGutterTargetClass}`) + .forEach((targetElement) => { + targetElement.setAttribute( + kTooltipTextAttribute, + gutterTooltipText, + ); + }); +} + +function getTooltipTextForTarget(target: HTMLElement): string | undefined { + if (target.id === "formatButton") { + return formatDialogTitleTooltipText; + } + + const value = target.getAttribute(kTooltipTextAttribute); + return value || undefined; +} + +function setActiveTooltipText(tooltipText: string | undefined): void { + activeTooltipText = tooltipText; + if (!activeTooltipText) { + hidePageHoverTooltip(); + return; + } + + pageHoverTooltip?.text(activeTooltipText).show(); +} + +function moveTooltipBubble(event: MousePositionEvent): void { + if (!activeTooltipText) { + return; + } + + pageHoverTooltip?.css({ + left: event.clientX + 12, + top: event.clientY + 12, + }); +} + +function setupTooltipLocalization(): void { + if (tooltipLocalizationInitialized) { + return; + } + + tooltipLocalizationInitialized = true; + + theOneLocalizationManager + .asyncGetText("EditTab.Tooltip.Gutter", kDefaultGutterTooltipText, "") + .done((result) => { + gutterTooltipText = result; + updateGutterTooltipTexts(); + }); + + theOneLocalizationManager + .asyncGetText("EditTab.FormatDialog.Format", "Format", "") + .done((result) => { + formatDialogTitleTooltipText = result; + }); +} + +export function setupPageHoverTooltips(): void { + ensurePageHoverTooltipBubble(); + removeTooltipTargets(); + addGutterTooltipTargets(); + setupTooltipLocalization(); + + $(document) + .off("mouseenter.bloomPageTooltip") + .on( + "mouseenter.bloomPageTooltip", + `.${kTooltipTargetClass}, #formatButton`, + function (e) { + const target = this as HTMLElement; + setActiveTooltipText(getTooltipTextForTarget(target)); + moveTooltipBubble(e as unknown as MousePositionEvent); + }, + ) + .off("mousemove.bloomPageTooltip") + .on("mousemove.bloomPageTooltip", function (e) { + moveTooltipBubble(e as unknown as MousePositionEvent); + }) + .off("mouseleave.bloomPageTooltip") + .on( + "mouseleave.bloomPageTooltip", + `.${kTooltipTargetClass}, #formatButton`, + () => { + setActiveTooltipText(undefined); + }, + ); +} + +export function cleanupPageHoverTooltips(): void { + hidePageHoverTooltip(); + $(document).off(".bloomPageTooltip"); + removeTooltipTargets(); + pageHoverTooltip?.remove(); + pageHoverTooltip = undefined; +} + +function removePageHoverTooltipMarkupFrom(root: ParentNode): void { + root.querySelectorAll( + `[${kTooltipUiMarkerAttribute}="${kTooltipUiMarkerValue}"]`, + ).forEach((element) => { + element.remove(); + }); +} + +export function getBodyInnerHtmlWithoutPageHoverTooltips(): string { + const bodyClone = document.body.cloneNode(true) as HTMLElement; + removePageHoverTooltipMarkupFrom(bodyClone); + return bodyClone.innerHTML; +}