diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf index 7804507a757d..f310f432c1a6 100644 --- a/DistFiles/localization/en/Bloom.xlf +++ b/DistFiles/localization/en/Bloom.xlf @@ -1887,6 +1887,11 @@ ID: EditTab.FrontMatter.OriginalContributorsPrompt Put {lang} in your translation, so it can be replaced by the language name. Do NOT translate "lang". + + Original copyright & license + ID: EditTab.FrontMatter.OriginalCopyrightAndLicenseHint + Label in the hint bubble shown on the credits page next to the sentence Bloom generates about the original book's copyright and license. + This book is an adaptation of the original, {0}. ID: EditTab.FrontMatter.OriginalCopyrightSentence diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 92acec8a47a4..ef8d5430b49b 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -55,6 +55,11 @@ ID: AiImageEditor.SlotLabel.CanvasBackground Names one picture of a page that has several, shown over the picture in the AI image editor's strip of the book's images. This is the picture behind the page, which other pictures sit on top of. The code puts the page name in front of it, giving e.g. "Page 3 - Canvas Background". + + Unlock to edit + ID: EditTab.FrontMatter.UnlockOriginalCopyrightAndLicenseTooltip + Tooltip on the padlock button in the hint bubble beside the sentence Bloom writes on the credits page about the original book's copyright and license. Clicking it lets the user reword that sentence. + No Indent ID: EditTab.TextContextMenu.NoIndent diff --git a/PAPERCUTS.md b/PAPERCUTS.md index 848384532bd7..8ef225ade174 100644 --- a/PAPERCUTS.md +++ b/PAPERCUTS.md @@ -329,3 +329,23 @@ want first in `MruProjects`, or delete the entries so Bloom shows the collection the branch rather than the version in that path. **Context:** BL-16781, after re-basing the `dev-blorgswitch` worktree onto Version6.5. + +## 2026-09-10 — `vitest run` finishes every test and then never exits + +In a fresh worktree (`pnpm install` run today, vitest 4.0.8), `pnpm exec vitest run` prints all +its ✓ lines and then hangs forever instead of printing the summary and exiting. Nothing has +failed — the tests are done — but there is no summary line, no exit code, and no way to tell +"still running" from "wedged". `--no-file-parallelism` (which AGENTS.md recommends for the older +worker-pool hang) and `--pool=forks` both hang the same way. + +It reads exactly like a hung test, so the reflex is to go hunting for the file that hangs. There +isn't one: run any single directory and its files all pass, then that run hangs too. Something +keeps the event loop alive after teardown. + +**Workaround:** run a directory at a time under `timeout`, and read the results out of the ✓ +lines rather than the summary — e.g. +`timeout 200 pnpm --dir src/BloomBrowserUI exec vitest run bookEdit/toolbox`. Note that +`timeout` does not kill the pnpm child, so a loop over directories has to be watched. + +**Context:** preflight on BL-16859. Whole front-end suite green this way (~350 tests), but it +took an hour of wall-clock to establish. diff --git a/src/BloomBrowserUI/bookEdit/css/editMode.less b/src/BloomBrowserUI/bookEdit/css/editMode.less index cc876174e752..b52c145b06b8 100644 --- a/src/BloomBrowserUI/bookEdit/css/editMode.less +++ b/src/BloomBrowserUI/bookEdit/css/editMode.less @@ -325,6 +325,41 @@ body:has(#canvas-element-context-controls:hover) .bloom-page, top: calc(@PageLabelVerticalDisplacement - 48); } +// An icon in the top right corner of a hint bubble, floated so the text wraps beside it. +// See BloomHintBubbles.getPossibleLinkIcon. +.hintBubbleIcon { + float: right; + line-height: 0; + margin: 0 0 4px 10px; + + img { + display: block; + // Sized by height rather than width: the open padlock's drawing is wider than the + // closed one's only because its shackle swings out to the side, and both are drawn in + // a box of the same height, so matching the heights makes the two padlocks match. + height: 15px; + width: auto; + } +} + +// When there is somewhere for it to go, it behaves like a small button. +a.hintBubbleIcon { + padding: 3px; + margin: -3px -3px 1px 7px; + border-radius: 3px; + opacity: 0.7; + cursor: pointer; + + &:hover { + opacity: 1; + background-color: fade(@tipLinkColor, 15%); + } + + &:active { + background-color: fade(@tipLinkColor, 30%); + } +} + [data-derived="originalCopyrightAndLicense"] { cite { &.missingOriginalTitle, diff --git a/src/BloomBrowserUI/bookEdit/editablePage.ts b/src/BloomBrowserUI/bookEdit/editablePage.ts index c88eafcf3748..e3231ac039a2 100644 --- a/src/BloomBrowserUI/bookEdit/editablePage.ts +++ b/src/BloomBrowserUI/bookEdit/editablePage.ts @@ -370,6 +370,16 @@ window["PasteImageCredits"] = () => { EditableDivUtils.pasteImageCredits(); }; +//UnlockOriginalCredits() is called from the hint bubble on the credits page's +//generated original-copyright sentence. See BookCopyrightAndLicense.SetOriginalCopyrightNoticeHint(). +window["UnlockOriginalCredits"] = () => { + EditableDivUtils.unlockOriginalCredits(); +}; + +window["RelockOriginalCredits"] = () => { + EditableDivUtils.relockOriginalCredits(); +}; + $(document).ready(() => { $("body").find("*[data-i18n]").localize(); bootstrap(); diff --git a/src/BloomBrowserUI/bookEdit/img/lock.svg b/src/BloomBrowserUI/bookEdit/img/lock.svg new file mode 100644 index 000000000000..bcdc3e0058fd --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/img/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/BloomBrowserUI/bookEdit/img/unlock.svg b/src/BloomBrowserUI/bookEdit/img/unlock.svg new file mode 100644 index 000000000000..e46f4cf60699 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/img/unlock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/BloomBrowserUI/bookEdit/js/BloomHintBubbles.ts b/src/BloomBrowserUI/bookEdit/js/BloomHintBubbles.ts index 13fa73c040db..c6fa4b3414e7 100644 --- a/src/BloomBrowserUI/bookEdit/js/BloomHintBubbles.ts +++ b/src/BloomBrowserUI/bookEdit/js/BloomHintBubbles.ts @@ -458,7 +458,10 @@ export default class BloomHintBubbles { shouldShowAlways = true; whatToSay = `${whatToSay}`; } - whatToSay = whatToSay + this.getPossibleHyperlink(source, target); + const icon = this.getPossibleLinkIcon(source, target); + const hyperlink = this.getPossibleHyperlink(source, target); + // The icon floats, so it has to come before the text it floats beside. + whatToSay = icon + whatToSay + hyperlink; if (onFocusOnly) { shouldShowAlways = false; } @@ -467,10 +470,34 @@ export default class BloomHintBubbles { whatToSay, shouldShowAlways, additionalQtipClasses, + !!functionCall || (icon + hyperlink).includes("`; + if (!linkTarget) return `${img}`; + if (linkTarget.indexOf("(") > 0) + linkTarget = "javascript:" + linkTarget + ";"; + const tooltip = bubbleSource.attr("data-link-icon-tooltip"); + const title = tooltip + ? ` title='${theOneLocalizationManager + .getLocalizedHint(tooltip, target.get(0)) + .replace(/'/g, "'")}'` + : ""; + return `${img}`; + } + // Handle a second line in the bubble which links to something like a javascript function private static getPossibleHyperlink( bubbleSource: JQuery, @@ -479,12 +506,12 @@ export default class BloomHintBubbles { let linkText = bubbleSource.attr("data-link-text"); let linkTarget = bubbleSource.attr("data-link-target"); if (linkText && linkTarget) { + if (linkTarget.indexOf("(") > 0) + linkTarget = "javascript:" + linkTarget + ";"; linkText = theOneLocalizationManager.getLocalizedHint( linkText, target.get(0), ); - if (linkTarget.indexOf("(") > 0) - linkTarget = "javascript:" + linkTarget + ";"; return "
" + linkText + ""; } return ""; @@ -495,6 +522,10 @@ export default class BloomHintBubbles { whatToSay: string, shouldShowAlways: boolean, additionalClasses?: string, + // True when the bubble contains something to click. A bubble sits to the right of its + // target, so with the default hide rules the pointer leaving the target to reach the + // link closes the bubble before the click lands. + bubbleContainsALink?: boolean, ) { const pos = { at: "right center", @@ -516,6 +547,10 @@ export default class BloomHintBubbles { }, hide: { event: hideEvents, + // fixed keeps the bubble open while the pointer is over the bubble itself; the + // delay covers the gap the pointer crosses to get there. + fixed: bubbleContainsALink, + delay: bubbleContainsALink ? 300 : 0, }, style: { classes: theClasses, diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 30ab1554988c..0498644bd3b8 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -875,6 +875,19 @@ export function SetupElements( // theOneCanvasElementManager.setActiveElement(undefined); // } + // A field the server has just opened for editing in response to a click, such as + // the sentence about the original book on the credits page. The user asked for it, + // so it wins over the guesses below, and the caret goes to the start of it. + const fieldJustOpened = container.querySelector( + "[data-bloom-focus-when-shown]", + ) as HTMLElement | null; + if (fieldJustOpened && elementToFocus !== "none") { + fieldJustOpened.removeAttribute("data-bloom-focus-when-shown"); + fieldJustOpened.focus(); + EditableDivUtils.makeSelectionIn(fieldJustOpened, 0, -1, true); + return; + } + if (elementToFocus !== "none") { // the check for visibility-code-on here prevents focusing a bloom-editable that we are just // showing as a prompt. diff --git a/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts b/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts index b7feafb5da25..385f5094130f 100644 --- a/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts +++ b/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts @@ -1,5 +1,5 @@ /// -import { get, postString } from "../../utils/bloomApi"; +import { get, post, postString } from "../../utils/bloomApi"; import $ from "jquery"; interface qtipInterface extends JQuery { @@ -276,6 +276,22 @@ export class EditableDivUtils { }); } + // Bloom generates the sentence about the original book's copyright and license into a + // data-derived div on the credits page, where the user cannot touch it. This asks the server + // to hand the text over: it stores the current wording in the data div, stops generating the + // sentence, and rebuilds the credits page with that spot as an ordinary editable field. + // The server saves the page and reloads it, so we have nothing to change here ourselves. + public static unlockOriginalCredits() { + post("copyrightAndLicense/userEditsOriginalCopyrightNotice"); + } + + // Close the sentence about the original book again. The wording the user has just typed is + // theirs from now on; saving the page stores it in the data div, and the page comes back + // with that spot read-only, as it is on every other visit. + public static relockOriginalCredits() { + post("common/saveChangesAndRethinkPageEvent"); + } + public static pasteImageCredits() { const activeElement = document.activeElement; get("image/imageCreditsForWholeBook", (result) => { diff --git a/src/BloomBrowserUI/bookEdit/js/editableDivUtilsSpec.ts b/src/BloomBrowserUI/bookEdit/js/editableDivUtilsSpec.ts index 68a625499be0..be125445d337 100644 --- a/src/BloomBrowserUI/bookEdit/js/editableDivUtilsSpec.ts +++ b/src/BloomBrowserUI/bookEdit/js/editableDivUtilsSpec.ts @@ -1,5 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import $ from "jquery"; import { EditableDivUtils } from "./editableDivUtils"; +import { post } from "../../utils/bloomApi"; + +vi.mock("../../utils/bloomApi", async (importOriginal) => ({ + ...((await importOriginal()) as object), + post: vi.fn(), +})); describe("EditableDivUtils Tests", () => { it("normalizeBloomLineBreakSpansInElement preserves a simple linebreak span", () => { @@ -611,3 +618,20 @@ describe("EditableDivUtils Tests", () => { expect(div.innerHTML).toBe(before); }); }); + +describe("EditableDivUtils.unlockOriginalCredits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // All the work happens on the server, which stores the current wording, stops generating + // the sentence, and reloads the page with an editable field in its place. All this side + // has to get right is the endpoint name. + it("asks the server to hand the notice over to the user", () => { + EditableDivUtils.unlockOriginalCredits(); + + expect(post).toHaveBeenCalledWith( + "copyrightAndLicense/userEditsOriginalCopyrightNotice", + ); + }); +}); diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 7833b64905e3..58dbd574cf8f 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -1591,7 +1591,8 @@ originalLicense is CreativeCommonsLicenseInfo bookDom, this.FolderPath, bookData, - false + false, + BookInfo.MetaData.UserEditsOriginalCopyrightNotice ); } } @@ -2121,7 +2122,8 @@ private void EnsureUpToDateMemoryUnprotected(IProgress progress) OurHtmlDom, FolderPath, _bookData, - BookInfo.MetaData.UseOriginalCopyright + BookInfo.MetaData.UseOriginalCopyright, + BookInfo.MetaData.UserEditsOriginalCopyrightNotice ); _bookData.MergeBrandingSettings(CollectionSettings.Subscription.BrandingKey); _bookData.SynchronizeDataItemsThroughoutDOM(); @@ -2146,7 +2148,8 @@ private void EnsureUpToDateMemoryUnprotected(IProgress progress) OurHtmlDom, FolderPath, _bookData, - BookInfo.MetaData.UseOriginalCopyright + BookInfo.MetaData.UseOriginalCopyright, + BookInfo.MetaData.UserEditsOriginalCopyrightNotice ); OurHtmlDom.RemoveMetaElement( @@ -4260,6 +4263,11 @@ public bool UpdateDomFromEditedPage( if (needToDoFullSave) _bookData.SuckInDataFromEditedDom(editedPageDom, BookInfo); //this will do an updatetitle + // The user's permission to edit the sentence about the original book was good for + // one look at the page, so the copy that goes back into the book holds their wording + // as text they cannot type in. + BookCopyrightAndLicense.LockOriginalCopyrightNotice(pageToSaveToDisk); + // When the user edits the styles on a page, the new or modified rules show up in a