diff --git a/AGENTS.md b/AGENTS.md index 12c2f39bf4b9..799bfca1c8f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,3 +25,7 @@ The vscode terminal often loses the first character sent from copilot agents. So # Don't run build It is vital that you not run `yarn build` unless instructed to. If there is already a "--watch" build running, you will wreck it and waste the developer's time. You are welcome to `yarn lint` if you want to check for errors without building. + +# Localization +- Localizations for translatable strings are kept in DistFiles/localizations; new ones are initially added to one of the files in the "en" subdirectory +- Mark new XLF entries translate="no" diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 7095a6d09b97..fe19c8562b16 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -1337,6 +1337,18 @@ Copy Page ID: EditTab.CopyPage + + Standard + ID: EditTab.CustomCover.Standard + + + Cover Layout: + ID: EditTab.CustomCover.CoverLayout + + + Custom + ID: EditTab.CustomCover.Custom + Change Layout ID: EditTab.CustomPage.ChangeLayout @@ -2185,6 +2197,10 @@ ID: EditTab.Toolbox.ComicTool.Options.BackgroundColor.OldLace A background color that looks like old lace + + Become background + ID: EditTab.Toolbox.ComicTool.Options.BecomeBackground + Choose video from your computer... ID: EditTab.Toolbox.ComicTool.Options.ChooseVideo @@ -2193,11 +2209,19 @@ Copy text ID: EditTab.Toolbox.ComicTool.Options.CopyText + + Cover Image + ID: EditTab.Toolbox.ComicTool.Options.CoverImage + Duplicate ID: EditTab.Toolbox.ComicTool.Options.Duplicate Tooltip for the bubble Duplicate icon + + Field Type: + ID: EditTab.Toolbox.ComicTool.Options.FieldType + Fill Background ID: EditTab.Toolbox.ComicTool.Options.FillBackground @@ -2220,6 +2244,11 @@ ID: EditTab.Toolbox.ComicTool.Options.OuterOutlineColor Refers to the Outer Outline Color of the bubble used by the callout + + Language: + ID: EditTab.Toolbox.ComicTool.Options.Language + Used to switch which alternative of a text overlay is shown + None ID: EditTab.Toolbox.ComicTool.Options.OuterOutlineColor.None diff --git a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx b/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx index 6123fc94e4ab..c0203c2d2e63 100644 --- a/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx +++ b/src/BloomBrowserUI/bookEdit/bookSettings/FieldVisibilityGroup.tsx @@ -5,10 +5,13 @@ import { useApiObject } from "../../utils/bloomApi"; import { useL10n } from "../../react_components/l10nHooks"; import { useMemo } from "react"; -interface ILanguageNameValues { +export interface ILanguageNameValues { language1Name: string; + language1Tag: string; language2Name: string; + language2Tag: string; language3Name?: string; + language3Tag?: string; } // This is used in BookSettingsDialog to provide a group of Config-R booleans that control @@ -40,7 +43,9 @@ export const FieldVisibilityGroup: React.FunctionComponent<{ const languageNameValues: ILanguageNameValues = useApiObject("settings/languageNames", { language1Name: "", + language1Tag: "", language2Name: "", + language2Tag: "", }); const showWrittenLanguage1TitleLabel = useL10n( props.labelFrame, diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index 20ad00226eb6..09f5b9c26dca 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -67,7 +67,12 @@ import { GameType, getGameType } from "../toolbox/games/GameInfo"; import { setGeneratedDraggableId } from "../toolbox/canvas/CanvasElementItem"; import { editLinkGrid } from "./linkGrid"; import { showLinkTargetChooserDialog } from "../../react_components/LinkTargetChooser/LinkTargetChooserDialogLauncher"; -import { kBloomButtonClass } from "../toolbox/canvas/canvasElementUtils"; +import { + kBloomButtonClass, + kBloomCanvasSelector, +} from "../toolbox/canvas/canvasElementUtils"; +import { getString, post, useApiObject } from "../../utils/bloomApi"; +import { ILanguageNameValues } from "../bookSettings/FieldVisibilityGroup"; interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { subMenu?: ILocalizableMenuItemProps[]; @@ -153,6 +158,14 @@ const CanvasElementContextControls: React.FunctionComponent<{ } }; + const languageNameValues: ILanguageNameValues = + useApiObject("settings/languageNames", { + language1Name: "", + language1Tag: "", + language2Name: "", + language2Tag: "", + }); + const menuEl = useRef(); const noneLabel = useL10n("None", "EditTab.Toolbox.DragActivity.None", ""); @@ -160,6 +173,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ const chooseBooksLabel = useL10n( "Choose books...", "EditTab.Toolbox.CanvasTool.LinkGrid.ChooseBooks", + "", ); const currentDraggableTargetId = props.canvasElement?.getAttribute( @@ -357,6 +371,9 @@ const CanvasElementContextControls: React.FunctionComponent<{ menuOptions, editableTextElement, props.canvasElement, + props.setMenuOpen, + languageNameValues, + noneLabel, ); } } @@ -431,7 +448,14 @@ const CanvasElementContextControls: React.FunctionComponent<{ ); // and these for text boxes if (editableTextElement && !isNavButton) { - addTextMenuItems(menuOptions, editableTextElement, props.canvasElement); + addTextMenuItems( + menuOptions, + editableTextElement, + props.canvasElement, + props.setMenuOpen, + languageNameValues, + noneLabel, + ); } const runMetadataDialog = () => { @@ -905,6 +929,395 @@ const CanvasElementContextControls: React.FunctionComponent<{ } }; +// Fields that should get the bloom-contentFirst, Second, etc. classes managed by the appearance system. +// This is a pain. The proper way to know whether a field is controlled by the appearance system is that +// it has a data-visibility-variable attribute. We remove those when we make a custom page. +// The canvas elements that are actually on the page should be visible. We could copy it to some other attribute. +// But that would only work for the fields that were originally on the page. We want to be able to add new data-book fields. +// And there are certain clauses we need to add to such fields to give them the standard appearance. +// So until I get a better idea, I'm just putting in a hard-coded list. +const fieldsControlledByAppearanceSystem = ["bookTitle"]; + +function makeLanguageMenuItem( + ce: HTMLElement, + menuOptions: IMenuItemWithSubmenu[], + setMenuOpen: (open: boolean) => void, + languageNameValues: ILanguageNameValues, +) { + const tg = ce.getElementsByClassName( + "bloom-translationGroup", + )[0] as HTMLElement; + if (!tg) { + // no way to mess with language, leave this menu item out. + return; + } + const updateTg = ( + tg: HTMLElement, + langCode: string, + langName: string, + dataDefaultLang: string, + classes: string[], + appearanceClasses: string[], + ) => { + tg.setAttribute("data-default-languages", dataDefaultLang); + const editables = Array.from( + tg.getElementsByClassName("bloom-editable"), + ); + if (editables.length === 0) return; // not able to handle this yet. + let editableInLang = editables.find( + (e) => e.getAttribute("lang") === langCode, + ); + if (editableInLang) { + // remove editableInLang from the array editables + editables.splice(editables.indexOf(editableInLang), 1); + } else { + // This can happen for a newly created bubble + let editableToClone = editables.find( + (e) => e.getAttribute("lang") === "z", + ); + if (!editableToClone) { + // just clone the first one + editableToClone = editables[0]; + } + editableInLang = editableToClone.cloneNode(true) as HTMLElement; + // Remove classes generated by TranslationGroupManager that are specific to the original language. + Array.from(editableInLang.classList).forEach((c) => { + if (c.startsWith("bloom-content")) { + editableInLang!.classList.remove(c); + } + }); + editableInLang.innerHTML = "


