Skip to content

Commit 2febabf

Browse files
Merge branch 'master' into feature_v1
2 parents f643c38 + 5a34c5b commit 2febabf

33 files changed

+4362
-333
lines changed

index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
<link rel="preload" href="https://fonts.googleapis.com/icon?family=Material+Icons" as="style"
2121
onload="this.onload=null;this.rel='stylesheet'">
2222
<link rel="preload" href="fonts/material-icons.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
23+
<!-- NOTE: The exact query-string URL here must be listed in sw.js `precacheFiles`.
24+
If you change `?v=fixed` (or remove/add a query param), update sw.js so offline still works. -->
2325
<link rel="preload" href="css/activities.css?v=fixed" as="style" onload="this.onload=null;this.rel='stylesheet'">
2426
<link rel="preload" href="dist/css/style.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
2527
<link rel="preload" href="dist/css/keyboard.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

js/SaveInterface.js

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -784,20 +784,49 @@ class SaveInterface {
784784

785785
afterSaveLilypondLY(lydata, filename) {
786786
filename = docById("fileName").value;
787-
if (platform.FF) {
788-
// eslint-disable-next-line no-console
789-
console.debug('execCommand("copy") does not work on FireFox');
790-
} else {
787+
const showCopiedMessage = () => {
788+
this.activity.textMsg(
789+
_("The Lilypond code is copied to clipboard. You can paste it here: ") +
790+
"<a href='http://hacklily.org' target='_blank'>http://hacklily.org</a> "
791+
);
792+
};
793+
794+
const legacyCopy = () => {
791795
const tmp = jQuery("<textarea />").appendTo(document.body);
792796
tmp.val(lydata);
793797
tmp.select();
794798
tmp[0].setSelectionRange(0, lydata.length);
795-
document.execCommand("copy");
799+
let copied = false;
800+
try {
801+
copied = document.execCommand("copy");
802+
} catch (e) {
803+
copied = false;
804+
}
796805
tmp.remove();
797-
this.activity.textMsg(
798-
_("The Lilypond code is copied to clipboard. You can paste it here: ") +
799-
"<a href='http://hacklily.org' target='_blank'>http://hacklily.org</a> "
800-
);
806+
return copied;
807+
};
808+
809+
if (navigator.clipboard && window.isSecureContext) {
810+
navigator.clipboard
811+
.writeText(lydata)
812+
.then(showCopiedMessage)
813+
.catch(err => {
814+
const copied = legacyCopy();
815+
if (copied) {
816+
showCopiedMessage();
817+
} else {
818+
// eslint-disable-next-line no-console
819+
console.debug("Clipboard copy failed:", err);
820+
}
821+
});
822+
} else {
823+
const copied = legacyCopy();
824+
if (copied) {
825+
showCopiedMessage();
826+
} else {
827+
// eslint-disable-next-line no-console
828+
console.debug("Clipboard copy failed");
829+
}
801830
}
802831
this.download("ly", "data:text;utf8," + encodeURIComponent(lydata), filename);
803832
}

js/__tests__/SaveInterface.test.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ describe("afterSaveMIDI", () => {
273273
global.activity = {
274274
logo: {
275275
_midiData: {
276-
"0": [
276+
0: [
277277
{
278278
note: ["G4"],
279279
duration: 4,
@@ -514,8 +514,13 @@ describe("saveWAV & saveABC methods", () => {
514514

515515
describe("saveLilypond Methods", () => {
516516
let activity, saveInterface, mockActivity, mockDocById;
517+
let originalClipboard;
518+
let originalSecureContext;
517519

518520
beforeEach(() => {
521+
jest.useRealTimers();
522+
originalClipboard = navigator.clipboard;
523+
originalSecureContext = window.isSecureContext;
519524
// Set up the DOM structure
520525
document.body.innerHTML = `
521526
<div id="lilypondModal" style="display: none;">
@@ -553,6 +558,7 @@ describe("saveLilypond Methods", () => {
553558
return { on: jest.fn() };
554559
}
555560
return {
561+
0: { setSelectionRange: jest.fn() },
556562
appendTo: jest.fn().mockReturnThis(),
557563
val: jest.fn().mockReturnThis(),
558564
select: jest.fn(),
@@ -661,6 +667,15 @@ describe("saveLilypond Methods", () => {
661667

662668
afterEach(() => {
663669
jest.clearAllMocks();
670+
if (originalClipboard === undefined) {
671+
delete navigator.clipboard;
672+
} else {
673+
navigator.clipboard = originalClipboard;
674+
}
675+
Object.defineProperty(window, "isSecureContext", {
676+
value: originalSecureContext,
677+
configurable: true
678+
});
664679
});
665680

666681
it("should save a Lilypond file with default settings", () => {
@@ -749,6 +764,58 @@ describe("saveLilypond Methods", () => {
749764
// Verify activity.save.download is not called
750765
expect(activity.save.download).not.toHaveBeenCalled();
751766
});
767+
768+
it("should show copied message when Clipboard API succeeds", async () => {
769+
const writeText = jest.fn().mockResolvedValue();
770+
navigator.clipboard = { writeText };
771+
Object.defineProperty(window, "isSecureContext", { value: true, configurable: true });
772+
773+
saveInterface.afterSaveLilypondLY(lydata, filename);
774+
await writeText.mock.results[0].value;
775+
776+
expect(writeText).toHaveBeenCalledWith(lydata);
777+
expect(document.execCommand).not.toHaveBeenCalled();
778+
expect(mockActivity.textMsg).toHaveBeenCalled();
779+
});
780+
781+
it("should fall back to legacy copy when Clipboard API fails", async () => {
782+
const writeText = jest.fn().mockRejectedValue(new Error("denied"));
783+
navigator.clipboard = { writeText };
784+
Object.defineProperty(window, "isSecureContext", { value: true, configurable: true });
785+
document.execCommand.mockReturnValue(true);
786+
787+
saveInterface.afterSaveLilypondLY(lydata, filename);
788+
await writeText.mock.results[0].value.catch(() => {});
789+
790+
expect(writeText).toHaveBeenCalledWith(lydata);
791+
expect(document.execCommand).toHaveBeenCalledWith("copy");
792+
expect(mockActivity.textMsg).toHaveBeenCalled();
793+
});
794+
795+
it("should not show copied message when all copy methods fail", async () => {
796+
const writeText = jest.fn().mockRejectedValue(new Error("denied"));
797+
navigator.clipboard = { writeText };
798+
Object.defineProperty(window, "isSecureContext", { value: true, configurable: true });
799+
document.execCommand.mockReturnValue(false);
800+
801+
saveInterface.afterSaveLilypondLY(lydata, filename);
802+
await writeText.mock.results[0].value.catch(() => {});
803+
804+
expect(writeText).toHaveBeenCalledWith(lydata);
805+
expect(document.execCommand).toHaveBeenCalledWith("copy");
806+
expect(mockActivity.textMsg).not.toHaveBeenCalled();
807+
});
808+
809+
it("should use legacy copy when Clipboard API is unavailable", () => {
810+
delete navigator.clipboard;
811+
Object.defineProperty(window, "isSecureContext", { value: true, configurable: true });
812+
document.execCommand.mockReturnValue(true);
813+
814+
saveInterface.afterSaveLilypondLY(lydata, filename);
815+
816+
expect(document.execCommand).toHaveBeenCalledWith("copy");
817+
expect(mockActivity.textMsg).toHaveBeenCalled();
818+
});
752819
});
753820

754821
describe("MXML Methods", () => {

0 commit comments

Comments
 (0)