Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions DistFiles/localization/en/Bloom.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -1887,6 +1887,11 @@
<note>ID: EditTab.FrontMatter.OriginalContributorsPrompt</note>
<note>Put {lang} in your translation, so it can be replaced by the language name. Do NOT translate "lang".</note>
</trans-unit>
<trans-unit id="EditTab.FrontMatter.OriginalCopyrightAndLicenseHint" sil:dynamic="true" translate="no">
<source xml:lang="en">Original copyright &amp; license</source>
<note>ID: EditTab.FrontMatter.OriginalCopyrightAndLicenseHint</note>
<note>Label in the hint bubble shown on the credits page next to the sentence Bloom generates about the original book's copyright and license.</note>
</trans-unit>
<trans-unit id="EditTab.FrontMatter.OriginalCopyrightSentence">
<source xml:lang="en">This book is an adaptation of the original, {0}.</source>
<note>ID: EditTab.FrontMatter.OriginalCopyrightSentence</note>
Expand Down
5 changes: 5 additions & 0 deletions DistFiles/localization/en/BloomMediumPriority.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
<note>ID: AiImageEditor.SlotLabel.CanvasBackground</note>
<note>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".</note>
</trans-unit>
<trans-unit id="EditTab.FrontMatter.UnlockOriginalCopyrightAndLicenseTooltip" sil:dynamic="true" translate="no">
<source xml:lang="en">Unlock to edit</source>
<note>ID: EditTab.FrontMatter.UnlockOriginalCopyrightAndLicenseTooltip</note>
<note>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.</note>
</trans-unit>
<trans-unit id="EditTab.TextContextMenu.NoIndent">
<source xml:lang="en">No Indent</source>
<note>ID: EditTab.TextContextMenu.NoIndent</note>
Expand Down
20 changes: 20 additions & 0 deletions PAPERCUTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 35 additions & 0 deletions src/BloomBrowserUI/bookEdit/css/editMode.less
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/BloomBrowserUI/bookEdit/editablePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/BloomBrowserUI/bookEdit/img/lock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/BloomBrowserUI/bookEdit/img/unlock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 38 additions & 3 deletions src/BloomBrowserUI/bookEdit/js/BloomHintBubbles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,10 @@ export default class BloomHintBubbles {
shouldShowAlways = true;
whatToSay = `<a href='${functionCall}'>${whatToSay}</a>`;
}
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;
}
Expand All @@ -467,10 +470,34 @@ export default class BloomHintBubbles {
whatToSay,
shouldShowAlways,
additionalQtipClasses,
!!functionCall || (icon + hyperlink).includes("<a "),
);
}, bloomQtipUtils.horizontalOverlappingBubblesDelay);
}

// An icon in the top right corner of the bubble: data-link-icon names a file in
// bookEdit/img, data-link-target says what clicking it does, and data-link-icon-tooltip
// says what it is for. Without a target it is just an indicator, with nothing to click.
private static getPossibleLinkIcon(
bubbleSource: JQuery,
target: JQuery,
): string {
const linkIcon = bubbleSource.attr("data-link-icon");
if (!linkIcon) return "";
let linkTarget = bubbleSource.attr("data-link-target");
const img = `<img src='/bloom/bookEdit/img/${linkIcon}.svg'>`;
if (!linkTarget) return `<span class='hintBubbleIcon'>${img}</span>`;
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, "&#39;")}'`
: "";
return `<a class='hintBubbleIcon' href='${linkTarget}'${title}>${img}</a>`;
}

// Handle a second line in the bubble which links to something like a javascript function
private static getPossibleHyperlink(
bubbleSource: JQuery,
Expand All @@ -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 "<br><a href='" + linkTarget + "'>" + linkText + "</a>";
}
return "";
Expand All @@ -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",
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// <reference path="../../typings/jquery/jquery.d.ts" />
import { get, postString } from "../../utils/bloomApi";
import { get, post, postString } from "../../utils/bloomApi";
import $ from "jquery";

interface qtipInterface extends JQuery {
Expand Down Expand Up @@ -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) => {
Expand Down
26 changes: 25 additions & 1 deletion src/BloomBrowserUI/bookEdit/js/editableDivUtilsSpec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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",
);
});
});
47 changes: 43 additions & 4 deletions src/BloomExe/Book/Book.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1591,7 +1591,8 @@ originalLicense is CreativeCommonsLicenseInfo
bookDom,
this.FolderPath,
bookData,
false
false,
BookInfo.MetaData.UserEditsOriginalCopyrightNotice
);
}
}
Expand Down Expand Up @@ -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();
Expand All @@ -2146,7 +2148,8 @@ private void EnsureUpToDateMemoryUnprotected(IProgress progress)
OurHtmlDom,
FolderPath,
_bookData,
BookInfo.MetaData.UseOriginalCopyright
BookInfo.MetaData.UseOriginalCopyright,
BookInfo.MetaData.UserEditsOriginalCopyrightNotice
);

OurHtmlDom.RemoveMetaElement(
Expand Down Expand Up @@ -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 <style/> element with title "userModifiedStyles".
// Here we copy that over to the book DOM.
var userModifiedStyles = HtmlDom.GetUserModifiedStyleElement(editedPageDom.Head);
Expand Down Expand Up @@ -4900,11 +4908,42 @@ public void SetMetadata(Metadata metadata)
OurHtmlDom,
FolderPath,
_bookData,
BookInfo.MetaData.UseOriginalCopyright
BookInfo.MetaData.UseOriginalCopyright,
BookInfo.MetaData.UserEditsOriginalCopyrightNotice
);
BookInfo.SetLicenseAndCopyrightMetadata(metadata);
}

/// <summary>
/// True for exactly one rendering of the credits page: the one right after the user
/// clicks the padlock. Editing the sentence about the original book is a momentary
/// permission, so leaving the page or refreshing it shows it locked again.
/// </summary>
public bool OriginalCopyrightNoticeIsUnlockedForOneRendering { get; set; }

/// <summary>
/// Hand the sentence about the original book's copyright and license over to the user.
/// The wording Bloom is showing right now goes into the data div, the flag is set so
/// that Bloom stops generating it, and the next rendering of the page shows it as an
/// editable field.
/// </summary>
public void LetUserEditOriginalCopyrightNotice()
{
// Only the first time: after that the data div already holds the user's own wording.
if (!BookInfo.MetaData.UserEditsOriginalCopyrightNotice)
{
BookCopyrightAndLicense.SeedUserEditableOriginalCopyrightNotice(
OurHtmlDom,
_bookData
);
BookInfo.MetaData.UserEditsOriginalCopyrightNotice = true;
// Book.Save() does not write meta.json, so this has to be explicit.
BookInfo.Save();
SetMetadata(GetLicenseMetadata());
}
OriginalCopyrightNoticeIsUnlockedForOneRendering = true;
}

public void SetTitle(string name)
{
OurHtmlDom.Title = name;
Expand Down
Loading