"; // start in the usual empty state + editableInLang.setAttribute("lang", langCode); + editableInLang.setAttribute("data-languagetipcontent", langName); + tg.appendChild(editableInLang); + } + + // Ensure that we have the expected set of bloom-contentN and visibility classes. + // This duplicates some knowledge in the C# TranslationGroupManager class, + // but I don't see a good way to avoid that without reloading the page. + editableInLang.classList.add("bloom-visibility-code-on"); + for (const c of classes) { + editableInLang.classList.add(c); + } + const dataBookValue = editableInLang.getAttribute("data-book"); + if ( + dataBookValue && + fieldsControlledByAppearanceSystem.includes(dataBookValue) + ) { + for (const c of appearanceClasses) { + editableInLang.classList.add(c); + } + } + + // and conversely remove them from the others + for (const editable of editables) { + // Ensure visibility code is off for others. + editable.classList.remove("bloom-visibility-code-on"); + } + setMenuOpen(false); + }; + + const activeLangTag = tg + .getElementsByClassName("bloom-editable bloom-visibility-code-on")[0] + ?.getAttribute("lang"); + const subMenu: ILocalizableMenuItemProps[] = [ + { + l10nId: null, + english: languageNameValues.language1Name, + onClick: () => { + updateTg( + tg, + languageNameValues.language1Tag, + languageNameValues.language1Name, + "V", + ["bloom-content1"], + ["bloom-contentFirst"], + ); + }, + icon: activeLangTag === languageNameValues.language1Tag && ( + + ), + }, + ]; + + if ( + languageNameValues.language2Tag && + languageNameValues.language2Tag !== languageNameValues.language1Tag + ) { + subMenu.push({ + l10nId: null, + english: languageNameValues.language2Name, // todo: nice name + onClick: () => { + updateTg( + tg, + languageNameValues.language2Tag, + languageNameValues.language2Name, + "N1", + ["bloom-contentNational1"], + ["bloom-contentSecond"], + ); + }, + icon: activeLangTag === languageNameValues.language2Tag && ( + + ), + }); + } + if ( + languageNameValues.language3Tag && + languageNameValues.language3Tag !== languageNameValues.language1Tag && + languageNameValues.language3Tag !== languageNameValues.language2Tag + ) { + subMenu.push({ + l10nId: null, + english: languageNameValues.language3Name!, + onClick: () => { + updateTg( + tg, + languageNameValues.language3Tag!, + languageNameValues.language3Name!, + "N2", + ["bloom-contentNational2"], + ["bloom-contentThird"], + ); + }, + icon: activeLangTag === languageNameValues.language3Tag && ( + + ), + }); + } + if (subMenu.length < 2) return; // no choice we can make, leave this menu item out. + menuOptions.push({ + l10nId: "EditTab.Toolbox.ComicTool.Options.Language", + english: "Language:", + subMenu: subMenu, + onClick: () => {}, + }); +} + +const fieldTypeData: Array<{ + dataBook: string; + dataDerived: string; + label: string; + readOnly: boolean; + editableClasses: string[]; + classes: string[]; + hint?: string; + functionOnHintClick?: string; +}> = [ + { + dataBook: "bookTitle", + dataDerived: "", + label: "Book Title", + readOnly: false, + editableClasses: ["Title-On-Cover-style", "bloom-padForOverflow"], + classes: ["bookTitle"], + }, + { + dataBook: "smallCoverCredits", + dataDerived: "", + label: "Cover Credits", + readOnly: false, + editableClasses: ["smallCoverCredits", "Cover-Default-style"], + classes: [], + }, + { + dataBook: "", + dataDerived: "languagesOfBook", + label: "Languages", + readOnly: true, + editableClasses: [], + classes: ["coverBottomLangName", "Cover-Default-style"], + }, + { + dataBook: "", + dataDerived: "topic", + label: "Topic", + readOnly: true, + editableClasses: [], + classes: [ + "coverBottomBookTopic", + "bloom-userCannotModifyStyles", + "bloom-alwaysShowBubble", + "Cover-Default-style", + ], + hint: "Click to choose topic", + functionOnHintClick: "showTopicChooser", + }, +]; + +function makeFieldTypeMenuItem( + ce: HTMLElement, + menuOptions: IMenuItemWithSubmenu[], + setMenuOpen: (open: boolean) => void, + noneLabel: string, +) { + // We only do this on custom pages, at least for now + if (!ce.closest(".bloom-page")?.classList?.contains("bloom-custom-cover")) + return; + const tg = ce.getElementsByClassName( + "bloom-translationGroup", + )[0] as HTMLElement; + // This menu should currently only be made for bloom-editable elements which + // should be in translation groups, but just in case we generalize to include + // something that doesn't have one, let's not crash. + if (!tg) return; + + const clearFieldTypeClasses = () => { + for (const fieldType of fieldTypeData) { + for (const className of fieldType.classes) { + tg.classList.remove(className); + } + for (const editable of Array.from( + tg.getElementsByClassName("bloom-editable"), + )) { + editable.classList.remove(...fieldType.editableClasses); + } + } + }; + + const activeType = tg + .getElementsByClassName("bloom-editable bloom-visibility-code-on")[0] + ?.getAttribute("data-book"); + const subMenu: ILocalizableMenuItemProps[] = [ + { + l10nId: null, + english: noneLabel, + onClick: () => { + clearFieldTypeClasses(); + for (const editable of Array.from( + tg.getElementsByClassName("bloom-editable"), + )) { + editable.removeAttribute("data-book"); + } + setMenuOpen(false); + }, + icon: !activeType && , + }, + ]; + for (const fieldType of fieldTypeData) { + subMenu.push({ + l10nId: null, + english: fieldType.label, + onClick: () => { + clearFieldTypeClasses(); + const editables = Array.from( + tg.getElementsByClassName("bloom-editable"), + ); + if (fieldType.readOnly) { + const readOnlyDiv = document.createElement("div"); + readOnlyDiv.setAttribute( + "data-derived", + fieldType.dataDerived, + ); + if (fieldType.hint) { + readOnlyDiv.setAttribute("data-hint", fieldType.hint); + } + if (fieldType.functionOnHintClick) { + readOnlyDiv.setAttribute( + "data-functiononhintclick", + fieldType.functionOnHintClick, + ); + } + readOnlyDiv.classList.add(...fieldType.classes); + tg.parentElement!.insertBefore(readOnlyDiv, tg); + tg.remove(); + // Reload the page to get the derived content loaded. + post("common/saveChangesAndRethinkPageEvent", () => {}); + } else { + tg.classList.add(...fieldType.classes); + for (const editable of editables) { + editable.classList.add(...fieldType.editableClasses); + editable.setAttribute("data-book", fieldType.dataBook); + if ( + editable.classList.contains( + "bloom-visibility-code-on", + ) + ) { + getString( + `editView/getDataBookValue?lang=${editable.getAttribute("lang")}&dataBook=${fieldType.dataBook}`, + (content) => { + // content comes from a source that looked empty, we don't want to overwrite something the user may + // already have typed here. + // But it may well have something in it, because we usually have an empty paragraph to start with. + // To test whether it looks empty, we put the text into a newly created element and then + // see whether it's textContent is empty. + // The logic of overwriting something which the user has typed here is that if we keep what's here, + // then the user may never know that there was already something in that field. But if we overwrite, then + // the user can always correct it back to what he just typed. + const temp = document.createElement("div"); + temp.innerHTML = content || ""; + if (temp.textContent.trim() !== "") + editable.innerHTML = content; + }, + ); + } + } + } + setMenuOpen(false); + }, + icon: activeType === fieldType.dataBook && ( + + ), + }); + } + + menuOptions.push({ + l10nId: "EditTab.Toolbox.ComicTool.Options.FieldType", + english: "Field Type:", + subMenu: subMenu, + onClick: () => {}, + }); +} + +function makeImageFieldMenuItem( + canvasElement: HTMLElement, + img: HTMLElement, + menuOptions: IMenuItemWithSubmenu[], + setMenuOpen: (open: boolean, launchingDialog?: boolean) => void, +) { + // We only do this on custom pages, at least for now. + if ( + !canvasElement + .closest(".bloom-page") + ?.classList?.contains("bloom-custom-cover") + ) + return; + + const isCoverImage = img.getAttribute("data-book") === "coverImage"; + const subMenu: ILocalizableMenuItemProps[] = [ + { + l10nId: "EditTab.Toolbox.ComicTool.Options.CoverImage", + english: "Cover Image", + onClick: () => { + if (isCoverImage) { + img.removeAttribute("data-book"); + } else { + const page = canvasElement.closest( + ".bloom-page", + ) as HTMLElement; + for (const existingCoverImage of Array.from( + page.querySelectorAll('img[data-book="coverImage"]'), + )) { + if (existingCoverImage !== img) { + existingCoverImage.removeAttribute("data-book"); + } + } + img.setAttribute("data-book", "coverImage"); + } + setMenuOpen(false); + }, + icon: isCoverImage && , + }, + ]; + + menuOptions.push({ + l10nId: "EditTab.Toolbox.ComicTool.Options.FieldType", + english: "Field Type:", + subMenu: subMenu, + onClick: () => {}, + }); +} + const buttonWidth = "22px"; const ButtonWithTooltip: React.FunctionComponent<{ @@ -999,6 +1412,9 @@ function addTextMenuItems( menuOptions: IMenuItemWithSubmenu[], editable: HTMLElement, canvasElement: HTMLElement, + setMenuOpen: (open: boolean) => void, + languageNameValues: ILanguageNameValues, + noneLabel: string, ) { const autoHeight = !canvasElement.classList.contains("bloom-noAutoHeight"); const toggleAutoHeight = () => { @@ -1045,7 +1461,15 @@ function addTextMenuItems( icon: autoHeight && , }); } + menuOptions.push(...textMenuItem); + makeLanguageMenuItem( + canvasElement, + menuOptions, + setMenuOpen, + languageNameValues, + ); + makeFieldTypeMenuItem(canvasElement, menuOptions, setMenuOpen, noneLabel); } function addVideoMenuItems( @@ -1237,8 +1661,85 @@ function addImageMenuOptions( ); }, }); + if (realImagePresent) { + imageMenuOptions.push({ + l10nId: "EditTab.Toolbox.ComicTool.Options.BecomeBackground", + english: "Become Background", + onClick: () => { + const bloomCanvas = canvasElement.closest( + kBloomCanvasSelector, + ) as HTMLElement; + const bgImageCe = bloomCanvas.getElementsByClassName( + kBackgroundImageClass, + )[0] as HTMLElement; + if (!bgImageCe) return; // paranoia + const bgImgContainer = bgImageCe.getElementsByClassName( + kImageContainerClass, + )[0] as HTMLElement; + const bgImg = bgImgContainer?.getElementsByTagName( + "img", + )[0] as HTMLImageElement; + if (!bgImg) return; + const haveRealBgImage = hasRealImage(bgImg); + const currentImgSrce = img.getAttribute("src") || ""; + const currentCopyright = img.getAttribute("data-copyright"); + const currentCreator = img.getAttribute("data-creator"); + const currentLicense = img.getAttribute("data-license"); + const currentDataBook = img.getAttribute("data-book"); + + if (haveRealBgImage) { + img.setAttribute( + "src", + bgImg.getAttribute("src") || "", + ); + img.setAttribute( + "data-copyright", + bgImg.getAttribute("data-copyright") || "", + ); + img.setAttribute( + "data-creator", + bgImg.getAttribute("data-creator") || "", + ); + img.setAttribute( + "data-license", + bgImg.getAttribute("data-license") || "", + ); + theOneCanvasElementManager.updateCanvasElementForChangedImage( + img, + ); + } else { + // delete the image that we turned into the background; there's no + // real image to swap with it. + theOneCanvasElementManager.deleteCurrentCanvasElement(); + } + + bgImg.src = currentImgSrce; + bgImg.setAttribute( + "data-copyright", + currentCopyright || "", + ); + bgImg.setAttribute("data-creator", currentCreator || ""); + bgImg.setAttribute("data-license", currentLicense || ""); + // Not sure how or if this should generalize. When we make a custom cover, + // the cover image is initially a moveable canvas element with data-book=coverImage. + // Changing it to a background image should preserve that. + // However, if we then create another image overlay and make THAT the background, + // I think we still want the cover background to be the cover image. So we will not + // remove an existing data-book value if the current image doesn't have one. + if (currentDataBook) { + bgImg.setAttribute("data-book", currentDataBook); + } + // review: do we want to preserve cropping? Also when going the other way? + theOneCanvasElementManager.updateCanvasElementForChangedImage( + bgImg, + ); + }, + }); + } } + makeImageFieldMenuItem(canvasElement, img, imageMenuOptions, setMenuOpen); + menuOptions.unshift(...imageMenuOptions); } diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts b/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts index 9ae5722241b5..cec4f2e8cd32 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts @@ -3173,11 +3173,10 @@ export class CanvasElementManager { kCanvasElementSelector, ) as HTMLDivElement; topBox.style.color = ""; - const editables = topBox.getElementsByClassName("bloom-editable"); - for (let i = 0; i < editables.length; i++) { - const editableElement = editables[i] as HTMLElement; - editableElement.style.color = hexOrRgbColor; - } + const textColorTargets = this.getTextColorTargets(topBox); + textColorTargets.forEach((target) => { + target.style.color = hexOrRgbColor; + }); } public getTextColorInformation(): ITextColorInfo { @@ -3197,20 +3196,19 @@ export class CanvasElementManager { // bloom-editables. So if the canvas element div didn't have a color, check the inner // bloom-editables. if (textColor === "") { - const editables = - topBox.getElementsByClassName("bloom-editable"); - if (editables.length === 0) { + const textColorTargets = this.getTextColorTargets(topBox); + if (textColorTargets.length === 0) { // Image on Image case comes here. isDefaultStyleColor = true; textColor = "black"; } else { - const firstEditable = editables[0] as HTMLElement; - const colorStyle = firstEditable.style.color; + const firstTextTarget = textColorTargets[0]; + const colorStyle = firstTextTarget.style.color; if (colorStyle) { textColor = colorStyle; } else { textColor = - this.getDefaultStyleTextColor(firstEditable); + this.getDefaultStyleTextColor(firstTextTarget); isDefaultStyleColor = true; } } @@ -3219,11 +3217,28 @@ export class CanvasElementManager { return { color: textColor, isDefault: isDefaultStyleColor }; } + private getTextColorTargets(topBox: HTMLElement): HTMLElement[] { + const editables = Array.from( + topBox.getElementsByClassName("bloom-editable"), + ) as HTMLElement[]; + if (editables.length > 0) { + return editables; + } + + const derivedTargets = Array.from( + topBox.querySelectorAll("[data-derived]"), + ) as HTMLElement[]; + if (topBox.hasAttribute("data-derived")) { + derivedTargets.unshift(topBox); + } + return derivedTargets; + } + // Returns the computed color of the text, which in the absence of a color style from the // Canvas element Tool will be from the Bubble-style (set in the StyleEditor). // An unfortunate, but greatly simplifying, use of JQuery. - public getDefaultStyleTextColor(firstEditable: HTMLElement): string { - return $(firstEditable).css("color"); + public getDefaultStyleTextColor(textElement: HTMLElement): string { + return $(textElement).css("color"); } // This gives us the patriarch (farthest ancestor) canvas element of a family of canvas elements. @@ -4809,24 +4824,6 @@ export class CanvasElementManager { ); } - public addCanvasElementWithScreenCoords( - screenX: number, - screenY: number, - canvasElementType: CanvasElementType, - userDefinedStyleName?: string, - rightTopOffset?: string, - ): HTMLElement | undefined { - const clientX = screenX - window.screenX; - const clientY = screenY - window.screenY; - return this.addCanvasElement( - clientX, - clientY, - canvasElementType, - userDefinedStyleName, - rightTopOffset, - ); - } - private addCanvasElementFromOriginal( offsetX: number, offsetY: number, @@ -4929,16 +4926,18 @@ export class CanvasElementManager { // Don't add a canvas element if we can't find the containing bloom-canvas. return undefined; } - // initial mouseX, mouseY coordinates are relative to viewport - const positionInViewport = new Point( - mouseX, - mouseY, + // initial mouseX, mouseY coordinates are relative to viewport. We want relative to bloom-canvas. + const rect = bloomCanvas[0].getBoundingClientRect(); + const requestedPositionInCanvas = new Point( + mouseX - rect.left, + mouseY - rect.top, PointScaling.Scaled, - "Scaled Viewport coordinates", + "Scaled bloom-canvas coordinates", ); + // Adjust so it's more certain to be IN the bloom-canvas. const positionInBloomCanvas = this.adjustRelativePointToBloomCanvas( bloomCanvas[0], - positionInViewport, + requestedPositionInCanvas, ); if (canvasElementType === "video") { return this.addVideoCanvasElement( diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 461adf3a7444..abbfcb9fb492 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -67,6 +67,7 @@ import { setupBookLinkGrids } from "./linkGrid"; import PlaceholderProvider from "./PlaceholderProvider"; import { initChoiceWidgetsForEditing } from "./simpleComprehensionQuiz"; import { handleUndo } from "../editViewFrame"; +import { setupCoverMenu } from "../toolbox/canvas/customCover"; // Allows toolbox code to make an element properly in the context of this iframe. export function makeElement( @@ -478,6 +479,45 @@ function hasOrigami(container: HTMLElement) { return container.getElementsByClassName("split-pane-component").length > 0; } +function prepareSourceAndHintBubbles(container: HTMLElement): { + divsThatHaveSourceBubbles: HTMLElement[]; + bubbleDivs: any[]; +} { + // Copy source texts out to their own div, where we can make a bubble with tabs out of them + // We do this because if we made a bubble out of the div, that would suck up the vernacular editable area, too. + const divsThatHaveSourceBubbles: HTMLElement[] = []; + const bubbleDivs: any[] = []; + if ($(container).find(".bloom-preventSourceBubbles").length === 0) { + $(container) + .find("*.bloom-translationGroup") + .not(".bloom-readOnlyInTranslationMode") + .each(function () { + if ($(this).find("textarea, div").length > 1) { + const bubble = + BloomSourceBubbles.ProduceSourceBubbles(this); + if (bubble.length !== 0) { + divsThatHaveSourceBubbles.push(this); + bubbleDivs.push(bubble); + } + } + }); + } + + // NB: this should be after the ProduceSourceBubbles(), because hint-bubbles are lower priority + // and should not show if we already have a source bubble. (Eventually we may make the hint part + // of the source bubble when there is one...Bl-4295.) This would happen with the Book Title, which + // would have both when there are source languages to show. + BloomHintBubbles.addHintBubbles( + container, + divsThatHaveSourceBubbles, + bubbleDivs, + ); + + PlaceholderProvider.addPlaceholders(container); + + return { divsThatHaveSourceBubbles, bubbleDivs }; +} + // Originally, all this code was in document.load and the selectors were acting // on all elements (not bound by the container). I added the container bound so we // can add new elements (such as during layout mode) and call this on only newly added elements. @@ -727,38 +767,8 @@ export function SetupElements( setupBookLinkGrids(container); - // Copy source texts out to their own div, where we can make a bubble with tabs out of them - // We do this because if we made a bubble out of the div, that would suck up the vernacular editable area, too, - const divsThatHaveSourceBubbles: HTMLElement[] = []; - const bubbleDivs: any[] = []; - if ($(container).find(".bloom-preventSourceBubbles").length === 0) { - $(container) - .find("*.bloom-translationGroup") - .not(".bloom-readOnlyInTranslationMode") - .each(function () { - if ($(this).find("textarea, div").length > 1) { - const bubble = - BloomSourceBubbles.ProduceSourceBubbles(this); - if (bubble.length !== 0) { - divsThatHaveSourceBubbles.push(this); - bubbleDivs.push(bubble); - } - } - }); - } - - //NB: this should be after the ProduceSourceBubbles(), because hint-bubbles are lower - // priority, and should not show if we already have a source bubble. - // (Eventually we may make the hint part of the source bubble when there is one...Bl-4295.) - // This would happen with the Book Title, which would have both - // when there are source languages to show - BloomHintBubbles.addHintBubbles( - container, - divsThatHaveSourceBubbles, - bubbleDivs, - ); - - PlaceholderProvider.addPlaceholders(container); + const { divsThatHaveSourceBubbles, bubbleDivs } = + prepareSourceAndHintBubbles(container); // We seem to need a delay to get a reliable result in BloomSourceBubbles.MakeSourceBubblesIntoQtips() // as it calls BloomSourceBubbles.CreateAndShowQtipBubbleFromDiv(), which ends by calling @@ -766,12 +776,7 @@ export function SetupElements( // For getting focus set reliably, it seems best to do this whole loop inside one delay, rather than // have separate delays invoked each time through the loop. setTimeout(() => { - for (let i = 0; i < bubbleDivs.length; i++) { - BloomSourceBubbles.MakeSourceBubblesIntoQtips( - divsThatHaveSourceBubbles[i], - bubbleDivs[i], - ); - } + makeSourceBubblesIntoQtips(bubbleDivs, divsThatHaveSourceBubbles); BloomSourceBubbles.setupSizeChangedHandling(divsThatHaveSourceBubbles); if (theOneCanvasElementManager.isCanvasElementEditingOn) { // If we saved the page with an indication that a particular element should be @@ -935,6 +940,27 @@ export function SetupElements( ConstrainContentsOfPageLabel(container); } +function makeSourceBubblesIntoQtips( + bubbleDivs: any[], + divsThatHaveSourceBubbles: HTMLElement[], +) { + for (let i = 0; i < bubbleDivs.length; i++) { + BloomSourceBubbles.MakeSourceBubblesIntoQtips( + divsThatHaveSourceBubbles[i], + bubbleDivs[i], + ); + } +} + +export function recomputeSourceBubblesForPage(container: HTMLElement) { + const { divsThatHaveSourceBubbles, bubbleDivs } = + prepareSourceAndHintBubbles(container); + setTimeout(() => { + makeSourceBubblesIntoQtips(bubbleDivs, divsThatHaveSourceBubbles); + BloomSourceBubbles.setupSizeChangedHandling(divsThatHaveSourceBubbles); + }, 100); +} + // This function sets up a rule to display a prompt following the placeholder we insert for a missing // "originalTitle" element. It is displayed using CSS :after so we don't have to modify the DOM to // make it appear, which would risk having it show up in published books. We insert the CSS dynamically @@ -1029,6 +1055,7 @@ function OneTimeSetup() { setupOrigami(); hookupLinkHandler(); setupDragActivityTabControl(); + setupCoverMenu(); } interface String { @@ -1043,10 +1070,14 @@ function isTextSelected(): boolean { let reportedTextSelected = isTextSelected(); +let inactiveMarginBox: HTMLElement | undefined; + // --------------------------------------------------------------------------------- // called inside document ready function // --------------------------------------------------------------------------------- export function bootstrap() { + hideInactiveMarginBox(); + bloomQtipUtils.setQtipZindex(); $.fn.reverse = function (...args) { @@ -1275,6 +1306,8 @@ function requestPageContentInternal() { // The toolbox is in a separate iframe, hence the call to getToolboxBundleExports(). getToolboxBundleExports()?.removeToolboxMarkup(); removeEditingDebris(); // Enhance this makes a change when better it would only changed the + restoreInactiveMarginBox(); + const content = getBodyContentForSavePage(); const userStylesheet = userStylesheetContent(); postString( @@ -1300,7 +1333,57 @@ function requestPageContentInternal() { ); } } +export function hideInactiveMarginBox() { + // If we have two margin boxes and are only showing one, it simplifies things to remove the inactive one. + // Things that search the whole document won't unintentionally find things there, + // nor modify them in unwanted ways because they aren't visible (and hence have zero size, etc). + // We will put the inactive one back unchanged when we save the page. + const customMarginBox = document.getElementsByClassName( + "bloom-customMarginBox", + )[0] as HTMLElement | undefined; + if (customMarginBox) { + const page = document.getElementsByClassName( + "bloom-page", + )[0] as HTMLElement; + if (page.classList.contains("bloom-custom-cover")) { + inactiveMarginBox = Array.from( + customMarginBox.parentElement!.getElementsByClassName( + "marginBox", + ), + ).find( + (mb) => !mb.classList.contains("bloom-customMarginBox"), + ) as HTMLElement; + } else { + inactiveMarginBox = customMarginBox; + } + // There should always be an inactive margin box, and if there is it will + // certainly have a parent, but if somehow there isn't we don't need to crash. + inactiveMarginBox?.parentElement?.removeChild(inactiveMarginBox); + } +} +export function restoreInactiveMarginBox(): HTMLElement | undefined { + if (!inactiveMarginBox) return undefined; + const marginBox = document.getElementsByClassName( + "marginBox", + )[0] as HTMLElement; + // I'm not sure whether it matters to keep them in the expected order, + // but it doesn't cost much and removes one possible source of confusion. + if (marginBox.classList.contains("bloom-customMarginBox")) { + // put the inactive one back before it + marginBox.parentElement!.insertBefore(inactiveMarginBox, marginBox); + } else { + // put the inactive one back after it + marginBox.parentElement!.insertBefore( + inactiveMarginBox, + marginBox.nextSibling, + ); + } + // Clear the variable so we don't accidentally put it back twice if something goes wrong and we call this again. + const result = inactiveMarginBox; + inactiveMarginBox = undefined; + return result; +} // Caution: We don't want this to become an async method because we don't want // any other event handlers running between cleaning up the page and // getting the content to save. (Or think hard before changing that.) diff --git a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx index fc793c9e79cf..12c825d54f26 100644 --- a/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx +++ b/src/BloomBrowserUI/bookEdit/sourceBubbles/BloomSourceBubbles.tsx @@ -41,15 +41,15 @@ export default class BloomSourceBubbles { return BloomSourceBubbles.MakeSourceTextDivForGroup(group, newLangTag); } - // remvoe tooltips from every TG in the container (and the container itself, if it IS a TG). + // remove tooltips from everything in the container (and the container itself, if it has one). + // warning: this will remove qtips even if they were not created by our source bubble or hint + // bubble code. public static removeSourceBubbles( container: HTMLElement | null | undefined, ): void { if (!container) return; // saves every client checking (often only because of eslint) - const groups = Array.from( - container.getElementsByClassName("bloom-translationGroup"), - ); - if (container.classList.contains("bloom-translationGroup")) { + const groups = Array.from(container.querySelectorAll("[data-hasqtip]")); + if (container.hasAttribute("data-hasqtip")) { groups.push(container); } groups.forEach((group) => { diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasElementItem.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasElementItem.tsx index 5cc5a180981c..fbb0838e6067 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasElementItem.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasElementItem.tsx @@ -104,9 +104,9 @@ const ondragend = ( } } - const canvasElement = canvasElementManager.addCanvasElementWithScreenCoords( - ev.screenX, - ev.screenY, + const canvasElement = canvasElementManager.addCanvasElement( + ev.clientX, + ev.clientY, canvasElementType, userDefinedStyleName, rightTopOffset, diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasToolControls.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasToolControls.tsx index c8f4fd5805cd..77b6560a3b4d 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasToolControls.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasToolControls.tsx @@ -96,7 +96,13 @@ const CanvasToolControls: React.FunctionComponent = () => { const [imageFillMode, setImageFillMode] = useState( kImageFillModePaddedValue, ); - const [isXmatter, setIsXmatter] = useState(true); + // This is generally true if the page is xmatter (tools are forbidden). + // There is a special case where it is false for custom front cover (tools are allowed). + // Currently it will be true for game pages, although in fact we + // don't currently allow it to be used; it is partly historical, + // and partly we want to show a special message if someone tries to use it there. + const [pageTypeForbidsCanvasTools, setPageTypeForbidsCanvasTools] = + useState(true); // This 'counter' increments on new page ready so we can re-check if the book is locked. const [pageRefreshIndicator, setPageRefreshIndicator] = useState(0); @@ -174,7 +180,9 @@ const CanvasToolControls: React.FunctionComponent = () => { if (CanvasTool.theOneCanvasTool) { CanvasTool.theOneCanvasTool.callOnNewPageReady = () => { bubbleSpecInitialization(); - setIsXmatter(ToolboxToolReactAdaptor.isXmatter()); + setPageTypeForbidsCanvasTools( + ToolboxToolReactAdaptor.isXmatter(true), + ); const count = pageRefreshIndicator; setPageRefreshIndicator(count + 1); }; @@ -922,7 +930,11 @@ const CanvasToolControls: React.FunctionComponent = () => { > {
+import * as ReactDOM from "react-dom"; +import { CustomCoverMenu } from "./customCoverMenu"; +import { + CanvasElementManager, + kBackgroundImageClass, + theOneCanvasElementManager, +} from "../../js/CanvasElementManager"; +import { EditableDivUtils } from "../../js/editableDivUtils"; +import { kBloomCanvasClass, kCanvasElementClass } from "./canvasElementUtils"; +import { getAsync, postString } from "../../../utils/bloomApi"; +import { Bubble, BubbleSpec } from "comicaljs"; +import { + hideInactiveMarginBox, + recomputeSourceBubblesForPage, + restoreInactiveMarginBox, +} from "../../js/bloomEditing"; +import BloomSourceBubbles from "../../sourceBubbles/BloomSourceBubbles"; +import { getToolboxBundleExports } from "../../js/bloomFrames"; +import { ILanguageNameValues } from "../../bookSettings/FieldVisibilityGroup"; + +/* Summary of how custom covers work + - the pug for a standard front cover now has two margin boxes, one with class + bloom-customMarginBox and one without. One is hidden and the other is visible + (or in edit mode, one or the other is deleted except during operations that + manipulate them, then restored when the page is saved. I decided to do this + because various code searching the document was finding elements in the hidden + marginBox...for example, code might try to initially focus a box there. I didn't + want to have to keep on and on changing things to filter stuff in the inactive one). + - the content of the custom cover is stored in the bloom-customMarginBox. + The auto-layout content is kept in the regular marginBox. It's easy to switch + which is shown, and our data-book mechanism keeps things that should match + in sync. + - a custom page is indicated by the presence of the class + bloom-custom-cover on the bloom-page div. + - a custom cover page margin box has a single bloom-canvas div, + with one or more bloom-canvas-element divs inside it. + - when first created, we make a canvas element for each text and image + element that was on the cover page. + - each canvas element is absolutely positioned to (initially) match where the content + was on the page before conversion. + - where multiple languages of something can be shown (e.g., title), we make + a canvas element for each language that was visible at the time of conversion. + We disable any appearance system visibility control and put a value in + data-default-languages to indicate which of the collection languages + should be shown there. + - kludge: so that such elements keep their appearance-system default sizes, + we keep the appearance-system special classes like bloom-contentSecond. + C# code is also patched not to remove these. + - either the regular or custom margin box is visible, dependinng on whether + the page has bloom-custom-cover. + - the customMarginBox has data-book="customCover", so its entire content + is saved in the data-div. We change the data-div copy a bit so that + operations looking for all elements of certain kinds in the DOM don't + find these data-div copies (even in older versions of Bloom). + - previous versions of Bloom don't have a bloom-customMarginBox on their + template cover pages, so they will just go away when the book is brought + up to date there. But the data will survive in the data-div, so it will + come back if the newer Bloom does a bring-book-up-to-date. + - it's important that we save data-book values out of the visible marginBox. + marginBox is marked data-ignore="bloom-custom-cover", and the custom margin box + with data-ignore="!bloom-custom-cover" to trigger this behavior in the C# + BookData code. + - it's important that we restore the content of the custom margin box + before restoring any of the elements it contains, since if there has been + editing of elements like title in auto mode (or an older Bloom), we want + to end up with the edited content. The custom margin box has a class + bloom-contains-child-data to tell the BookData code to restore it in + a first pass. +*/ + +export function convertCoverPageToCustom( + page: HTMLElement, + startOver = false, +): void { + theOneCanvasElementManager?.turnOffCanvasElementEditing(); + // we need to get rid of the old ones before we switch things around, + // since the remove code makes use of the existing divs that the + // source bubbles are connected to. + BloomSourceBubbles.removeSourceBubbles(page); + + const customMarginBox = restoreInactiveMarginBox(); + if (!customMarginBox) { + return; // current xmatter doesn't support this + } + const marginBox = Array.from( + customMarginBox.parentElement!.getElementsByClassName("marginBox"), + ).find((mb) => mb !== customMarginBox) as HTMLElement; + if (!marginBox) return; // paranoia and lint + if (customMarginBox.children.length > 0) { + if (startOver) { + // remove existing content, and carry on to create new content from scratch. + // We don't particularly need to save the page, since we're copying everything + // from the current page anyway. + customMarginBox.innerHTML = ""; + } else { + // We already have a custom page saved away, but we need to do a full save and + // update process to get our book data elements transferred from one version + // of the page to the other. + // We'll leave it up to toggleCustomCover to actually change the class on + // the front cover because we need to save things in the current state. + // This will reload the page, so nothing else that happens here matters. + postString("editView/toggleCustomCover", page.getAttribute("id")!); + return; + } + } + + const contentElements = Array.from( + marginBox.querySelectorAll("[data-book], [data-derived]"), + ); + const newCanvasElements: HTMLElement[] = []; + const newCeImages: HTMLElement[] = []; + + for (const elem of contentElements) { + if (elem instanceof HTMLImageElement) { + // The main cover image. We will clone it, but it will no longer be + // a background image. + const ce = elem.parentElement?.parentElement; + if (ce && ce.classList.contains(kCanvasElementClass)) { + const newCe = ce.cloneNode(true) as HTMLElement; + newCeImages.push(newCe); + newCe.classList.remove(kBackgroundImageClass); + // set its top and left to where it currently is relative to the page + const pageRect = page.getBoundingClientRect(); + // base this on the original! + const tgRect = ce.getBoundingClientRect(); + const scale = EditableDivUtils.getPageScale(); + newCe.style.left = (tgRect.left - pageRect.left) / scale + "px"; + newCe.style.top = (tgRect.top - pageRect.top) / scale + "px"; + } + } else { + let ceContent = elem; + let baseSizeOn = elem; + if (elem.classList.contains("bloom-editable")) { + const tg = elem.parentElement; + if (!tg || !tg.classList.contains("bloom-translationGroup")) { + continue; + } + // we'll only make a CE for bloom-editables that are visible. + // (the image description is a special case, it is typically not visible because rules hide the + // containing TG) + if ( + !elem.classList.contains("bloom-visibility-code-on") || + tg.classList.contains("bloom-imageDescription") + ) { + continue; + } + + ceContent = tg.cloneNode(true) as HTMLElement; + const lang = elem.getAttribute("lang"); + const newEditable = ceContent.querySelector( + `.bloom-editable[lang='${lang}']`, + ) as HTMLElement; + + // Now, we need to make the CE display just the one bloom-editable that it + // was made for. + for (const e of Array.from( + ceContent.getElementsByClassName("bloom-editable"), + )) { + if (e !== newEditable) { + e.classList.remove("bloom-visibility-code-on"); + } + } + // We don't need to await this. We just want it done before the next time + // we save the page, and the async result should arrive almost instantly. + setDataDefault(ceContent as HTMLElement, lang || ""); + + // Don't let the appearance system mess with which languages are visible here. + ceContent.removeAttribute("data-visibility-variable"); + + newEditable.classList.add("bloom-visibility-code-on"); + // I don't know why an editable that is not part of a CE yet would have one of these, + // but if it does it is obsolete and will interfere with the position we're setting + // here. (Maybe I only saw it on pages I'd already messed with?) + Array.from( + ceContent.querySelectorAll("[data-bubble-alternate]"), + ).forEach((e) => { + e.removeAttribute("data-bubble-alternate"); + }); + } else { + // This is debatable. But I think having empty CEs around for things like optional + // branding elements is not helpful. + if (ceContent.clientHeight === 0 || ceContent.clientWidth === 0) + continue; // invisible, skip it. + // tempting to move it, but we don't want to mess with the current page + // until we finish the loop, so that nothing moves before we figure out + // where to put its new canvas element. + ceContent = elem.cloneNode(true) 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. + const newCe = document.createElement("div"); + newCe.classList.add(kCanvasElementClass); + newCanvasElements.push(newCe); + newCe.appendChild(ceContent); + // Before we move the tg, measure its size and make newCe match + // Review: do we need to add allowance for borders/margins/padding? + newCe.style.width = baseSizeOn.clientWidth + "px"; + newCe.style.height = baseSizeOn.clientHeight + "px"; + // set its top and left to where it currently is relative to the page + const pageRect = page.getBoundingClientRect(); + const tgRect = baseSizeOn.getBoundingClientRect(); + const scale = EditableDivUtils.getPageScale(); + newCe.style.left = (tgRect.left - pageRect.left) / scale + "px"; + newCe.style.top = (tgRect.top - pageRect.top) / scale + "px"; + } + } + const mainCanvas = document.createElement("div"); + mainCanvas.classList.add(kBloomCanvasClass); + mainCanvas.classList.add("bloom-has-canvas-element"); + // We'll put the new images before (and with lower bubble index) than text, since + // we may want to put text over images, and clicks should prefer the text. + let level = 2; // level 1 will be the background image + for (const newCE of newCeImages.concat(newCanvasElements)) { + mainCanvas.appendChild(newCE); + // as each one is added, we give it a bubble spec. getDefaultBubbleSpec + // assigns a new 'level' to each, which is important for how our mouse event + // handler figures out z-ordering and makes sure comicaljs doesn't think they + // are a family of connected bubbles. + const bubble = new Bubble(newCE); + const bubbleSpec: BubbleSpec = Bubble.getDefaultBubbleSpec( + newCE, + "none", + ); + bubbleSpec.level = level; + level++; + bubble.setBubbleSpec(bubbleSpec); + } + + customMarginBox.appendChild(mainCanvas); + // This needs to be after we measure positions of things! + page.classList.add("bloom-custom-cover"); + finishReactivatingPage(page); +} + +async function setDataDefault( + ceContent: HTMLElement, + lang: string, +): Promise { + // We also want it to stay that way when C# code later updates visibility codes. + // This is based on a setting in the TG. Using these generic codes means that if + // the collection languages change, or we make a derivative, the right language + // should still be made visible in each box. + // settings/languageNames + const languageNameValues = (await getAsync("settings/languageNames")) + .data as ILanguageNameValues; + if (languageNameValues.language1Tag === lang) { + ceContent.setAttribute("data-default-languages", "V"); + } else if (languageNameValues.language2Tag === lang) { + ceContent.setAttribute("data-default-languages", "N1"); + } else if (languageNameValues.language3Tag === lang) { + ceContent.setAttribute("data-default-languages", "N2"); + } +} + +function finishReactivatingPage(page: HTMLElement): void { + hideInactiveMarginBox(); // don't want to create source bubbles and canvases in it! + // The divs that originally held source bubbles are gone + recomputeSourceBubblesForPage(page); + // it's a new canvas so we need to set it up + theOneCanvasElementManager.turnOnCanvasElementEditing(); + ensureDerivedFieldsFitOnCustomCover(page); + getToolboxBundleExports()?.applyToolboxStateToPage(); +} + +function ensureDerivedFieldsFitOnCustomCover(page: HTMLElement): void { + if (!page.classList.contains("bloom-custom-cover")) { + return; + } + + const bloomCanvas = page.getElementsByClassName( + kBloomCanvasClass, + )[0] as HTMLElement; + if (!bloomCanvas) { + return; + } + + const derivedElements = Array.from( + bloomCanvas.querySelectorAll("div[data-derived]"), + ) as HTMLElement[]; + for (const derivedElement of derivedElements) { + const canvasElement = derivedElement.closest( + `.${kCanvasElementClass}`, + ) as HTMLElement; + if (!canvasElement) { + continue; + } + + const currentWidth = CanvasElementManager.pxToNumber( + canvasElement.style.width, + ); + const currentHeight = CanvasElementManager.pxToNumber( + canvasElement.style.height, + ); + if (currentWidth <= 0 || currentHeight <= 0) { + continue; + } + + const getRenderedHeight = (): number => + Math.ceil(derivedElement.getBoundingClientRect().height); + const getRenderedWidth = (): number => + Math.ceil( + Math.max( + derivedElement.getBoundingClientRect().width, + derivedElement.offsetWidth, + ), + ); + + const overflowsVertically = (): boolean => + getRenderedHeight() > currentHeight + 1; + const overflowsHorizontally = (): boolean => { + const containerWidth = CanvasElementManager.pxToNumber( + canvasElement.style.width, + canvasElement.clientWidth, + ); + return getRenderedWidth() > containerWidth + 1; + }; + const hasOverflow = (): boolean => + overflowsVertically() || overflowsHorizontally(); + + const oldWhiteSpace = derivedElement.style.whiteSpace; + derivedElement.style.whiteSpace = "normal"; + + let fittedWidth = currentWidth; + if (hasOverflow()) { + derivedElement.style.whiteSpace = "nowrap"; + const noWrapWidth = Math.max(currentWidth, getRenderedWidth()); + derivedElement.style.whiteSpace = "normal"; + + canvasElement.style.width = `${noWrapWidth}px`; + if (hasOverflow()) { + fittedWidth = noWrapWidth; + } else { + let low = currentWidth; + let high = noWrapWidth; + while (high - low > 1) { + const mid = Math.floor((low + high) / 2); + canvasElement.style.width = `${mid}px`; + if (hasOverflow()) { + low = mid; + } else { + high = mid; + } + } + fittedWidth = high; + } + } + derivedElement.style.whiteSpace = oldWhiteSpace; + + if (fittedWidth > currentWidth) { + canvasElement.style.width = `${fittedWidth}px`; + } else { + canvasElement.style.width = `${currentWidth}px`; + } + + const finalWidth = Math.max(currentWidth, fittedWidth); + const maxLeft = Math.max(0, bloomCanvas.clientWidth - finalWidth); + const maxTop = Math.max(0, bloomCanvas.clientHeight - currentHeight); + const clampedLeft = Math.max( + 0, + Math.min(canvasElement.offsetLeft, maxLeft), + ); + const clampedTop = Math.max( + 0, + Math.min(canvasElement.offsetTop, maxTop), + ); + if (clampedLeft !== canvasElement.offsetLeft) { + canvasElement.style.left = `${clampedLeft}px`; + } + if (clampedTop !== canvasElement.offsetTop) { + canvasElement.style.top = `${clampedTop}px`; + } + } + + theOneCanvasElementManager?.ensureCanvasElementsIntersectParent( + bloomCanvas, + ); +} + +export function setupCoverMenu(): void { + const page = document.getElementsByClassName("bloom-page")[0]; + // currently only the outside front cover can be switched between auto and custom + if (!page || !page.classList.contains("outsideFrontCover")) return; + + const usingLegacyTheme = isUsingLegacyTheme(); + if (usingLegacyTheme && page.classList.contains("bloom-custom-cover")) { + postString("editView/toggleCustomCover", page.getAttribute("id")!); + return; + } + + // Create the container if needed (which it usually will be, because the cover + // is not a customPage and doesn't get one automatically). This duplicates + // (but without jquery) some code in origami.ts + let container: HTMLElement | undefined = document.getElementsByClassName( + "above-page-control-container", + )[0] as HTMLElement; + if (!container) { + container = document.createElement("div") as HTMLElement; + container.classList.add("above-page-control-container"); + container.classList.add("bloom-ui"); + container.style.maxWidth = page.clientWidth + "px"; + // see commment in origami.ts about why we put it first. + // the code there puts it at the start of #page-scaling-container, but that + // is always the parent of .bloom-page, so this is equivalent. + page.parentElement?.insertBefore( + container, + page.parentElement.firstChild, + ); + } + + renderCoverMenu(page as HTMLElement, container as HTMLElement); + + if (page.classList.contains("bloom-custom-cover")) { + ensureDerivedFieldsFitOnCustomCover(page as HTMLElement); + } +} + +function renderCoverMenu(page: HTMLElement, container: HTMLElement): void { + // Render a customCoverMenu React component into this container + const isCustomCover = page.classList.contains("bloom-custom-cover"); + const usingLegacyTheme = isUsingLegacyTheme(); + ReactDOM.render( + { + if (usingLegacyTheme && selection !== "standard") { + return; + } + if (selection === "standard") { + // We'll leave it up to toggleCustomCover to actually change the class on + // the front cover because we need to save things in the current state. + // This will reload the page, so nothing else that happens here matters. + postString( + "editView/toggleCustomCover", + page.getAttribute("id")!, + ); + } else { + const startOver = selection === "customStartOver"; + convertCoverPageToCustom(page, startOver); + } + renderCoverMenu(page, container); + }} + />, + container, + ); +} + +function isUsingLegacyTheme(): boolean { + return !!document.querySelector("link[href*='basePage-legacy-5-6.css']"); +} + +// Todo: +// - Test how books that have this open in 6.3, and whether this damages +// things in 6.4. May need some of the TranslationGroupManager changes +// in 6.3, and to prevent earlier 6.3's from opening such books. Concern is +// that the DataDiv element containing the custom cover may be found as the +// first source of fields, causing bring-book-up-to-date to revert to the last +// version of such fields saved by 6.4. Try changing xmatter and branding +// using an older Bloom...I noted a suspicion that it could mess up font sizes +// on the custom cover. +// - Lots of testing. For example, any issues with deriving a book from one +// with a custom cover? +// - code that is looking for the main cover image (e.g., to make a thumbnail) +// should find any image on the cover (maybe the largest?) if there isn't one +// marked as the cover image.) +// - Field should offer topic as well as language list. +// - Field should offer to make an image "the cover image". +// - cropping does not survive "become background image". Maybe this is to be expected, +// given that it is likely to change shape? +// - Do we want to do any auto-sizing of read-only fields (languages and topic)? +// - Make sure the appropriate Canvas controls (and only those) are enabled for +// read-only fields. For example, we should be able to change colors...not sure +// about others. +// - Think about CSS class and method names. John wants to be able to convert origami +// pages (one-way, more-or-less) to "custom" pages with a single canvas and +// child elements. I don't think this will involve keeping both versions around +// like we're doing for the cover; more like a new page type, only for "change layout", +// that results in converting all the page content to canvas elements. +// - page thumbnail for front cover is not always showing custom layout diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customCoverMenu.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customCoverMenu.tsx new file mode 100644 index 000000000000..c77388de2384 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customCoverMenu.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import type { SelectChangeEvent } from "@mui/material/Select"; +import { css, ThemeProvider } from "@emotion/react"; +import { Select, MenuItem, ListItemIcon } from "@mui/material"; +import CheckIcon from "@mui/icons-material/Check"; +import { useL10n } from "../../../react_components/l10nHooks"; +import { toolboxMenuPopupTheme } from "../../../bloomMaterialUITheme"; +import { kBloomPurple } from "../../../utils/colorUtils"; +import { Span } from "../../../react_components/l10nComponents"; + +export const CustomCoverMenu: React.FunctionComponent<{ + isCustom: boolean; + disableCustomCover?: boolean; + setCustom: (value: "standard" | "custom" | "customStartOver") => void; +}> = (props) => { + const standardLabel = useL10n("Standard", "EditTab.CustomCover.Standard"); + const customLabel = useL10n("Custom", "EditTab.CustomCover.Custom"); + + const handleChange = (event: SelectChangeEvent) => { + let selection = event.target.value as + | "standard" + | "custom" + | "customStartOver"; + // If custom is selected with shift+ctrl held, trigger startOver behavior + // TypeScript thinks the argument should be a SelectChangeEvent in order to pass + // the function as the onChange handler for a Select, but in fact it always + // comes in as a PointerEvent which has the keyboard modifier info we need. + const pointerEvent = event as unknown as PointerEvent; + if ( + selection === "custom" && + pointerEvent.shiftKey && + pointerEvent.ctrlKey + ) { + selection = "customStartOver"; + } + props.setCustom(selection); + }; + + const renderMenuItem = ( + value: "standard" | "custom", + label: string, + checked: boolean, + disabled?: boolean, + ) => { + return ( + + + + + {label} + + ); + }; + + return ( + +
+ + Cover Layout: + + +
+
+ ); +}; diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx b/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx index d73315278200..31fe2156ce24 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx @@ -95,12 +95,19 @@ export default abstract class ToolboxToolReactAdaptor page.setAttribute(name, encodeURIComponent(unencodedValue)); } - public static isXmatter(): boolean { + public static isXmatter(returnFalseForCustomCover = false): boolean { const pageClass = this.getBloomPageAttrDecoded("class"); - return !pageClass - ? false // paranoia - : pageClass.indexOf("bloom-frontMatter") >= 0 || - pageClass.indexOf("bloom-backMatter") >= 0; + if (!pageClass) return false; // paranoia + if ( + returnFalseForCustomCover && + pageClass.indexOf("bloom-custom-cover") >= 0 + ) { + return false; + } + return ( + pageClass.indexOf("bloom-frontMatter") >= 0 || + pageClass.indexOf("bloom-backMatter") >= 0 + ); } public static isCurrentPageABloomGame(): boolean { diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 1bbbe03b9ffe..853c1e8c0a8f 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -1298,6 +1298,13 @@ private void FixDuplicateAudioIdsInDataDiv(HtmlDom bookDOM, HashSet idSe return; // shouldn't happen, but paranoia sometimes pays off, especially in running tests. var nodes = dataDiv .SafeSelectNodes("(.//div|.//span)[@id and contains(@class,'audio-sentence')]") + // We expect the customCover to have copies of other stuff in the data div. + .Where(x => + !( + x is SafeXmlElement e + && e.ParentWithAttributeValue("data-book", "customCover") != null + ) + ) .ToList(); var duplicateAudioIdsFixed = 0; foreach (var audioElement in nodes) @@ -2484,6 +2491,10 @@ public void BringXmatterHtmlUpToDate(HtmlDom bookDOM) // Various things, especially publication, don't work with unknown page sizes. Layout layout = Layout.FromDomAndChoices(bookDOM, Layout.A5Portrait, fileLocator); var oldIds = new List(); + var frontCover = + RawDom.SelectSingleNode("/html/body/div[contains(@class, 'outsideFrontCover')]") + as SafeXmlElement; + var frontCoverWasCustom = frontCover?.HasClass("bloom-custom-cover") ?? false; XMatterHelper.RemoveExistingXMatter(bookDOM, oldIds); // this says, if you can't figure out the page size, use the one we got before we removed the xmatter... // still requiring it to be a valid layout. @@ -2496,7 +2507,13 @@ public void BringXmatterHtmlUpToDate(HtmlDom bookDOM) oldIds, CoverIsImage ); - + frontCover = + RawDom.SelectSingleNode("/html/body/div[contains(@class, 'outsideFrontCover')]") + as SafeXmlElement; + if (frontCoverWasCustom) + { + frontCover?.AddClass("bloom-custom-cover"); + } var dataBookLangs = bookDOM.GatherDataBookLanguages(); TranslationGroupManager.PrepareDataBookTranslationGroups(bookDOM.RawDom, dataBookLangs); @@ -4688,7 +4705,8 @@ public void CopyImageMetadataToWholeBookAndSave(Metadata metadata, IProgress pro BookStorage.ShowAccessDeniedErrorReport(e); return; // Probably not much point to saving if copying the image metadata didn't fully complete successfully } - Save(); + // This function is always called on a publication we are creating + Save(true); } public Metadata GetLicenseMetadata() @@ -4733,7 +4751,7 @@ bool OkToChangeFileAndFolderName } } - public void Save() + public void Save(bool forPublication = false) { // If you add something here, consider whether it is needed in SaveForPageChanged(). // I believe all the things currently here before the actual Save are not needed @@ -4757,10 +4775,18 @@ public void Save() RemoveObsoleteSoundAttributes(OurHtmlDom); RemoveVideoWarnings(); - // Note that at this point _bookData has already been updated with the edited page's data, if any. - // This will take priority over other data it finds in the book, even earlier in the book - // than the edited page. - _bookData.UpdateVariablesAndDataDivThroughDOM(BookInfo); //will update the title if needed + if (!forPublication) + { + // Note that at this point _bookData has already been updated with the edited page's data, if any. + // This will take priority over other data it finds in the book, even earlier in the book + // than the edited page. + // We don't need to do this if we're saving a temporary DOM for making a publication, + // since we already did it once when first creating the temporary book, and doing it + // again might wipe out intentional changes (e.g., where the src of coverImage has been + // changed for cropping). + _bookData.UpdateVariablesAndDataDivThroughDOM(BookInfo); //will update the title if needed + } + if (OkToChangeFileAndFolderName) { Storage.UpdateBookFileAndFolderName(CollectionSettings); //which will update the file name if needed @@ -5172,19 +5198,63 @@ public string GetCoverImagePathAndElt(out SafeXmlElement coverImgElt) coverImgElt = null; if (Storage == null) return null; // can happen in tests - // This first branch covers the currently obsolete approach to images using background-image. - // In that approach the data-book attribute is on the imageContainer. - // Note that we want the coverImage from a page, instead of the dataDiv because the former - // "doesn't have the data in the form that GetImageElementUrl can handle." - coverImgElt = Storage - .Dom.SafeSelectNodes("//div[not(@id='bloomDataDiv')]/div[@data-book='coverImage']") + var outsideFrontCover = Storage + .Dom.SafeSelectNodes( + "//div[contains(concat(' ', normalize-space(@class), ' '), ' outsideFrontCover ')]" + ) .Cast() .FirstOrDefault(); - // If that fails, we look for an img with the relevant attribute. Happily this doesn't conflict with the data-div. + + if (outsideFrontCover == null) + return null; + + var isCustomCover = outsideFrontCover + .GetAttribute("class") + .Contains("bloom-custom-cover"); + + var coverSearchRoot = outsideFrontCover; + if (isCustomCover) + { + coverSearchRoot = outsideFrontCover + .SafeSelectNodes( + ".//div[contains(concat(' ', normalize-space(@class), ' '), ' bloom-customMarginBox ')]" + ) + .Cast() + .FirstOrDefault(); + + if (coverSearchRoot == null) + coverSearchRoot = outsideFrontCover; + } + + // Prefer an img in the outsideFrontCover. This is the current expected shape. + coverImgElt = coverSearchRoot + .SafeSelectNodes(".//img[@data-book='coverImage']") + .Cast() + .FirstOrDefault(); + + // Fall back to the obsolete background-image approach where data-book is on a div. if (coverImgElt == null) { - coverImgElt = Storage - .Dom.SafeSelectNodes("//img[@data-book='coverImage']") + coverImgElt = coverSearchRoot + .SafeSelectNodes(".//div[@data-book='coverImage']") + .Cast() + .FirstOrDefault(); + } + + if (coverImgElt == null && isCustomCover) + { + coverImgElt = coverSearchRoot + .SafeSelectNodes(".//img") + .Cast() + .FirstOrDefault(); + } + + if (coverImgElt == null && isCustomCover) + { + coverImgElt = coverSearchRoot + .SafeSelectNodes( + ".//div[contains(translate(@style, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'background-image')]" + ) .Cast() .FirstOrDefault(); } diff --git a/src/BloomExe/Book/BookData.cs b/src/BloomExe/Book/BookData.cs index e7605c717d67..4e95098f6890 100644 --- a/src/BloomExe/Book/BookData.cs +++ b/src/BloomExe/Book/BookData.cs @@ -74,6 +74,24 @@ public class BookData /// private const string kDataXmatterPage = "data-xmatter-page"; + private static readonly string[] _nestedDataAttributes = + { + "data-book", + "data-derived", + "data-collection", + "data-library", + kDataXmatterPage, + }; + + private static readonly Dictionary _inactiveClassMap = new Dictionary< + string, + string + > + { + { "bloom-translationGroup", "tg-inactive" }, + { "bloom-editable", "edit-inactive" }, + }; + private readonly HtmlDom _dom; private readonly Action _updateImgNode; internal readonly CollectionSettings CollectionSettings; @@ -779,81 +797,89 @@ public void SetupDisplayOfLanguagesOfBook(DataSet data = null) /// private void SetUpDisplayOfTopicInBook(DataSet data, BookInfo info = null) { - var topicPageElement = this._dom.SelectSingleNode("//div[@data-derived='topic']"); - if (topicPageElement == null) + var topicPageElements = this._dom.SafeSelectNodes("//div[@data-derived='topic']"); + if (topicPageElements.Length == 0) { //old-style. here we don't have the data-derived, so we need to avoid picking from the datadiv - topicPageElement = this._dom.SelectSingleNode( + topicPageElements = this._dom.SafeSelectNodes( "//div[not(id='bloomDataDiv')]//div[@data-book='topic']" ); - if (topicPageElement == null) + if (topicPageElements.Length == 0) { - //most unit tests do not have complete books, so this not surprising. It just means we don't have anything to do + //most unit tests do not have complete books, so this is not surprising. It just means we don't have anything to do return; } } - //clear it out what's there now - topicPageElement.RemoveAttribute("lang"); - topicPageElement.InnerText = ""; + // Come up with the string, if any, that we are going to put in the visible + // topic element(s). (There be more than one, if custom covers are in use.) DataSetElementValue topicData; + string englishTopic = null; // default means we don't have a topic + string bestTranslation = null; + string langOfTopicToShowOnCover = "en"; // should never be used not updated. + if (data.TextVariables.TryGetValue("topic", out topicData)) + { + //we use English as the "key" for topics. + englishTopic = topicData.TextAlternatives.GetExactAlternative("en"); + if (!string.IsNullOrEmpty(englishTopic) && englishTopic != "NoTopic") + { + var stringId = "Topics." + englishTopic; + + var tagsInPriorityOrder = GetLanguagePrioritiesForLocalizedTextOnPage(); + langOfTopicToShowOnCover = + tagsInPriorityOrder.FirstOrDefault(t => + LocalizationManager.GetIsStringAvailableForLangId(stringId, t) + ) ?? "en"; + + bestTranslation = LocalizationManager.GetDynamicStringOrEnglish( + "Bloom", + stringId, + englishTopic, + "this is a book topic", + langOfTopicToShowOnCover + ); - var parentOfTopicDisplayElement = ((SafeXmlElement)(topicPageElement.ParentNode)); - //this just lets us have css rules that vary if there is a topic (allows other text to be centered instead left-aligned) - //we'll change it later if we find there is a topic - parentOfTopicDisplayElement.SetAttribute("data-have-topic", "false"); - - //if we have no topic element in the data-div - //leave the field in the page with an empty text. - if (!data.TextVariables.TryGetValue("topic", out topicData)) - { - return; + //NB: in a unit test environment, GetDynamicStringOrEnglish is going to give us the id back, which is annoying. + if (bestTranslation == stringId) + bestTranslation = englishTopic; + } } - //we use English as the "key" for topics. - var englishTopic = topicData.TextAlternatives.GetExactAlternative("en"); + foreach (SafeXmlElement topicPageElement in topicPageElements) + { + //clear it out what's there now + topicPageElement.RemoveAttribute("lang"); + topicPageElement.InnerText = ""; - //if we have no topic, just clear it out from the page - if (string.IsNullOrEmpty(englishTopic) || englishTopic == "NoTopic") - return; + var parentOfTopicDisplayElement = ((SafeXmlElement)(topicPageElement.ParentNode)); + //this just lets us have css rules that vary if there is a topic (allows other text to be centered instead left-aligned) + //we'll change it later if we find there is a topic + parentOfTopicDisplayElement.SetAttribute("data-have-topic", "false"); - // Even if we have a topic, if we don't want to show it on the page, - // we don't want this attribute set to true. - // This was a tough call. The main effect of data-have-topic being false is to center the - // language name. We do want that to happen when the topic is hidden, as well as when it - // is not set. So does it make more sense to have code here take into account the - // appearance setting, or should the CSS know about both reasons for centering? - // I don't see a strong reason either way, so I let it be decided by the fact that - // there's no obvious way in CSS to get the centering behavior (done by setting both - // margins to auto) based on whether --cover-topic-show is 'none' or - // 'doShow-css-will-ignore-this-and-use-default'. So data-have-topic could plausibly - // be renamed data-show-topic (or we could just use a class), but I'm not sure what - // backwards compatibility issues that might cause, so decided not to rename. - if (ShouldShowTopic(info)) - parentOfTopicDisplayElement.SetAttribute("data-have-topic", "true"); - - var stringId = "Topics." + englishTopic; - - var tagsInPriorityOrder = GetLanguagePrioritiesForLocalizedTextOnPage(); - var langOfTopicToShowOnCover = - tagsInPriorityOrder.FirstOrDefault(t => - LocalizationManager.GetIsStringAvailableForLangId(stringId, t) - ) ?? "en"; - - var bestTranslation = LocalizationManager.GetDynamicStringOrEnglish( - "Bloom", - stringId, - englishTopic, - "this is a book topic", - langOfTopicToShowOnCover - ); + //if we have no topic leave the field in the page with an empty text and the false above + if (bestTranslation == null) + { + continue; + } - //NB: in a unit test environment, GetDynamicStringOrEnglish is going to give us the id back, which is annoying. - if (bestTranslation == stringId) - bestTranslation = englishTopic; + // Even if we have a topic, if we don't want to show it on the page, + // we don't want this attribute set to true. + // This was a tough call. The main effect of data-have-topic being false is to center the + // language name. We do want that to happen when the topic is hidden, as well as when it + // is not set. So does it make more sense to have code here take into account the + // appearance setting, or should the CSS know about both reasons for centering? + // I don't see a strong reason either way, so I let it be decided by the fact that + // there's no obvious way in CSS to get the centering behavior (done by setting both + // margins to auto) based on whether --cover-topic-show is 'none' or + // 'doShow-css-will-ignore-this-and-use-default'. So data-have-topic could plausibly + // be renamed data-show-topic (or we could just use a class), but I'm not sure what + // backwards compatibility issues that might cause, so decided not to rename. + if (ShouldShowTopic(info)) + parentOfTopicDisplayElement.SetAttribute("data-have-topic", "true"); - topicPageElement.SetAttribute("lang", langOfTopicToShowOnCover); - topicPageElement.InnerText = bestTranslation; + topicPageElement.SetAttribute("lang", langOfTopicToShowOnCover); + topicPageElement.InnerText = bestTranslation; + } } private bool ShouldShowTopic(BookInfo info) @@ -1315,6 +1341,42 @@ public void GatherDataItemsFromXElement( { bool isCollectionValue = false; + // If the element has an ancestor with the data-ignore attribute, look for + // a still higher ancestor that has the class specified in the attribute. + // If there is such an ancestor, we won't read data from this element. + // (Or, if the data-ignore value starts with "!", we will skip if there + // is no such ancestor.) (This is initially used to skip reading from + // the auto-layout marginBox when we are showing the custom one, and + // vice-versa). + var ignoreParent = node.ParentWithAttribute("data-ignore"); + if (ignoreParent != null) + { + var test = ignoreParent.GetAttribute("data-ignore"); + var classToLookFor = test; + var skipIfNotFound = test.StartsWith("!"); + if (skipIfNotFound) + classToLookFor = classToLookFor.Substring(1); // remove the leading ! + var ancestor = ignoreParent.ParentWithClass(classToLookFor); + if ( + ancestor == null && skipIfNotFound + || ancestor != null && !skipIfNotFound + ) + continue; + } + + // Don't load data from an element on a page that is hidden. This seems like a + // good plan in general. But it's especially important on a custom cover page + // where there may be multiple translation groups containing the same set of + // bloom-editables with the same data-book value but only some of them are visible. + if ( + node.HasClass("bloom-editable") + && !node.HasClass("bloom-visibility-code-on") + && node.ParentWithClass("bloom-page") != null + ) + { + continue; + } + string key = node.GetAttribute("data-book").Trim(); if (key == String.Empty) { @@ -1355,6 +1417,8 @@ public void GatherDataItemsFromXElement( var labels = node1.SafeSelectNodes(".//label"); foreach (var label in labels) label.ParentNode.RemoveChild(label); + if (node.HasClass("bloom-contains-child-data")) + HideStuffInDataDivChildren(node1 as SafeXmlElement); value = node1.InnerXml.Trim(); //may contain formatting if (KeysOfVariablesThatAreUrlEncoded.Contains(key)) { @@ -1495,6 +1559,10 @@ public void GatherDataItemsFromXElement( // They are already on both so no point in copying. "data-book", "data-collection", + // This attribute affects an element's position on the page, and + // (now that a custom cover may have two copies of the title) + // we definitely don't want to force them both to be in the same place. + "data-bubble-alternate", // This is important because without it magic languages like "N1" could get overwritten by specific ones. "lang", // If there's explicit formatting on an element, we probably don't want the same on every copy of @@ -1523,6 +1591,7 @@ public void GatherDataItemsFromXElement( "bloom-content3", "bloom-contentNational1", "bloom-contentNational2", + "bloom-custom-cover-only-visible", } ); @@ -1536,6 +1605,20 @@ public void GatherDataItemsFromXElement( private List> GetAttributesToSave(SafeXmlElement node) { var result = new List>(); + if (HtmlDom.IsInCustomMarginBox(node)) + { + // We don't want to transfer attribute values or classes from the custom page + // layout to the standard one. It's likely that special styling or image cropping + // or anything similar will have the wrong effect there. The one exception is the + // src of an image: we allow changing the cover image in one place to change it in + // the other, just as changing the text of a title in one place changes it in both. + // We don't need to worry about re-creating attribute values inside the custom margin + // box, because its whole content is saved. + if (node.Name == "img" && !string.IsNullOrWhiteSpace(node.GetAttribute("src"))) + result.Add( + Tuple.Create("src", XmlString.FromUnencoded(node.GetAttribute("src"))) + ); + } foreach (var attr in node.AttributePairs) { if (_attributesNotToCopy.Contains(attr.Name)) @@ -1621,127 +1704,164 @@ HashSet> itemsToDelete { try { + // elements that have children that also have data-book attributes + // (for example, the customMarginBox on a custom front cover) + // must be processed before all others. For example, if we've been + // editing the auto version of the cover, the data-div still contains + // a copy of the custom layout version. Its layout is relevant, + // but its version of things like the title text may be obsolete. + // We want to first restore the customMarginBox content, and then + // restore things like the title into it (among other places). + var nodesToProcessFirst = targetDom + .SafeSelectNodes( + "//div[contains(@class,'bloom-contains-child-data') and @data-book]" + ) + .Cast(); + foreach (var elt in nodesToProcessFirst) + UpdateOneElementFromDataSet(data, itemsToDelete, elt); + + // Run this query AFTER that update, so that we're updating the (possibly modified) set of nodes that + // result from doing it. var query = $"//{elementName}[(@data-book or @data-collection or @data-library or @{kDataXmatterPage})]"; - var nodesOfInterest = targetDom.SafeSelectNodes(query); + var nodesOfInterest = targetDom.SafeSelectNodes(query).Cast(); - foreach (SafeXmlElement node in nodesOfInterest) + foreach (var elt in nodesOfInterest) { - var key = node.GetAttribute("data-book").Trim(); - - if (key == string.Empty) + // if it has that class we already processed it, and should not do so again, + // since it might replace some of the nodes in our list with new ones. + if (!elt.HasClass("bloom-contains-child-data")) { - key = node.GetAttribute(kDataXmatterPage).Trim(); - if (key != string.Empty) - { - UpdateXmatterPageDataAttributeSets(data, node); - continue; - } - key = node.GetAttribute("data-collection").Trim(); - if (key == string.Empty) - { - key = node.GetAttribute("data-library").Trim(); //"library" is the old name for what is now "collection" - } + UpdateOneElementFromDataSet(data, itemsToDelete, elt); } + } + } + catch (Exception error) + { + throw new ApplicationException( + "Error in UpdateDomFromDataSet(," + + elementName + + "). RawDom was:\r\n" + + targetDom.OuterXml, + error + ); + } + } - if (string.IsNullOrEmpty(key)) - continue; + private void UpdateOneElementFromDataSet( + DataSet data, + HashSet> itemsToDelete, + SafeXmlElement node + ) + { + var key = node.GetAttribute("data-book").Trim(); - if (data.TextVariables.ContainsKey(key)) - { - if (UpdateImageFromDataSet(data, node, key)) - continue; + if (key == string.Empty) + { + key = node.GetAttribute(kDataXmatterPage).Trim(); + if (key != string.Empty) + { + UpdateXmatterPageDataAttributeSets(data, node); + return; + } + key = node.GetAttribute("data-collection").Trim(); + if (key == string.Empty) + { + key = node.GetAttribute("data-library").Trim(); //"library" is the old name for what is now "collection" + } + } - var lang = DealiasWritingSystemId( - node.GetOptionalStringAttribute("lang", "*") - ); + if (string.IsNullOrEmpty(key)) + return; - // //see comment later about the inability to clear a value. TODO: when we re-write Bloom, make sure this is possible - // if(data.TextVariables[key].TextAlternatives.Forms.Length==0) - // { - // //no text forms == desire to remove it. THe multitextbase prohibits empty strings, so this is the best we can do: completly remove the item. - // targetDom.RemoveChild(node); - // } - // else - if (!string.IsNullOrEmpty(lang)) - //if we don't even have this language specified (e.g. no national language), the give up - { - //Ideally, we have this string, in this desired language. - DataSetElementValue dsv = data.TextVariables[key]; - var form = dsv.TextAlternatives.GetBestAlternative(new[] { lang, "*" }); - var s = form == null ? "" : form.Form; + if (data.TextVariables.ContainsKey(key)) + { + if (UpdateImageFromDataSet(data, node, key)) + return; - if (KeysOfVariablesThatAreUrlEncoded.Contains(key)) - { - Debug.Assert( - !s.Contains("&"), - "In memory, all image urls should be encoded such that & is just &." - ); - } - //But if not, maybe we should copy one in from another national language - if (StringAlternativeHasNoText(s)) - s = PossiblyCopyFromAnotherLanguage(node, lang, data, key); - - //NB: this was the focus of a multi-hour bug search, and it's not clear that I got it right. - //The problem is that the title page has N1 and n2 alternatives for title, the cover may not. - //the gather page was gathering no values for those alternatives (why not), and so GetBestAlternativeSTring - //was giving "", which we then used to remove our nice values. - //REVIEW: what affect will this have in other pages, other circumstances. Will it make it impossible to clear a value? - //Hoping not, as we are differentiating between "" and just not being in the multitext at all. - //don't overwrite a datadiv alternative with empty just becuase this page has no value for it. - // JohnT update: if we simply do nothing when dsv.TextAlternatives doesn't contain lang, - // that DOES prevent deleting stuff. We often got away with it, because the edited page would - // have the empty content from being edited, and itemsToDelete would list this key/lang combination - // as a result, and a couple of calls up the stack, UpdateVariablesAndDataDiv() would typically - // delete it from the data-div while processing itemsToDelete. But if we're looking at the bookTitle, - // which is typically on more than one page, there's still a page where it's not deleted, and the - // next update will pick that as preferred value. See BL-10739 - if ( - s == "" - && !dsv.TextAlternatives.ContainsAlternative(lang) - && !itemsToDelete.Contains(Tuple.Create(key, lang)) - ) - continue; + var lang = DealiasWritingSystemId(node.GetOptionalStringAttribute("lang", "*")); + + // //see comment later about the inability to clear a value. TODO: when we re-write Bloom, make sure this is possible + // if(data.TextVariables[key].TextAlternatives.Forms.Length==0) + // { + // //no text forms == desire to remove it. THe multitextbase prohibits empty strings, so this is the best we can do: completly remove the item. + // targetDom.RemoveChild(node); + // } + // else + if (!string.IsNullOrEmpty(lang)) + //if we don't even have this language specified (e.g. no national language), the give up + { + //Ideally, we have this string, in this desired language. + DataSetElementValue dsv = data.TextVariables[key]; + var form = dsv.TextAlternatives.GetBestAlternative(new[] { lang, "*" }); + var s = form == null ? "" : form.Form; - //hack: until I think of a more elegant way to avoid repeating the language name in N2 when it's the exact same as N1... - var n1Form = GetBestUnwrappedAlternative( - data.TextVariables[key].TextAlternatives, - new[] { MetadataLanguage1Tag, "*" } - ); - if (lang == MetadataLanguage2Tag && n1Form != null && s == n1Form.Form) - { - s = ""; //don't show it in N2, since it's the same as N1 - } - SetInnerXmlPreservingLabel(key, node, XmlString.FromXml(s)); - var attrs = dsv.GetAttributeList(lang); - if (attrs != null) - { - MergeAttrsIntoElement(attrs, node); - } - } - } - else if (!HtmlDom.IsImgOrSomethingWithBackgroundImage(node)) + if (KeysOfVariablesThatAreUrlEncoded.Contains(key)) { - // See whether we need to delete something - var lang = DealiasWritingSystemId( - node.GetOptionalStringAttribute("lang", "*") + Debug.Assert( + !s.Contains("&"), + "In memory, all image urls should be encoded such that & is just &." ); - if (itemsToDelete.Contains(Tuple.Create(key, lang))) - { - SetInnerXmlPreservingLabel(key, node, XmlString.Empty); // a later process may remove node altogether. - } + } + //But if not, maybe we should copy one in from another national language + if (StringAlternativeHasNoText(s)) + s = PossiblyCopyFromAnotherLanguage(node, lang, data, key); + + //NB: this was the focus of a multi-hour bug search, and it's not clear that I got it right. + //The problem is that the title page has N1 and n2 alternatives for title, the cover may not. + //the gather page was gathering no values for those alternatives (why not), and so GetBestAlternativeSTring + //was giving "", which we then used to remove our nice values. + //REVIEW: what affect will this have in other pages, other circumstances. Will it make it impossible to clear a value? + //Hoping not, as we are differentiating between "" and just not being in the multitext at all. + //don't overwrite a datadiv alternative with empty just becuase this page has no value for it. + // JohnT update: if we simply do nothing when dsv.TextAlternatives doesn't contain lang, + // that DOES prevent deleting stuff. We often got away with it, because the edited page would + // have the empty content from being edited, and itemsToDelete would list this key/lang combination + // as a result, and a couple of calls up the stack, UpdateVariablesAndDataDiv() would typically + // delete it from the data-div while processing itemsToDelete. But if we're looking at the bookTitle, + // which is typically on more than one page, there's still a page where it's not deleted, and the + // next update will pick that as preferred value. See BL-10739 + if ( + s == "" + && !dsv.TextAlternatives.ContainsAlternative(lang) + && !itemsToDelete.Contains(Tuple.Create(key, lang)) + ) + return; + + //hack: until I think of a more elegant way to avoid repeating the language name in N2 when it's the exact same as N1... + var n1Form = GetBestUnwrappedAlternative( + data.TextVariables[key].TextAlternatives, + new[] { MetadataLanguage1Tag, "*" } + ); + if (lang == MetadataLanguage2Tag && n1Form != null && s == n1Form.Form) + { + s = ""; //don't show it in N2, since it's the same as N1 + } + SetInnerXmlPreservingLabel(key, node, XmlString.FromXml(s)); + if (node.HasClass("bloom-contains-child-data")) + RestoreStuffInDataDivChildren(node); + var attrs = dsv.GetAttributeList(lang); + // don't copy attributes (including classes) from standard page into custom. + // To properly prevent this, we must also not copy into the copy saved in the + // data-div. + if ( + attrs != null + && !HtmlDom.IsInCustomMarginBox(node) + && !HtmlDom.IsInCustomCoverInDataDiv(node) + ) + { + MergeAttrsIntoElement(attrs, node); } } } - catch (Exception error) + else if (!HtmlDom.IsImgOrSomethingWithBackgroundImage(node)) { - throw new ApplicationException( - "Error in UpdateDomFromDataSet(," - + elementName - + "). RawDom was:\r\n" - + targetDom.OuterXml, - error - ); + // See whether we need to delete something + var lang = DealiasWritingSystemId(node.GetOptionalStringAttribute("lang", "*")); + if (itemsToDelete.Contains(Tuple.Create(key, lang))) + { + SetInnerXmlPreservingLabel(key, node, XmlString.Empty); // a later process may remove node altogether. + } } } @@ -1798,6 +1918,84 @@ SafeXmlElement node } } + /// + /// When we save more or less the whole contents of the cover page as a single entry + /// in the data-div, suddenly the data-div contains a lot of kinds of elements that + /// we didn't previously expect to find there. In particular, it would contain further + /// elements with data-book and similar attributes, as well as translation groups and + /// editables. Bloom does various tasks by looking for all the elements in the book + /// that have these properties. In 6.4, we've tried to guard against these processes + /// inadvertently picking up the elements in the data-div. But we'd like books that + /// have this data to safely open in earlier versions of Bloom. So we're replacing + /// various important attributes and classes with inactive versions that earlier Blooms + /// (and any code we've missed that is still around) that looks for such elements will + /// not find them in the saved element in the data-div. + /// + private void HideStuffInDataDivChildren(SafeXmlElement root) + { + SwapStuffInDataDivChildren(root, toInactive: true); + } + + private void RestoreStuffInDataDivChildren(SafeXmlElement root) + { + SwapStuffInDataDivChildren(root, toInactive: false); + } + + private void SwapStuffInDataDivChildren(SafeXmlElement root, bool toInactive) + { + if (root == null) + return; + + foreach (SafeXmlElement element in root.SafeSelectNodes(".//*")) + { + SwapNestedClasses(element, toInactive); + + foreach (var activeAttributeName in _nestedDataAttributes) + { + var inactiveAttributeName = $"{activeAttributeName}-inactive"; + var sourceAttributeName = toInactive + ? activeAttributeName + : inactiveAttributeName; + var destinationAttributeName = toInactive + ? inactiveAttributeName + : activeAttributeName; + + if (!element.HasAttribute(sourceAttributeName)) + continue; + + var attributeValue = element.GetAttribute(sourceAttributeName); + element.RemoveAttribute(sourceAttributeName); + element.SetAttribute(destinationAttributeName, attributeValue); + } + } + } + + private void SwapNestedClasses(SafeXmlElement element, bool toInactive) + { + var classAttr = element.GetAttribute("class"); + if (string.IsNullOrWhiteSpace(classAttr)) + return; + + var classes = classAttr.Split().ToList(); + var changed = false; + for (var i = 0; i < classes.Count; i++) + { + foreach (var kvp in _inactiveClassMap) + { + var sourceClass = toInactive ? kvp.Key : kvp.Value; + var destinationClass = toInactive ? kvp.Value : kvp.Key; + if (classes[i] == sourceClass) + { + classes[i] = destinationClass; + changed = true; + } + } + } + + if (changed) + element.SetAttribute("class", string.Join(" ", classes)); + } + // internal for testing internal static bool StringAlternativeHasNoText(string s) { @@ -1908,6 +2106,10 @@ internal bool UpdateImageFromDataSet(DataSet data, SafeXmlElement node, string k } } + // Don't transfer data other than the src from the standard cover page to the custom one. + if (HtmlDom.IsInCustomMarginBox(node)) + return true; + // Historically, we've gone back and forth about putting width/height on images. Normally if we find this, // we want to remove it because we now use object-fit:contain instead. However in some styling cases (at least border), // that wasn't sufficient so we need to keep using width/height. We indicate that we're in this situation with this diff --git a/src/BloomExe/Book/BookStorage.cs b/src/BloomExe/Book/BookStorage.cs index 97e8ffbe684b..7d2fd60edbb1 100644 --- a/src/BloomExe/Book/BookStorage.cs +++ b/src/BloomExe/Book/BookStorage.cs @@ -4585,6 +4585,10 @@ static bool HasMessedUpMarginBox(SafeXmlElement page) var marginBox = GetMarginBox(page); if (marginBox == null) return true; // marginBox should not be missing + // If we're on a custom cover page, well, I suppose the author can delete + // everything if they want. + if (marginBox.HasClass("bloom-customMarginBox")) + return false; var internalNodes = marginBox.ChildNodes.Where(x => x is SafeXmlElement).ToList(); if (internalNodes.Count == 0) { @@ -4605,16 +4609,26 @@ static bool HasMessedUpMarginBox(SafeXmlElement page) return false; } - // I think this is more efficient than an xpath, especially since marginBox is usually the last top-level child. - static SafeXmlElement GetMarginBox(SafeXmlElement parent) + // I think this is more efficient than an xpath, especially since marginBox is usually the last top-level child + // (except for cover pages, where there is often an empty custom margin box after the main one). + static SafeXmlElement GetMarginBox(SafeXmlElement parent, bool onCustomPage = false) { + if (parent.HasClass("bloom-page")) + onCustomPage = parent.HasClass("bloom-custom-cover"); foreach ( SafeXmlElement child in parent.ChildNodes.Where(x => x is SafeXmlElement).Reverse() ) { - if (child.GetAttribute("class").Contains("marginBox")) - return child; - var mb = GetMarginBox(child); + if (child.HasClass("marginBox")) + { + var isCustomMarginBox = child.HasClass("bloom-customMarginBox"); + // We'll find the one that is currently relevant. + if (onCustomPage && isCustomMarginBox || !onCustomPage && !isCustomMarginBox) + return child; + continue; // we don't need to search inside either kind of marginBox + } + + var mb = GetMarginBox(child, onCustomPage); if (mb != null) return mb; } diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index 819c55b71910..7620e3f14ae0 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -607,8 +607,14 @@ public static void RemoveRtlDir(SafeXmlElement e) e.RemoveAttribute("dir"); } - public static void RemoveClassesBeginningWith(SafeXmlElement xmlElement, string classPrefix) + public static void RemoveClassesBeginningWith( + SafeXmlElement xmlElement, + string classPrefix, + HashSet classesToKeep = null + ) { + if (classesToKeep == null) + classesToKeep = new HashSet(); var oldClasses = xmlElement.GetClasses(); if (oldClasses.Length == 0) @@ -617,7 +623,7 @@ public static void RemoveClassesBeginningWith(SafeXmlElement xmlElement, string var classes = ""; foreach (var part in oldClasses) { - if (!part.StartsWith(classPrefix)) + if (!part.StartsWith(classPrefix) || classesToKeep.Contains(part)) classes += part + " "; } xmlElement.SetAttribute("class", classes.Trim()); @@ -3787,6 +3793,12 @@ SafeXmlElement node ) { var result = new List>(); + // Don't save any of this data for an image in the custom margin box. We don't + // need it for reconstructing that image, because it is saved with all its parents + // in the data-div entry for the custom margin box. And we don't want to transfer + // its layout settings to the standard one. + if (IsInCustomMarginBox(node)) + return result; var ce = node.ParentElement?.ParentElement; if (ce != null) { @@ -3828,6 +3840,13 @@ public static void ReconstructBackgroundImgWrapper( string[] backgroundImgValues ) { + // We don't need to do this to the cover image that is embedded in the custom + // margin box, because its containers with the style on the bloom-canvas-element + // and the data-imgsizebasedon of the bloom-canvas are saved as part of the content + // of the custom margin box. And we MUST not copy the values we saved from the + // standard cover into the custom one, either directly or via the copy in the data-div. + if (IsInCustomMarginBox(node) || IsInCustomCoverInDataDiv(node)) + return; // The situation we want to establish is that the image is inside an imageContainer // inside a canvasElement inside a bloomCanvas. That may not be true initially. var imageContainer = node.ParentElement; @@ -3871,5 +3890,18 @@ string[] backgroundImgValues bloomCanvas.SetAttribute("data-imgsizebasedon", backgroundImgValues[0]); canvasElement.SetAttribute("style", backgroundImgValues[1]); } + + public static bool IsInCustomCoverInDataDiv(SafeXmlElement node) + { + var customCover = node.ParentWithAttributeValue("data-book", "customCover"); + if (customCover == null) + return false; + return customCover.ParentWithAttributeValue("id", "bloomDataDiv") != null; + } + + public static bool IsInCustomMarginBox(SafeXmlElement node) + { + return node.ParentWithClass("bloom-customMarginBox") != null; + } } } diff --git a/src/BloomExe/Book/TranslationGroupManager.cs b/src/BloomExe/Book/TranslationGroupManager.cs index c693415cd312..ba1ec66ad4d5 100644 --- a/src/BloomExe/Book/TranslationGroupManager.cs +++ b/src/BloomExe/Book/TranslationGroupManager.cs @@ -485,7 +485,23 @@ private static void UpdateContentLanguageClassesOnElement( string[] dataDefaultLanguages ) { - HtmlDom.RemoveClassesBeginningWith(editable, "bloom-content"); + HashSet classesToKeep = null; + if (editable.ParentWithAttributeValue("data-book", "customCover") != null) + { + // on a custom page, every bloom-editable is the only thing visible + // in its translationGroup, and visibility is not controlled by the + // appearance system. So we will never add these classes. However, + // they might have been copied there when setting up the custom layout. + // To avoid a distracting and possibly annoying change of appearance + // when transitioning to a custom layout, we will keep whatever class + // from this group the element had when the conversion happened. + // The above check prevents it from being removed both in the live page + // and in the data-div. + classesToKeep = new HashSet( + new[] { "bloom-contentFirst", "bloom-contentSecond", "bloom-contentThird" } + ); + } + HtmlDom.RemoveClassesBeginningWith(editable, "bloom-content", classesToKeep); var lang = editable.GetAttribute("lang"); //These bloom-content* classes are used by some stylesheet rules, primarily to boost the font-size of some languages. diff --git a/src/BloomExe/Book/XMatterHelper.cs b/src/BloomExe/Book/XMatterHelper.cs index a7f5aa601d84..83b46211d675 100644 --- a/src/BloomExe/Book/XMatterHelper.cs +++ b/src/BloomExe/Book/XMatterHelper.cs @@ -351,7 +351,7 @@ var file in Directory _bookDom.EnsureStylesheetLinks(Path.GetFileName(PathToXMatterStylesheet)); - var divBeforeNextFrontMatterPage = _bookDom.RawDom.SelectSingleNode( + var divToInsertNextPageAfter = _bookDom.RawDom.SelectSingleNode( "//body/div[@id='bloomDataDiv']" ); @@ -432,7 +432,7 @@ SafeXmlElement xmatterPage in XMatterDom.SafeSelectNodes( ) { //note: this is redundant unless this is the 1st backmatterpage in the list - divBeforeNextFrontMatterPage = _bookDom.RawDom.SelectSingleNode( + divToInsertNextPageAfter = _bookDom.RawDom.SelectSingleNode( "//body/div[last()]" ); } @@ -452,10 +452,10 @@ SafeXmlElement xmatterPage in XMatterDom.SafeSelectNodes( } } - _bookDom.RawDom - .SelectSingleNode("//body") - .InsertAfter(newPageDiv, divBeforeNextFrontMatterPage); - divBeforeNextFrontMatterPage = newPageDiv; + _bookDom + .RawDom.SelectSingleNode("//body") + .InsertAfter(newPageDiv, divToInsertNextPageAfter); + divToInsertNextPageAfter = newPageDiv; //enhance... this is really ugly. I'm just trying to clear out any remaining "{blah}" left over from the template foreach ( diff --git a/src/BloomExe/ImageProcessing/ImageUtils.cs b/src/BloomExe/ImageProcessing/ImageUtils.cs index 4d7cba75b824..d4ff682bb12b 100644 --- a/src/BloomExe/ImageProcessing/ImageUtils.cs +++ b/src/BloomExe/ImageProcessing/ImageUtils.cs @@ -2322,7 +2322,7 @@ bool useNewName var croppedImagePath = MakeCroppedImage(img, imageSourceFolder, imageDestFolder); var src = img.GetAttribute("src"); // a good default if we can't produce a cropped image for any reason. - // (The tests in MakeCroppedImage are a bit more robus than the ones we do before + // (The tests in MakeCroppedImage are a bit more robust than the ones we do before // deciding to call this method.) Capture this before we unencode, since it wants // to be a value we can put in a src attribute (if it doesn't get changed) // to lead to the file we may put at an unchanged file name. diff --git a/src/BloomExe/Publish/BloomPub/BloomPubMaker.cs b/src/BloomExe/Publish/BloomPub/BloomPubMaker.cs index 71f930241105..8922e20b4a08 100644 --- a/src/BloomExe/Publish/BloomPub/BloomPubMaker.cs +++ b/src/BloomExe/Publish/BloomPub/BloomPubMaker.cs @@ -611,16 +611,7 @@ public static Book.Book PrepareBookForBloomReader( ConvertCoverLinkToRealPageId(modifiedBook); AddDistributionFile(modifiedBookFolderPath, creator, settings); - // Make sure the bookdata we save is consistent with any changes we made (for example, - // to attributes of xmatter pages, BL-14907). Such changes are typically not made to the DataDiv itself, - // so we need to suck in the data from the edited DOM WITHOUT the DataDiv. - var dataDiv = modifiedBook.OurHtmlDom.SelectSingleNode("//div[@id='bloomDataDiv']"); - var parent = dataDiv.ParentElement; - var next = dataDiv.NextSibling; - parent.RemoveChild(dataDiv); - modifiedBook.BookData.SuckInDataFromEditedDom(modifiedBook.OurHtmlDom); - parent.InsertBefore(dataDiv, next); - modifiedBook.Save(); + modifiedBook.Save(true); return modifiedBook; } diff --git a/src/BloomExe/Publish/PublishHelper.cs b/src/BloomExe/Publish/PublishHelper.cs index 113df979b62a..e41ff7179d0d 100644 --- a/src/BloomExe/Publish/PublishHelper.cs +++ b/src/BloomExe/Publish/PublishHelper.cs @@ -322,6 +322,9 @@ private void RemoveUnwantedContentInternal( pageElts.Add(page); } + if (pageElts.Count > 0 && pageElts[0].HasClass("outsideFrontCover")) + RemoveUnwantedMarginBoxFromCover(pageElts[0]); + RemovePagesByFeatureSystem(book, dom, pageElts, medium, omittedPages); RemoveAssetsWhichRequireSubscription(book); RemoveClassesAndAttrsToDisableFeatures( @@ -584,6 +587,30 @@ var div in dom ); } + private static void RemoveUnwantedMarginBoxFromCover(SafeXmlElement page) + { + var marginBoxes = page.SafeSelectNodes(".//div[contains(@class, 'marginBox')]") + .Cast() + .ToList(); + + if (marginBoxes.Count <= 1) + return; // Nothing to remove if there's only one or none + + // Determine which marginBox to keep based on whether the book is using a custom cover + var keepCustom = page.HasClass("bloom-custom-cover"); + + foreach (var marginBox in marginBoxes) + { + var isCustomMarginBox = marginBox.HasClass("bloom-customMarginBox"); + + // Remove if it doesn't match what we want to keep + if (isCustomMarginBox != keepCustom) + { + marginBox.ParentNode.RemoveChild(marginBox); + } + } + } + /// /// Typically called once after one or a sequence of calls to RemovePagesbyFeatureSystem, /// generates any appropriate messages. @@ -1056,7 +1083,13 @@ public static Book.Book MakeDeviceXmatterTempBook( // Must come after ReallyCropImages, because any cropping for background images is // destroyed by SimplifyBackgroundImages. SimplifyBackgroundImages(modifiedBook.RawDom); - modifiedBook.Save(); + // We don't need a data-div in a publication; save some space. + var dataDiv = modifiedBook.RawDom.SelectSingleNode("//div[@id='bloomDataDiv']"); + if (dataDiv != null) + { + dataDiv.ParentElement.RemoveChild(dataDiv); + } + modifiedBook.Save(true); modifiedBook.UpdateSupportFiles(); return modifiedBook; } @@ -1159,26 +1192,6 @@ public static void SimplifyBackgroundImages(SafeXmlDocument dom) bloomCanvas.RemoveClass("bloom-has-canvas-element"); } } - - // We need to clear out these attributes from the data-div, or a later call to update things will try to restore - // the background image representation of any cover image. - var dataDiv = dom.SelectSingleNode("//div[@id='bloomDataDiv']"); - var bgImgDataAttrs = HtmlDom.BackgroundImgTupleNames; - if (dataDiv != null) - { - foreach ( - var elt in dataDiv - .SafeSelectNodes($".//div[@{bgImgDataAttrs[0]}]") - .Cast() - ) - { - foreach (var attr in bgImgDataAttrs) - { - if (elt.HasAttribute(attr)) - elt.RemoveAttribute(attr); - } - } - } } #region IDisposable Support diff --git a/src/BloomExe/SafeXml/SafeXmlElement.cs b/src/BloomExe/SafeXml/SafeXmlElement.cs index 90672a469354..731a5c9a18a0 100644 --- a/src/BloomExe/SafeXml/SafeXmlElement.cs +++ b/src/BloomExe/SafeXml/SafeXmlElement.cs @@ -187,7 +187,7 @@ public static SafeXmlElement WrapElement(XmlElement elt, SafeXmlDocument doc) /// public SafeXmlElement ParentWithClass(string targetClass) { - return (ParentNode as SafeXmlElement).ParentOrSelfWithClass(targetClass); + return (ParentNode as SafeXmlElement)?.ParentOrSelfWithClass(targetClass); } public SafeXmlElement ParentOrSelfWithClass(string targetClass) @@ -201,6 +201,30 @@ public SafeXmlElement ParentOrSelfWithClass(string targetClass) return current; } + public SafeXmlElement ParentWithAttribute(string targetAttribute) + { + var current = ParentElement; + while (current != null && !current.HasAttribute(targetAttribute)) + current = current.ParentNode as SafeXmlElement; + return current; + } + + /// + /// Get a parent element if any that has the specified value for the specified attribute + /// Note: currently if attrVal is empty it will match both parents where the attribute + /// is present and empty, and parents that don't have the attribute at all. It is not + /// intended that it should be used this way. + /// + public SafeXmlElement ParentWithAttributeValue(string targetAttribute, string attrVal) + { + ArgumentException.ThrowIfNullOrEmpty(attrVal); + + var current = ParentElement; + while (current != null && current.GetAttribute(targetAttribute) != attrVal) + current = current.ParentNode as SafeXmlElement; + return current; + } + public SafeXmlElement GetChildWithName(string name) { return ChildNodes.FirstOrDefault(n => n.Name.ToLowerInvariant() == name) diff --git a/src/BloomExe/WebLibraryIntegration/BookUpload.cs b/src/BloomExe/WebLibraryIntegration/BookUpload.cs index c80962c121c0..b41f552ddc75 100644 --- a/src/BloomExe/WebLibraryIntegration/BookUpload.cs +++ b/src/BloomExe/WebLibraryIntegration/BookUpload.cs @@ -884,6 +884,9 @@ IProgress progress PublishHelper.RemoveUnpublishableContent(page); PublishHelper.RemoveUnpublishableBookData(copiedBook.RawDom); PublishHelper.RemoveUnpublishableBookInfo(copiedBook.BookInfo); + // Don't pass forPublication true. Technically this is a copy being + // made for publication, but we're publishing it in a form that can + // be used for continued editing, so don't want any shortcuts. copiedBook.Save(); copiedBook.UpdateSupportFiles(); book = copiedBook; diff --git a/src/BloomExe/web/controllers/CollectionSettingsApi.cs b/src/BloomExe/web/controllers/CollectionSettingsApi.cs index 2a5858ca1f46..41c7a9fd2ef1 100644 --- a/src/BloomExe/web/controllers/CollectionSettingsApi.cs +++ b/src/BloomExe/web/controllers/CollectionSettingsApi.cs @@ -313,24 +313,34 @@ private void HandleLanguageDataRequest(ApiRequest request) request.ReplyWithJson(jsonString); } - // Used by BookSettingsDialog + // Used by BookSettingsDialog and others private void HandleGetLanguageNames(ApiRequest request) { var x = new ExpandoObject() as IDictionary; // The values set here should correspond to the declaration of ILanguageNameValues // in BookSettingsDialog.tsx. x["language1Name"] = _bookSelection.CurrentSelection.CollectionSettings.Language1.Name; + x["language1Tag"] = _bookSelection.CurrentSelection.CollectionSettings.Language1.Tag; x["language2Name"] = _bookSelection.CurrentSelection.CollectionSettings.Language2.Name; + x["language2Tag"] = _bookSelection.CurrentSelection.CollectionSettings.Language2.Tag; if ( !String.IsNullOrEmpty( _bookSelection.CurrentSelection.CollectionSettings.Language3?.Name ) ) + { x["language3Name"] = _bookSelection .CurrentSelection .CollectionSettings .Language3 .Name; + x["language3Tag"] = _bookSelection + .CurrentSelection + .CollectionSettings + .Language3 + .Tag; + } + request.ReplyWithJson(JsonConvert.SerializeObject(x)); } diff --git a/src/BloomExe/web/controllers/EditingViewApi.cs b/src/BloomExe/web/controllers/EditingViewApi.cs index 3cd2939d3769..5e9ddd69c59c 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -14,6 +14,7 @@ using Bloom.Utils; using L10NSharp; using SIL.IO; +using SIL.Progress; using SIL.Windows.Forms.Miscellaneous; namespace Bloom.web.controllers @@ -115,6 +116,51 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) HandleShowBookSettingsDialog, true ); + apiHandler.RegisterEndpointHandler( + "editView/toggleCustomCover", + HandleToggleCustomCover, + true + ); + apiHandler.RegisterEndpointHandler( + "editView/getDataBookValue", + HandleGetDataBookValue, + true + ); + } + + private void HandleGetDataBookValue(ApiRequest request) + { + var lang = request.RequiredParam("lang"); + var dataBook = request.RequiredParam("dataBook"); + var multiText = View.Model.CurrentBook.BookData.GetMultiTextVariableOrEmpty(dataBook); + var value = multiText.GetExactAlternative(lang) ?? ""; + request.ReplyWithText(value); + } + + /// + /// Save the current state of the page, then toggle the book cover custom flag, and finally reload the page. + /// + private void HandleToggleCustomCover(ApiRequest request) + { + var pageId = request.GetPostStringOrNull(); + request.PostSucceeded(); + View.Model.SaveThen( + () => + { + var book = View.Model.CurrentBook; + var page = book.GetPage(pageId); + var pageElt = page.GetDivNodeForThisPage(); + if (pageElt.HasClass("bloom-custom-cover")) + pageElt.RemoveClass("bloom-custom-cover"); + else + pageElt.AddClass("bloom-custom-cover"); + // Bring everything up to date consistent with the new + // state. Might be enough just do the BookData update. + book.EnsureUpToDateMemory(new NullProgress()); + return pageId; + }, + () => { } + ); } private void HandleJumpToPage(ApiRequest request) diff --git a/src/BloomTests/Book/BookDataTests.cs b/src/BloomTests/Book/BookDataTests.cs index fb01af62038d..b608bd5cb285 100644 --- a/src/BloomTests/Book/BookDataTests.cs +++ b/src/BloomTests/Book/BookDataTests.cs @@ -159,18 +159,18 @@ public void BookStarter_ClearUnneededOriginalContentFromDerivative_ClearsOutNonD
-

First Edition 2020
Second Edition 2023

-
-
English here
+

First Edition 2020
Second Edition 2023

+
+
English here
-
Something weird here
-
-
Some other stuff.
+
Something weird here
+
+
Some other stuff.
-
Something that oughta be removed in a new book.
-
+
Something that oughta be removed in a new book.
+
" @@ -245,7 +245,7 @@ public void GatherDataItemsFromXElement_BackgroundImage_GathersCroppingData() var dom = new HtmlDom( @"
- +
@@ -597,13 +597,13 @@ public void SuckInDataFromEditedDom_TitleRemovedFromEditedDom_RemovesEverywhere( HtmlDom bookDom = new HtmlDom( @"
-
DccTitle
-
EnTitle
+
DccTitle
+
EnTitle
-
DccTitle
-
EnTitle
+
DccTitle
+
EnTitle
@@ -621,8 +621,8 @@ public void SuckInDataFromEditedDom_TitleRemovedFromEditedDom_RemovesEverywhere( @"
-
DccTitle
-

+
DccTitle
+

" @@ -3268,6 +3268,122 @@ public void GatherDataItemsFromXElement_OmitsDataPageNumber() Assert.That(dataPage, Is.EqualTo("required singleton")); } + [Test] + public void SuckInDataFromEditedDom_ContainsChildData_InactivatesNestedDataAndClassesInDataDiv_ButRestoresInLiveDom() + { + var bookDom = new HtmlDom( + @" +
+
+
+
+
+ " + ); + var data = new BookData(bookDom, _collectionSettings, null); + + var editedPageDom = new HtmlDom( + @" +
+
+
Topic Placeholder
+
+
My Title
+
+
Collection Value
+
Library Value
+
xmatter
+
+
+ " + ); + + // SUT. This function first copies the data into the bloomDataDiv, then updates the live DOM to match + // So we expect that after this function, the bloomDataDiv will have the inactivated data, + // but the live DOM have the normal data in BOTH elements that have the data-book attribute. + data.SuckInDataFromEditedDom(editedPageDom); + + // Validate that the bloomDataDiv has the inactivated data and classes + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasNoMatchForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[@data-book or @data-derived or @data-collection or @data-library or @data-xmatter-page]" + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[@data-book-inactive]", + 1 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[@data-derived-inactive]", + 1 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[@data-collection-inactive]", + 1 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[@data-library-inactive]", + 1 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[@data-xmatter-page-inactive]", + 1 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[contains(concat(' ', normalize-space(@class), ' '), ' tg-inactive ')]", + 1 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[@id='bloomDataDiv']/div[@data-book='customCover']//*[contains(concat(' ', normalize-space(@class), ' '), ' edit-inactive ')]", + 1 + ); + + // Validate that the live DOM has the normal data and classes in both elements with the main data-book attribute + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[contains(@class,'bloom-page')]//div[contains(@class,'bloom-customMarginBox') and @data-book='customCover']//*[contains(concat(' ', normalize-space(@class), ' '), ' bloom-translationGroup ')]", + 2 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[contains(@class,'bloom-page')]//div[contains(@class,'bloom-customMarginBox') and @data-book='customCover']//*[contains(concat(' ', normalize-space(@class), ' '), ' bloom-editable ')]", + 2 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasNoMatchForXpath( + "//div[contains(@class,'bloom-page')]//div[contains(@class,'bloom-customMarginBox') and @data-book='customCover']//*[@data-book-inactive or @data-derived-inactive or @data-collection-inactive or @data-library-inactive or @data-xmatter-page-inactive]" + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[contains(@class,'bloom-page')]//div[contains(@class,'bloom-customMarginBox') and @data-book='customCover']//*[@data-book='bookTitle']", + 2 + ); + AssertThatXmlIn + .Dom(bookDom.RawDom) + .HasSpecifiedNumberOfMatchesForXpath( + "//div[contains(@class,'bloom-page')]//div[contains(@class,'bloom-customMarginBox') and @data-book='customCover']//*[@data-derived='topic']", + 2 + ); + } + public static CollectionSettings CreateCollection( string Language1LangTag = "tpi", string Language1Name = "Tok Pisin", diff --git a/src/BloomTests/Book/BookTests2.cs b/src/BloomTests/Book/BookTests2.cs new file mode 100644 index 000000000000..3de2cd0f496d --- /dev/null +++ b/src/BloomTests/Book/BookTests2.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Bloom.SafeXml; +using NUnit.Framework; + +namespace BloomTests.Book +{ + public class BookTests2 : BookTests + { + [Test] + public void GetCoverImagePathAndElt_HasTwoCoverImages_GetsRightOne() + { + SetDom( + @" +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
" + ); + File.WriteAllText(Path.Combine(_tempFolder.Path, "IMG_1413.jpg"), "dummy"); + var book = CreateBook(); + var coverImgPath = book.GetCoverImagePathAndElt(out SafeXmlElement coverImgElt); + Assert.That(Path.GetFileName(coverImgPath), Is.EqualTo("IMG_1413.jpg")); + Assert.That(coverImgElt.GetAttribute("id"), Is.EqualTo("normal")); + } + + [Test] + public void GetCoverImagePathAndElt_HasTwoImgCoverImages_IgnoresDataDivAndSwitchesToCustomWhenPageIsCustomCover() + { + SetDom( + @" +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+
+
+
" + ); + File.WriteAllText(Path.Combine(_tempFolder.Path, "IMG_1413.jpg"), "dummy"); + File.WriteAllText(Path.Combine(_tempFolder.Path, "IMG_from_dataDiv.jpg"), "dummy"); + File.WriteAllText(Path.Combine(_tempFolder.Path, "IMG_notCoverInNormal.jpg"), "dummy"); + File.WriteAllText(Path.Combine(_tempFolder.Path, "IMG_notCoverInCustom.jpg"), "dummy"); + var book = CreateBook(); + var coverImgPath = book.GetCoverImagePathAndElt(out SafeXmlElement coverImgElt); + Assert.That(Path.GetFileName(coverImgPath), Is.EqualTo("IMG_1413.jpg")); + Assert.That(coverImgElt.GetAttribute("id"), Is.EqualTo("normal")); + + var outsideFrontCover = + book.RawDom.SelectSingleNode( + "//div[contains(concat(' ', normalize-space(@class), ' '), ' outsideFrontCover ')]" + ) as SafeXmlElement; + outsideFrontCover.SetAttribute( + "class", + outsideFrontCover.GetAttribute("class") + " bloom-custom-cover" + ); + + coverImgPath = book.GetCoverImagePathAndElt(out coverImgElt); + Assert.That(Path.GetFileName(coverImgPath), Is.EqualTo("IMG_1413.jpg")); + Assert.That(coverImgElt.GetAttribute("id"), Is.EqualTo("custom")); + + coverImgElt.RemoveAttribute("data-book"); + coverImgPath = book.GetCoverImagePathAndElt(out coverImgElt); + Assert.That(Path.GetFileName(coverImgPath), Is.EqualTo("IMG_notCoverInCustom.jpg")); + Assert.That(coverImgElt.GetAttribute("id"), Is.EqualTo("custom-noncover-earlier")); + } + + [Test] + public void GetCoverImagePathAndElt_HasNoCoverImage_ReturnsNulls() + { + SetDom( + @" +
+
value
+
+
+
+
+
+
+ +
+
+
+
+
" + ); + var book = CreateBook(); + var coverImgPath = book.GetCoverImagePathAndElt(out SafeXmlElement coverImgElt); + Assert.That(coverImgPath, Is.Null); + Assert.That(coverImgElt, Is.Null); + } + } +} diff --git a/src/content/bookLayout/basePage.less b/src/content/bookLayout/basePage.less index 05615e468e4c..b3a5323c2c83 100644 --- a/src/content/bookLayout/basePage.less +++ b/src/content/bookLayout/basePage.less @@ -319,7 +319,13 @@ books may contain divs with box-header-off, so we need a rule to hide them.*/ // split-panes.) overflow: hidden; } -.customPage { +// probably some of these are not needed for custom covers, but the one that makes a +// translation group fill its containing canvas element is critical; otherwise, an +// empty one can't be clicked. A custom cover is very like an ordinary custom page +// with no origami splits but just one bloom-canvas that fills the page, so I think +// it's most likely to avoid problems if we share all the rules. +.customPage, +.bloom-custom-cover { .bloom-canvas { width: 100%; height: 100%; @@ -1004,3 +1010,17 @@ The buffer would be absent if the marginBox had a border or the page has a backg --topLevel-text-padding-right: var(--topLevel-text-padding); --topLevel-text-padding-left: var(--topLevel-text-padding); } + +// certain bloom pages have two marginBoxes. The second one is marked with +// bloom-customMarginBox. Normally we hide it. If the page has a marker class, +// we show it and hide the regular one. +// Note: these rules need to be fairly specific, and we don't want to +// use one rule to hide and a more-specific one to show, because other rules +// want to make the marginBox block or flex, and we don't know which to +// restore. So we do it all with fairly specific display:none rules. +.bloom-page:not(.bloom-custom-cover) .marginBox.bloom-customMarginBox { + display: none; +} +.bloom-page.bloom-custom-cover .marginBox:not(.bloom-customMarginBox) { + display: none; +} diff --git a/src/content/bookLayout/canvasElement.less b/src/content/bookLayout/canvasElement.less index 61295cd2666b..6ad351de33af 100644 --- a/src/content/bookLayout/canvasElement.less +++ b/src/content/bookLayout/canvasElement.less @@ -312,6 +312,20 @@ } } +// wants to be more specific than the appearance system rules +.bloom-page.bloom-custom-cover { + // everything is a canvas element, and we want to be able to put them anywhere + --cover-margin-top: 0px; + --cover-margin-bottom: 0px; + // these two don't seem to be used, at least in default theme? + --cover-margin-left: 0px; + --cover-margin-right: 0px; + --cover-margin-side: 0px; + .bloom-canvas { + margin: 0; + } +} + // We have some special rules that give image containers margin in various places // (e.g., traditional xmatter cover). But we don't want it inherited by canvas element // image containers, because we expect them to exactly match the canvas elements. diff --git a/src/content/templates/xMatter/bloom-xmatter-mixins.pug b/src/content/templates/xMatter/bloom-xmatter-mixins.pug index 7dad58d892ba..196366f0b43a 100644 --- a/src/content/templates/xMatter/bloom-xmatter-mixins.pug +++ b/src/content/templates/xMatter/bloom-xmatter-mixins.pug @@ -171,11 +171,17 @@ mixin editable-cover-image(language) .bloom-editable.ImageDescriptionEdit-style(lang=language, contenteditable="true", data-book="coverImageDescription")&attributes(attributes) +//- This originally used the page-cover mixin, but the outside front cover needs a second marginBox, +//- and it's less messy to duplicate a bit of knowledge about classes and attributes from that and +//- other mixins than to make them all know how to make the second marginBox. mixin factoryStandard-outsideFrontCover // FRONT COVER - +page-cover('Front Cover')(data-export='front-matter-cover', data-xmatter-page='frontCover')&attributes(attributes).frontCover.outsideFrontCover#74731b2d-18b0-420f-ac96-6de20f659810 - +standard-cover-contents - block front-cover-footer + +pageWithJustPageSize.bloom-page.cover.coverColor.bloom-frontMatter.frontCover.outsideFrontCover#74731b2d-18b0-420f-ac96-6de20f659810(data-page='required singleton', data-export='front-matter-cover', data-xmatter-page='frontCover')&attributes(attributes) + +page-label-english('Front Cover') + .marginBox(data-ignore="bloom-custom-cover") + +standard-cover-contents + block front-cover-footer + .marginBox.bloom-customMarginBox.bloom-contains-child-data(data-book="customCover" data-ignore="!bloom-custom-cover") mixin factoryStandard-creditsInsideFrontCover // Inside Front Cover CREDITS PAGE