From 3ee8245e7dad9a9f48c5c9735494823a42abe54f Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 20 Mar 2026 17:04:23 -0500 Subject: [PATCH 1/5] Add format dialog for read-only custom fields (BL-15948) --- .../bookEdit/StyleEditor/StyleEditor.ts | 42 +++++++++++++++++-- .../js/CanvasElementContextControls.tsx | 31 +++++++++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts index 77f260cd9971..d4c136e5ca51 100644 --- a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts +++ b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts @@ -1151,6 +1151,34 @@ export default class StyleEditor { private uiLang: string; + // We allow read-only fields to have a style dialog on custom-layout pages + // for elements that have a -style class to edit. + public static shouldAllowNonEditableStyleDialogTarget( + targetBox: HTMLElement, + ): boolean { + if (targetBox.classList.contains("bloom-editable")) { + return false; + } + if (StyleEditor.GetStyleClassFromElement(targetBox) === null) { + return false; + } + return ( + targetBox.closest( + ".bloom-page.bloom-customLayout[data-custom-layout-id]", + ) !== null + ); + } + + // When we allow an element that's not a bloom-editable to have a format dialog, + // we always use the default (not langauge-dependent) version of the rules, + // since we don't have the structure of a translation group containing multiple + // bloom-editables that might need different formatting. + private targetUsesLanguageIndependentRules( + targetBox: HTMLElement, + ): boolean { + return StyleEditor.shouldAllowNonEditableStyleDialogTarget(targetBox); + } + public AttachToBox(targetBox: HTMLElement) { this.uiLang = theOneLocalizationManager.getCurrentUILocale(); @@ -1498,7 +1526,11 @@ export default class StyleEditor { // BL-5616 This also applies if the textbox's default language is '*', // like it is for an Arithmetic Equation. const tag = $(this.boxBeingEdited).attr("lang"); - if (this.shouldSetDefaultRule() || tag === "*") { + if ( + this.shouldSetDefaultRule() || + this.targetUsesLanguageIndependentRules(this.boxBeingEdited) || + tag === "*" + ) { theOneLocalizationManager .asyncGetText( "BookEditor.DefaultForText", @@ -2217,11 +2249,15 @@ export default class StyleEditor { if (!styleName) { return null; // bizarre, since we put up the dialog } - const langAttrValue = StyleEditor.GetLangValueOrNull(target); + const useLanguageIndependentRule = + ignoreLanguage || this.targetUsesLanguageIndependentRules(target); + const langAttrValue = useLanguageIndependentRule + ? null + : StyleEditor.GetLangValueOrNull(target); return this.GetOrCreateRuleForStyle( styleName, langAttrValue, - ignoreLanguage, + useLanguageIndependentRule, forChildPara, ); } diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index e0b35450fb84..ebcdd7cfdad6 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -73,6 +73,7 @@ import { } from "../toolbox/canvas/canvasElementUtils"; import { getString, post, useApiObject } from "../../utils/bloomApi"; import { ILanguageNameValues } from "../bookSettings/FieldVisibilityGroup"; +import StyleEditor from "../StyleEditor/StyleEditor"; interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { subMenu?: ILocalizableMenuItemProps[]; @@ -85,6 +86,24 @@ function isNavigationButton(canvasElement: HTMLElement) { return canvasElement.classList.contains(kBloomButtonClass); } +function getFormatTargetElement( + canvasElement: HTMLElement, +): HTMLElement | undefined { + const editable = canvasElement.getElementsByClassName( + "bloom-editable bloom-visibility-code-on", + )[0] as HTMLElement | undefined; + if (editable) { + return editable; + } + + const candidates = Array.from( + canvasElement.querySelectorAll("[class]"), + ) as HTMLElement[]; + return candidates.find((candidate) => + StyleEditor.shouldAllowNonEditableStyleDialogTarget(candidate), + ); +} + // This is the controls bar that appears beneath a canvas element when it is selected. It contains buttons // for the most common operations that apply to the canvas element in its current state, and a menu for less common // operations. @@ -356,6 +375,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ const editableTextElement = props.canvasElement.getElementsByClassName( "bloom-editable bloom-visibility-code-on", )[0] as HTMLElement; + const formatTargetElement = getFormatTargetElement(props.canvasElement); if (isNavButton) { menuOptions.splice(0, 0, { @@ -456,6 +476,13 @@ const CanvasElementContextControls: React.FunctionComponent<{ languageNameValues, noneLabel, ); + } else if (formatTargetElement && !isNavButton) { + menuOptions.push({ + l10nId: "EditTab.Toolbox.ComicTool.Options.Format", + english: "Format", + onClick: () => GetEditor().runFormatDialog(formatTargetElement), + icon: , + }); } const runMetadataDialog = () => { @@ -617,7 +644,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ )} )} - {editableTextElement && !isNavButton && ( + {formatTargetElement && !isNavButton && ( { if (!props.canvasElement) return; GetEditor().runFormatDialog( - editableTextElement, + formatTargetElement, ); }} /> From a24bef72f1de277c4120245431e24217aec7d2b7 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Mon, 23 Mar 2026 15:15:51 -0500 Subject: [PATCH 2/5] Remove cover-is-image and migrate to custom cover --- .../localization/en/BloomMediumPriority.xlf | 2 + .../bookSettings/BookSettingsDialog.tsx | 41 ----- src/BloomExe/Book/AppearanceSettings.cs | 20 +-- src/BloomExe/Book/Book.cs | 16 +- src/BloomExe/Book/BookStorage.cs | 96 ++++++++++- src/BloomExe/Book/XMatterHelper.cs | 29 +--- src/BloomExe/Book/XMatterPackFinder.cs | 1 - .../FeatureRegistry.cs | 16 -- src/BloomTests/Book/BookStorageTests.cs | 162 ++++++++++++++++++ src/BloomTests/Book/BookTests.cs | 38 ++++ src/BloomTests/Publish/PublishHelperTests.cs | 14 +- .../CoverIsImage-XMatter.pug | 8 - 12 files changed, 312 insertions(+), 131 deletions(-) delete mode 100644 src/content/templates/xMatter/CoverIsImage-XMatter/CoverIsImage-XMatter.pug diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 4d540aaf3a23..6e55ef939b95 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -769,6 +769,7 @@ Fill the front cover with a single image BookSettings.CoverIsImage + Obsolete as of Bloom 6.4 Using this option turns on the [Print Bleed](https://en.wikipedia.org/wiki/Bleed_%28printing%29) indicators on paper layouts. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit. @@ -778,6 +779,7 @@ Replace the front cover content with a single full-bleed image. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit. BookSettings.CoverIsImage.Description.V2 + Obsolete as of Bloom 6.4 Use full bleed page layout diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx b/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx index 270213542984..e218a040ef03 100644 --- a/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/bookSettings/BookSettingsDialog.tsx @@ -263,15 +263,6 @@ export const BookSettingsDialog: React.FunctionComponent<{ "BookSettings.Gutter.DefaultLabel", ); - const coverIsImageLabel = useL10n( - "Fill the front cover with a single image", - "BookSettings.CoverIsImage", - ); - const coverIsImageDescription = useL10n( - "Replace the front cover content with a single full-bleed image. See [Full Page Cover Images](https://docs.bloomlibrary.org/full-page-cover-images) for information on sizing your image to fit.", - "BookSettings.CoverIsImage.Description.V2", - ); - const fullBleedLabel = useL10n( "Use full bleed page layout", "BookSettings.FullBleed", @@ -389,9 +380,6 @@ export const BookSettingsDialog: React.FunctionComponent<{ setMigratedTheme(""); }; - const tierAllowsFullPageCoverImage = - useGetFeatureStatus("fullPageCoverImage")?.enabled; - const tierAllowsFullBleed = useGetFeatureStatus("PrintshopReady")?.enabled; function saveSettingsAndCloseDialog() { @@ -490,35 +478,6 @@ export const BookSettingsDialog: React.FunctionComponent<{ )} -
- ( - `coverIsImage`, - )} - disabled={ - appearanceDisabled || - !tierAllowsFullPageCoverImage - } - /> -
- -
-
property) Properties[property.Key] = property.Value; } - public bool CoverIsImage - { - get { return _properties.coverIsImage; } - } - public bool FullBleed { get { return _properties.fullBleed; } diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 1f6ca06d034a..4a8489cf1f04 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -1764,6 +1764,8 @@ out dummy /// public void EnsureUpToDateMemory(IProgress progress) { + Storage.CaptureInitialStateForMigration(); + string oldMetaData = ""; if (RobustFile.Exists(BookInfo.MetaDataPath)) { @@ -1852,6 +1854,7 @@ public void EnsureUpToDateMemory(IProgress progress) // but it takes almost no time when the book IS already up-to-date. // These methods work with the same book metadata to determine what migration has // already been done, so they must be called in exactly this order. + Storage.RestoreStuffBeforeMigration(); Storage.MigrateMaintenanceLevels(); Storage.MigrateToMediaLevel1ShrinkLargeImages(); Storage.MigrateToLevel2RemoveTransparentComicalSvgs(); @@ -1866,6 +1869,7 @@ public void EnsureUpToDateMemory(IProgress progress) // Migration 11 does not exist. Storage.MigrateToLevel12PageNumberPosition(); Storage.MigrateToLevel13SplitPaneMarginBoxes(); + Storage.MigrateToLevel14CoverIsImageToCustomLayout(_bookData); Storage.DoBackMigrations(); @@ -2562,8 +2566,7 @@ public void BringXmatterHtmlUpToDate(HtmlDom bookDOM) layout, BookInfo.UseDeviceXMatter, _bookData.MetadataLanguage1Tag, - oldIds, - CoverIsImage + oldIds ); foreach ( var page in bookDOM @@ -4125,15 +4128,6 @@ public void InsertFullBleedMarkup(SafeXmlElement body) } } - public bool CoverIsImage => - BookInfo.AppearanceSettings.CoverIsImage - && FeatureStatus - .GetFeatureStatus( - CollectionSettings.Subscription, - FeatureName.FullPageCoverImage, - this - ) - .Enabled; public bool FullBleed => PageSizeSupportsFullBleed() && BookInfo.AppearanceSettings.FullBleed diff --git a/src/BloomExe/Book/BookStorage.cs b/src/BloomExe/Book/BookStorage.cs index 238f88310af2..d77852c82ef1 100644 --- a/src/BloomExe/Book/BookStorage.cs +++ b/src/BloomExe/Book/BookStorage.cs @@ -102,6 +102,8 @@ void CleanupUnusedSupportFiles( bool LinkToLocalCollectionStyles { get; set; } IEnumerable GetActivityFolderNamesReferencedInBook(); + void CaptureInitialStateForMigration(); + void RestoreStuffBeforeMigration(); void MigrateMaintenanceLevels(); void MigrateToMediaLevel1ShrinkLargeImages(); void MigrateToLevel2RemoveTransparentComicalSvgs(); @@ -116,6 +118,7 @@ void CleanupUnusedSupportFiles( void MigrateToLevel10GameHeader(); void MigrateToLevel12PageNumberPosition(); // Level 11 was skipped. void MigrateToLevel13SplitPaneMarginBoxes(); + void MigrateToLevel14CoverIsImageToCustomLayout(BookData bookData = null); void DoBackMigrations(); @@ -185,12 +188,13 @@ public class BookStorage : IBookStorage /// Bloom 6.3 11 = unused migration (decided operation wasn't needed after 12 introduced) /// Bloom 6.3 12 = change appearance settings page number control to use page number position system /// Bloom 6.3 13 = fix split-pane-component margin boxes with positioning style + /// Bloom 6.4 14 = migrate legacy coverIsImage setting to custom-layout cover /// History of kMediaMaintenanceLevel (introduced in 6.0) /// missing: set it to 0 if maintenanceLevel is 0 or missing, otherwise 1 /// 0 = No media maintenance has been done /// Bloom 6.0: 1 = maintenanceLevel at least 1 (so images are opaque and not too big) /// - public const int kMaintenanceLevel = 13; + public const int kMaintenanceLevel = 14; public const int kMediaMaintenanceLevel = 1; public const string PrefixForCorruptHtmFiles = "_broken_"; @@ -3112,6 +3116,7 @@ private void LoadCurrentBrandingFilesIntoBookFolder() private string _cachedXmatterPackName; private HtmlDom _cachedXmatterDom; private BookInfo _cachedXmatterBookInfo; + private SafeXmlElement _preMigrationOutsideFrontCover; private XMatterHelper XMatterHelper { @@ -3933,6 +3938,53 @@ private int GetMaintenanceLevel() return level; } + public void CaptureInitialStateForMigration() + { + _preMigrationOutsideFrontCover = null; + if (GetMaintenanceLevel() >= 14) + return; + + var outsideFrontCover = Dom.SafeSelectNodes( + "//body//div[contains(@class,'bloom-page') and contains(@class,'outsideFrontCover')]" + ) + .Cast() + .FirstOrDefault(); + if (outsideFrontCover == null || !outsideFrontCover.HasClass("cover-is-image")) + return; + + _preMigrationOutsideFrontCover = outsideFrontCover.CloneNode(true) as SafeXmlElement; + } + + public void RestoreStuffBeforeMigration() + { + if (_preMigrationOutsideFrontCover == null) + return; + + var currentOutsideFrontCover = Dom.SafeSelectNodes( + "//body//div[contains(@class,'bloom-page') and contains(@class,'outsideFrontCover')]" + ) + .Cast() + .FirstOrDefault(); + if (currentOutsideFrontCover == null) + return; + + var preservedMarginBox = GetMarginBox(_preMigrationOutsideFrontCover); + var currentMarginBox = GetMarginBox(currentOutsideFrontCover); + if (preservedMarginBox == null || currentMarginBox == null) + return; + + while (currentMarginBox.HasChildNodes) + currentMarginBox.RemoveChild(currentMarginBox.ChildNodes[0]); + + var preservedChildren = preservedMarginBox.ChildNodes.Cast().ToList(); + foreach (var child in preservedChildren) + { + currentMarginBox.AppendChild( + currentMarginBox.OwnerDocument.ImportNode(child, true) + ); + } + } + /// /// Bloom 4.9 and later (a bit later than the above 4.9 and therefore a separate maintenance /// level) will only put comical-generated svgs in Bloom imageContainers if they are @@ -4521,6 +4573,48 @@ public void MigrateToLevel13SplitPaneMarginBoxes() Dom.UpdateMetaElement("maintenanceLevel", "13"); } + public void MigrateToLevel14CoverIsImageToCustomLayout(BookData bookData = null) + { + if (GetMaintenanceLevel() >= 14) + return; + + var hadLegacyCoverSetting = BookInfo.AppearanceSettings.Properties.Remove( + "coverIsImage" + ); + + if (hadLegacyCoverSetting) + { + var outsideFrontCover = Dom.SafeSelectNodes( + "//body//div[contains(@class,'bloom-page') and contains(@class,'outsideFrontCover')]" + ) + .Cast() + .FirstOrDefault(); + if (outsideFrontCover != null) + { + outsideFrontCover.RemoveClass("cover-is-image"); + outsideFrontCover.AddClass("bloom-customLayout"); + + BookInfo.AppearanceSettings.PendingChangeRequiresXmatterUpdate = true; + + if ( + outsideFrontCover.HasClass("bloom-customLayout") + && !string.IsNullOrEmpty( + outsideFrontCover.GetAttribute("data-custom-layout-id") + ) + ) + { + var onePageDom = new HtmlDom(""); + onePageDom.Body.AppendChild( + onePageDom.RawDom.ImportNode(outsideFrontCover, true) + ); + bookData.SuckInDataFromEditedDom(onePageDom, BookInfo); + } + } + } + + Dom.UpdateMetaElement("maintenanceLevel", "14"); + } + /// /// This method reduces the list of features to the most specific one, based largely on the /// subscription tier. If there are multiple features that are at the same subscription diff --git a/src/BloomExe/Book/XMatterHelper.cs b/src/BloomExe/Book/XMatterHelper.cs index 83b46211d675..af0a7e5fd280 100644 --- a/src/BloomExe/Book/XMatterHelper.cs +++ b/src/BloomExe/Book/XMatterHelper.cs @@ -22,7 +22,6 @@ public class XMatterHelper { private readonly HtmlDom _bookDom; private readonly string _nameOfXMatterPack; - private readonly IFileLocator _fileLocator; /// /// Constructs by finding the file and folder of the xmatter pack, given the its key name e.g. "Factory", "SILIndonesia". @@ -41,7 +40,6 @@ public XMatterHelper( bool useDeviceVersionIfAvailable = false ) { - _fileLocator = fileLocator; string directoryPath = null; _bookDom = bookDom; var bookSpecificXMatterPack = bookDom.GetMetaValue("xmatter", null); @@ -318,8 +316,7 @@ public void InjectXMatter( Layout layout, bool orderXmatterForDeviceUse, string metadataLangTag, - List oldIds = null, - bool coverIsImage = false + List oldIds = null ) { //don't want to pollute shells with this content @@ -361,29 +358,7 @@ SafeXmlElement xmatterPage in XMatterDom.SafeSelectNodes( ) ) { - var newPageSource = xmatterPage; - - // If we are using an image for the front cover, replace the typical front cover with - // a special one which has a full-page bloom-canvas. - if (coverIsImage && IsOutsideFrontCoverPage(xmatterPage)) - { - var directoryPath = GetXMatterDirectory( - "CoverIsImage", - _fileLocator, - null, - true - ); - var coverIsImageDom = XmlHtmlConverter.GetXmlDomFromHtmlFile( - directoryPath.CombineForPath("CoverIsImage-XMatter.html"), - false - ); - var coverIsImagePage = coverIsImageDom.SelectSingleNode( - "/html/body/div[contains(@data-page,'required')]" - ); - newPageSource = coverIsImagePage as SafeXmlElement; - } - - var newPageDiv = _bookDom.RawDom.ImportNode(newPageSource, true) as SafeXmlElement; + var newPageDiv = _bookDom.RawDom.ImportNode(xmatterPage, true) as SafeXmlElement; // If we're updating an existing book, we want to keep the IDs (as much as possible; sometimes // the number of xmatter pages changes and we have to add IDs). In this case, oldIds is obtained diff --git a/src/BloomExe/Book/XMatterPackFinder.cs b/src/BloomExe/Book/XMatterPackFinder.cs index e8897a00c3d4..5e5e68e9b57d 100644 --- a/src/BloomExe/Book/XMatterPackFinder.cs +++ b/src/BloomExe/Book/XMatterPackFinder.cs @@ -40,7 +40,6 @@ public IEnumerable All "sharp", "forunittest", "templatestarter", - "coverisimage", }; public IEnumerable GetXMattersToOfferInSettings( diff --git a/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs b/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs index 05dc1841dd17..166c23ce3c33 100644 --- a/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs +++ b/src/BloomExe/SubscriptionAndFeatures/FeatureRegistry.cs @@ -16,7 +16,6 @@ public enum FeatureName ViewBookHistory, // Sort of tied to team collections now, but nothing says it has to be in the future... Motion, Music, - FullPageCoverImage, // The whole front cover is one full-bleed image CustomXMatterPage, WholeTextBoxAudio, @@ -161,21 +160,6 @@ public static class FeatureRegistry PreventPublishingInUnsupportedMediums = PreventionMethod.Remove, }, new FeatureInfo - { - Feature = FeatureName.FullPageCoverImage, - SubscriptionTier = SubscriptionTier.Pro, - ExistsInPageXPath = "self::div[contains(@class,'cover-is-image')]", - // This disabling doesn't get a chance to work, because when we bring a book's XMatter - // up to date, we update the cover-is-image class to something consistent with both - // the AppearanceSettings.CoverIsImage and Subscription.HaveActiveSubscription. - // (The latter should be changed to something involving this feature, but even then, - // the class will be removed by the time we execute the code that processes these properties.) - PreventPublishingInDerivativeBooks = PreventionMethod.DisabledByModifyingDom, - PreventPublishingInOriginalBooks = PreventionMethod.DisabledByModifyingDom, - ClassesToRemoveToDisable = "cover-is-image no-margin-page", - L10NId = "BookSettings.CoverIsImage", - }, - new FeatureInfo { Feature = FeatureName.CustomXMatterPage, SubscriptionTier = SubscriptionTier.Pro, diff --git a/src/BloomTests/Book/BookStorageTests.cs b/src/BloomTests/Book/BookStorageTests.cs index 67e3ebcad096..14d5e9eaf791 100644 --- a/src/BloomTests/Book/BookStorageTests.cs +++ b/src/BloomTests/Book/BookStorageTests.cs @@ -2205,6 +2205,168 @@ public void PerformNecessaryMaintenanceOnBook_HandlesMultipleSVGs() ); } + [Test] + public void MigrateToLevel14CoverIsImageToCustomLayout_ConvertsCoverClassAndCompletes() + { + var storage = GetInitialStorageWithCustomHtml( + @" + + +
+" + ); + + storage.BookInfo.AppearanceSettings.UpdateFromDynamic( + new Newtonsoft.Json.Linq.JObject { ["coverIsImage"] = true } + ); + + storage.MigrateToLevel14CoverIsImageToCustomLayout(); + + var maintLevel = storage.Dom.GetMetaValue("maintenanceLevel", "0"); + Assert.That(maintLevel, Is.EqualTo("14")); + Assert.That( + storage.Dom.SafeSelectNodes("//div[contains(@class,'cover-is-image')]").Count, + Is.EqualTo(0) + ); + Assert.That( + storage.Dom.SafeSelectNodes("//div[contains(@class,'bloom-customLayout')]").Count, + Is.EqualTo(1) + ); + var appearanceProperties = (System.Collections.Generic.IDictionary) + storage.BookInfo.AppearanceSettings.TestOnlyPropertiesAccess; + Assert.That(appearanceProperties.ContainsKey("coverIsImage"), Is.False); + } + + [Test] + public void MigrateToLevel14CoverIsImageToCustomLayout_WhenClassMissingButSettingPresent_MarksOutsideFrontCoverCustomLayout() + { + var storage = GetInitialStorageWithCustomHtml( + @" + + +
+" + ); + + storage.BookInfo.AppearanceSettings.UpdateFromDynamic( + new Newtonsoft.Json.Linq.JObject { ["coverIsImage"] = true } + ); + + storage.MigrateToLevel14CoverIsImageToCustomLayout(); + + Assert.That( + storage + .Dom.SafeSelectNodes( + "//div[contains(@class,'outsideFrontCover') and contains(@class,'bloom-customLayout')]" + ) + .Count, + Is.EqualTo(1) + ); + var appearanceProperties = (System.Collections.Generic.IDictionary) + storage.BookInfo.AppearanceSettings.TestOnlyPropertiesAccess; + Assert.That(appearanceProperties.ContainsKey("coverIsImage"), Is.False); + } + + [Test] + public void RestoreStuffBeforeMigration_WhenLegacyCoverCaptured_RestoresOutsideFrontCoverMarginBoxContent() + { + var storage = GetInitialStorageWithCustomHtml( + @" + + +
+
original
+
+" + ); + + storage.CaptureInitialStateForMigration(); + + var currentOutsideFrontCover = storage + .Dom.SafeSelectNodes("//div[contains(@class,'outsideFrontCover')]") + .Cast() + .First(); + currentOutsideFrontCover.RemoveClass("cover-is-image"); + var currentMarginBox = BookStorage.GetMarginBox(currentOutsideFrontCover); + currentMarginBox.InnerXml = "
replacement
"; + + storage.RestoreStuffBeforeMigration(); + + Assert.That( + storage + .Dom.SafeSelectNodes( + "//div[contains(@class,'outsideFrontCover')]//div[contains(@class,'original-content')]" + ) + .Count, + Is.EqualTo(1) + ); + Assert.That( + storage + .Dom.SafeSelectNodes( + "//div[contains(@class,'outsideFrontCover')]//div[contains(@class,'replacement-content')]" + ) + .Count, + Is.EqualTo(0) + ); + } + + [Test] + public void RestoreStuffBeforeMigration_WhenNoLegacyCoverCaptured_DoesNothing() + { + var storage = GetInitialStorageWithCustomHtml( + @" + + +
+
replacement
+
+" + ); + + storage.CaptureInitialStateForMigration(); + storage.RestoreStuffBeforeMigration(); + + Assert.That( + storage + .Dom.SafeSelectNodes( + "//div[contains(@class,'outsideFrontCover')]//div[contains(@class,'replacement-content')]" + ) + .Count, + Is.EqualTo(1) + ); + } + + [Test] + public void MigrateToLevel14CoverIsImageToCustomLayout_WithBookData_SavesCustomLayoutContentInDataDiv() + { + var storage = GetInitialStorageWithCustomHtml( + @" + + +
+
+

Cover custom text

+
+" + ); + + storage.BookInfo.AppearanceSettings.UpdateFromDynamic( + new Newtonsoft.Json.Linq.JObject { ["coverIsImage"] = true } + ); + var bookData = new BookData(storage.Dom, storage.CollectionSettings, _ => { }); + + storage.MigrateToLevel14CoverIsImageToCustomLayout(bookData); + + Assert.That( + storage + .Dom.SafeSelectNodes( + "//div[@id='bloomDataDiv']/div[@data-book='outsideFrontCoverCustom' and @lang='*' and contains(.,'Cover custom text')]" + ) + .Count, + Is.EqualTo(1) + ); + } + [Test] public void PerformNecessaryMaintenanceOnBook_DoesNothingIfAlreadyProcessed() { diff --git a/src/BloomTests/Book/BookTests.cs b/src/BloomTests/Book/BookTests.cs index b54c9f062891..a762d6af162b 100644 --- a/src/BloomTests/Book/BookTests.cs +++ b/src/BloomTests/Book/BookTests.cs @@ -2460,6 +2460,44 @@ public void BringBookUpToDate_CustomLayoutPage_RemovesCustomLayoutClassWithoutSu ); } + [Test] + public void BringBookUpToDate_Level14Migration_SavesCustomOutsideFrontCoverToDataDiv() + { + _bookDom = new HtmlDom( + @" + + + + + + +
+
+
+
preserve me
+
+
+ + " + ); + + var book = CreateBook(); + + book.BringBookUpToDate(new NullProgress()); + + AssertThatXmlIn + .Dom(book.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customOutsideFrontCover' and @lang='*']//div[contains(@class,'migrated-marker') and text()='preserve me']", + 1 + ); + } + [Test] public void BringBookUpToDate_RepairQuestionsPages_DoesNotMessUpGoodPages() { diff --git a/src/BloomTests/Publish/PublishHelperTests.cs b/src/BloomTests/Publish/PublishHelperTests.cs index 3472a62b83c4..a3f55c33d733 100644 --- a/src/BloomTests/Publish/PublishHelperTests.cs +++ b/src/BloomTests/Publish/PublishHelperTests.cs @@ -1078,15 +1078,15 @@ public void RemovePagesByFeatureSystem_DoesNotRemoveFullCoverImagePages() } [Test] - public void RemoveClassesToDisableFeatures_RemovesClassesFromFullCoverImagePages() + public void RemoveClassesToDisableFeatures_RemovesClassesFromCustomXMatterPages() { var doc = SafeXmlDocument.Create(); doc.LoadXml( @"
-
-
Front Cover
+
+
Back Cover
-
+
" ); var pageElts = doc.SafeSelectNodes("//div[contains(@class,'bloom-page')]") @@ -1103,15 +1103,13 @@ public void RemoveClassesToDisableFeatures_RemovesClassesFromFullCoverImagePages AssertThatXmlIn .Element(pageElts[0]) .HasSpecifiedNumberOfMatchesForXpath( - "//div[@id='fullCoverImage' and @class='bloom-page cover coverColor bloom-frontMatter frontCover outsideFrontCover side-right Device16x9Landscape']", + "//div[@id='customBackCover' and @class='bloom-page outsideBackCover side-left']", 1 ); Assert.That( messages, Has.Member( - // Would like it to be this, but the localization manager doesn't work well enough in unit tests. - //@"The feature ""Fill the front cover with a single image"" was removed from this book because it requires a higher subscription tier" - @"The feature ""BookSettings.CoverIsImage"" was removed from this book because it requires a higher subscription tier" + @"The feature ""PageLayout.CustomXMatterPage"" was removed from this book because it requires a higher subscription tier" ) ); } diff --git a/src/content/templates/xMatter/CoverIsImage-XMatter/CoverIsImage-XMatter.pug b/src/content/templates/xMatter/CoverIsImage-XMatter/CoverIsImage-XMatter.pug deleted file mode 100644 index 71d8072a5644..000000000000 --- a/src/content/templates/xMatter/CoverIsImage-XMatter/CoverIsImage-XMatter.pug +++ /dev/null @@ -1,8 +0,0 @@ -include ../bloom-xmatter-mixins.pug - -doctype html -html - head - body - +page-cover('Front Cover')(data-export='front-matter-cover', data-xmatter-page='frontCover').cover-is-image.no-margin-page.frontCover.outsideFrontCover#b5169df5-6a40-4c52-bd30-4cab45afe0ed - +standard-cover-image.bloom-imageObjectFit-cover From d20c090042d1daa50938310035ad7f2ed241488c Mon Sep 17 00:00:00 2001 From: John Thomson Date: Mon, 23 Mar 2026 15:54:46 -0500 Subject: [PATCH 3/5] Select the thing that just became the background --- .../js/CanvasElementContextControls.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index ebcdd7cfdad6..187b088f5f51 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -1778,6 +1778,33 @@ function addImageMenuOptions( theOneCanvasElementManager.updateCanvasElementForChangedImage( bgImg, ); + + // We want to make it active. However, if it used to be a placeholder, it was + // previously hidden. This interferes with making the measurements to move the + // control frame (at least), so we make a few attempts to activate it. + // Don't make a long timeout here, because it could override some other + // selection that the user quickly makes. + const activateConvertedBackground = () => { + requestAnimationFrame(() => { + theOneCanvasElementManager.setActiveElement( + bgImageCe, + ); + }); + }; + activateConvertedBackground(); + if (!haveRealBgImage) { + const fallbackHandle = setTimeout(() => { + activateConvertedBackground(); + }, 120); + bgImg.addEventListener( + "load", + () => { + clearTimeout(fallbackHandle); + activateConvertedBackground(); + }, + { once: true }, + ); + } setMenuOpen(false); }, }); From 515d2ed630d3ef3f6176ffbb804ee42f90bcd388 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Tue, 24 Mar 2026 10:47:37 -0500 Subject: [PATCH 4/5] Make hint bubbles work on custom pages --- .../toolbox/canvas/customXmatterPage.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx index 927dd4e7534a..a26a519610c0 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx @@ -166,6 +166,9 @@ export async function convertXmatterPageToCustom( // where to put its new canvas element. ceContent = elem.cloneNode(true) as HTMLElement; } + + preserveHintMetadata(elem as HTMLElement, ceContent as HTMLElement); + // make a new canvas element to hold this. Not sure what problems it will // cause when it's NOT a TG, but we have at least topic and language name that // never are and are commonly on the front cover. @@ -232,6 +235,77 @@ async function getLanguageNameValues(): Promise { .data as ILanguageNameValues; } +function copyHintAttributes(source: Element, target: HTMLElement): void { + [ + "data-hint", + "data-i18n", + "data-link-text", + "data-link-target", + "data-functiononhintclick", + ].forEach((attr) => { + const value = source.getAttribute(attr); + if (value && !target.hasAttribute(attr)) { + target.setAttribute(attr, value); + } + }); +} + +function preserveHintMetadata( + sourceElement: HTMLElement, + convertedElement: HTMLElement, +): void { + // If we've already preserved label content or explicit hint metadata, leave it alone. + if ( + convertedElement.querySelector("label.bubble") || + convertedElement.hasAttribute("data-hint") + ) { + return; + } + + const translationGroup = sourceElement.closest( + ".bloom-translationGroup", + ) as HTMLElement | null; + if (!translationGroup) { + copyHintAttributes(sourceElement, convertedElement); + if ( + !convertedElement.hasAttribute("data-hint") && + sourceElement.parentElement + ) { + copyHintAttributes(sourceElement.parentElement, convertedElement); + } + return; + } + + copyHintAttributes(translationGroup, convertedElement); + + const label = translationGroup.querySelector("label.bubble"); + if (!label) { + if ( + !convertedElement.hasAttribute("data-hint") && + translationGroup.parentElement + ) { + copyHintAttributes( + translationGroup.parentElement, + convertedElement, + ); + } + return; + } + + const labelText = label.textContent?.trim(); + if (labelText && !convertedElement.hasAttribute("data-hint")) { + convertedElement.setAttribute("data-hint", labelText); + } + copyHintAttributes(label, convertedElement); + + if ( + !convertedElement.hasAttribute("data-hint") && + translationGroup.parentElement + ) { + copyHintAttributes(translationGroup.parentElement, convertedElement); + } +} + function setDataDefault( ceContent: HTMLElement, lang: string, From 23693b92f37b2a28c8ef08a1f06b4197567c642b Mon Sep 17 00:00:00 2001 From: John Thomson Date: Tue, 24 Mar 2026 11:23:42 -0500 Subject: [PATCH 5/5] post-review fixes --- .../js/CanvasElementContextControls.tsx | 5 +-- src/BloomExe/Book/BookStorage.cs | 13 ++++--- src/BloomTests/Book/BookStorageTests.cs | 35 +++++++++++++++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index 187b088f5f51..95b2018c3955 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -376,6 +376,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ "bloom-editable bloom-visibility-code-on", )[0] as HTMLElement; const formatTargetElement = getFormatTargetElement(props.canvasElement); + const showFormatButton = !!formatTargetElement && !isNavButton; if (isNavButton) { menuOptions.splice(0, 0, { @@ -644,7 +645,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ )} )} - {formatTargetElement && !isNavButton && ( + {showFormatButton && ( )} {(!(hasImage && isPlaceHolder) && - !editableTextElement && + !showFormatButton && !(hasVideo && !videoAlreadyChosen)) || ( // Add a spacer if there is any button before these
= 14) return; - var hadLegacyCoverSetting = BookInfo.AppearanceSettings.Properties.Remove( - "coverIsImage" - ); + var hadLegacyCoverSetting = + BookInfo.AppearanceSettings.TryGetBooleanPropertyValue( + "coverIsImage", + out var legacyCoverSettingValue + ) && legacyCoverSettingValue; + BookInfo.AppearanceSettings.Properties.Remove("coverIsImage"); if (hadLegacyCoverSetting) { diff --git a/src/BloomTests/Book/BookStorageTests.cs b/src/BloomTests/Book/BookStorageTests.cs index 14d5e9eaf791..df7a57c7c2ac 100644 --- a/src/BloomTests/Book/BookStorageTests.cs +++ b/src/BloomTests/Book/BookStorageTests.cs @@ -2220,7 +2220,7 @@ public void MigrateToLevel14CoverIsImageToCustomLayout_ConvertsCoverClassAndComp new Newtonsoft.Json.Linq.JObject { ["coverIsImage"] = true } ); - storage.MigrateToLevel14CoverIsImageToCustomLayout(); + storage.MigrateToLevel14CoverIsImageToCustomLayout(null); var maintLevel = storage.Dom.GetMetaValue("maintenanceLevel", "0"); Assert.That(maintLevel, Is.EqualTo("14")); @@ -2252,7 +2252,7 @@ public void MigrateToLevel14CoverIsImageToCustomLayout_WhenClassMissingButSettin new Newtonsoft.Json.Linq.JObject { ["coverIsImage"] = true } ); - storage.MigrateToLevel14CoverIsImageToCustomLayout(); + storage.MigrateToLevel14CoverIsImageToCustomLayout(null); Assert.That( storage @@ -2267,6 +2267,37 @@ public void MigrateToLevel14CoverIsImageToCustomLayout_WhenClassMissingButSettin Assert.That(appearanceProperties.ContainsKey("coverIsImage"), Is.False); } + [Test] + public void MigrateToLevel14CoverIsImageToCustomLayout_WhenSettingIsFalse_RemovesPropertyWithoutConvertingCover() + { + var storage = GetInitialStorageWithCustomHtml( + @" + + +
+" + ); + + storage.BookInfo.AppearanceSettings.UpdateFromDynamic( + new Newtonsoft.Json.Linq.JObject { ["coverIsImage"] = false } + ); + + storage.MigrateToLevel14CoverIsImageToCustomLayout(null); + + Assert.That( + storage.Dom.SafeSelectNodes("//div[contains(@class,'cover-is-image')]").Count, + Is.EqualTo(1) + ); + Assert.That( + storage.Dom.SafeSelectNodes("//div[contains(@class,'bloom-customLayout')]").Count, + Is.EqualTo(0) + ); + var appearanceProperties = (System.Collections.Generic.IDictionary) + storage.BookInfo.AppearanceSettings.TestOnlyPropertiesAccess; + Assert.That(appearanceProperties.ContainsKey("coverIsImage"), Is.False); + Assert.That(storage.Dom.GetMetaValue("maintenanceLevel", "0"), Is.EqualTo("14")); + } + [Test] public void RestoreStuffBeforeMigration_WhenLegacyCoverCaptured_RestoresOutsideFrontCoverMarginBoxContent() {