Skip to content

Commit 40d87d4

Browse files
committed
feat: screenshot APIs accept DOM nodes and jQuery selectors
Add _resolveRect() helper to convert DOM elements and jQuery selector strings to capture rects, with zoom factor compensation. Update _capturePageBinary bounds validation to account for webview scale factor. Add tests for DOM element, selector, and error case inputs.
1 parent 6d17f0d commit 40d87d4

2 files changed

Lines changed: 181 additions & 9 deletions

File tree

src/phoenix/shell.js

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,52 @@ Phoenix.libs = {
147147
// global API is only usable/stable after App init
148148
Phoenix.globalAPI = {};
149149

150-
async function _capturePageBinary(rect) {
150+
function _resolveRect(rectOrNodeOrSelector) {
151+
if (rectOrNodeOrSelector === undefined || rectOrNodeOrSelector === null) {
152+
return undefined; // full page capture
153+
}
154+
let element;
155+
// Case 1: jQuery selector string
156+
if (typeof rectOrNodeOrSelector === 'string') {
157+
const $el = $(rectOrNodeOrSelector);
158+
if ($el.length === 0) {
159+
throw new Error("No element found for selector: " +
160+
rectOrNodeOrSelector);
161+
}
162+
if ($el.length > 1) {
163+
throw new Error("Selector must match exactly one element, but matched " +
164+
$el.length + ": " + rectOrNodeOrSelector);
165+
}
166+
element = $el[0];
167+
} else if (rectOrNodeOrSelector instanceof HTMLElement) {
168+
// Case 2: DOM node (Element instance)
169+
element = rectOrNodeOrSelector;
170+
} else if (typeof rectOrNodeOrSelector === 'object') {
171+
// Case 3: Plain rect object {x, y, width, height}
172+
return rectOrNodeOrSelector; // pass through for validation in _capturePageBinary
173+
} else {
174+
throw new Error("Expected a rect object, DOM node, or jQuery selector string");
175+
}
176+
// Convert DOM element to rect via getBoundingClientRect().
177+
// getBoundingClientRect() returns values in the zoomed CSS coordinate space, but
178+
// the native capture APIs (Electron capturePage, Tauri capture_page) expect
179+
// coordinates in the unzoomed viewport space. Divide by the webview zoom factor
180+
// to convert.
181+
const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1;
182+
const domRect = element.getBoundingClientRect();
183+
return {
184+
x: Math.round(domRect.x * zoomFactor),
185+
y: Math.round(domRect.y * zoomFactor),
186+
width: Math.round(domRect.width * zoomFactor),
187+
height: Math.round(domRect.height * zoomFactor)
188+
};
189+
}
190+
191+
async function _capturePageBinary(rectOrNodeOrSelector) {
151192
if (!Phoenix.isNativeApp) {
152193
throw new Error("Screenshot capture is not supported in browsers");
153194
}
195+
const rect = _resolveRect(rectOrNodeOrSelector);
154196
if (rect !== undefined) {
155197
if (rect.x === undefined || rect.y === undefined ||
156198
rect.width === undefined || rect.height === undefined) {
@@ -166,10 +208,11 @@ async function _capturePageBinary(rect) {
166208
if (rect.width <= 0 || rect.height <= 0) {
167209
throw new Error("rect width and height must be greater than 0");
168210
}
169-
if (rect.x + rect.width > window.innerWidth) {
211+
const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1;
212+
if (rect.x + rect.width > window.innerWidth * zoomFactor) {
170213
throw new Error("rect x + width exceeds window innerWidth");
171214
}
172-
if (rect.y + rect.height > window.innerHeight) {
215+
if (rect.y + rect.height > window.innerHeight * zoomFactor) {
173216
throw new Error("rect y + height exceeds window innerHeight");
174217
}
175218
}
@@ -830,18 +873,46 @@ Phoenix.app = {
830873
}
831874
return () => {}; // No-op for unsupported platforms
832875
},
833-
screenShotBinary: function (rect) {
834-
return _capturePageBinary(rect);
876+
/**
877+
* Captures a screenshot and returns the raw PNG bytes.
878+
* @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be:
879+
* - A rect object `{x, y, width, height}` specifying pixel coordinates
880+
* - A DOM element whose bounding rect will be captured
881+
* - A jQuery selector string (must match exactly one element)
882+
* - Omit to capture the full page
883+
* @returns {Promise<Uint8Array>} PNG image data
884+
*/
885+
screenShotBinary: function (rectOrNodeOrSelector) {
886+
return _capturePageBinary(rectOrNodeOrSelector);
835887
},
836-
screenShotToBlob: async function (rect) {
837-
const bytes = await _capturePageBinary(rect);
888+
/**
889+
* Captures a screenshot and returns it as a PNG Blob.
890+
* @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be:
891+
* - A rect object `{x, y, width, height}` specifying pixel coordinates
892+
* - A DOM element whose bounding rect will be captured
893+
* - A jQuery selector string (must match exactly one element)
894+
* - Omit to capture the full page
895+
* @returns {Promise<Blob>} PNG Blob with type "image/png"
896+
*/
897+
screenShotToBlob: async function (rectOrNodeOrSelector) {
898+
const bytes = await _capturePageBinary(rectOrNodeOrSelector);
838899
return new Blob([bytes], { type: "image/png" });
839900
},
840-
screenShotToPNGFile: async function (filePathToSave, rect) {
901+
/**
902+
* Captures a screenshot and writes it to a PNG file.
903+
* @param {string} filePathToSave - VFS path to save the PNG file to
904+
* @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be:
905+
* - A rect object `{x, y, width, height}` specifying pixel coordinates
906+
* - A DOM element whose bounding rect will be captured
907+
* - A jQuery selector string (must match exactly one element)
908+
* - Omit to capture the full page
909+
* @returns {Promise<void>}
910+
*/
911+
screenShotToPNGFile: async function (filePathToSave, rectOrNodeOrSelector) {
841912
if (!filePathToSave || typeof filePathToSave !== 'string') {
842913
throw new Error("filePathToSave must be a non-empty string");
843914
}
844-
const bytes = await _capturePageBinary(rect);
915+
const bytes = await _capturePageBinary(rectOrNodeOrSelector);
845916
return new Promise((resolve, reject) => {
846917
fs.writeFile(filePathToSave, bytes.buffer, 'binary', (err) => {
847918
if (err) {

test/spec/Native-platform-test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,54 @@ define(function (require, exports, module) {
461461
Phoenix.app.screenShotBinary({x: 0, y: 0, width: 100, height: 999999})
462462
).toBeRejectedWithError("rect y + height exceeds window innerHeight");
463463
});
464+
465+
it("Should capture a screenshot of a DOM element", async function () {
466+
const el = document.createElement("div");
467+
el.id = "screenshot-test-element";
468+
el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;";
469+
document.body.appendChild(el);
470+
try {
471+
const bytes = await Phoenix.app.screenShotBinary(el);
472+
expect(bytes instanceof Uint8Array).toBeTrue();
473+
expect(bytes.length).toBeGreaterThan(0);
474+
expect(isPNG(bytes)).withContext("Result should be valid PNG data").toBeTrue();
475+
} finally {
476+
el.remove();
477+
}
478+
});
479+
480+
it("Should capture a screenshot using a jQuery selector string", async function () {
481+
const el = document.createElement("div");
482+
el.id = "screenshot-test-element";
483+
el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;";
484+
document.body.appendChild(el);
485+
try {
486+
const bytes = await Phoenix.app.screenShotBinary("#screenshot-test-element");
487+
expect(bytes instanceof Uint8Array).toBeTrue();
488+
expect(bytes.length).toBeGreaterThan(0);
489+
expect(isPNG(bytes)).withContext("Result should be valid PNG data").toBeTrue();
490+
} finally {
491+
el.remove();
492+
}
493+
});
494+
495+
it("Should throw when jQuery selector matches no elements", async function () {
496+
await expectAsync(
497+
Phoenix.app.screenShotBinary("#nonexistent-element-xyz")
498+
).toBeRejectedWithError("No element found for selector: #nonexistent-element-xyz");
499+
});
500+
501+
it("Should throw when jQuery selector matches multiple elements", async function () {
502+
await expectAsync(
503+
Phoenix.app.screenShotBinary("div")
504+
).toBeRejectedWithError(/Selector must match exactly one element, but matched \d+: div/);
505+
});
506+
507+
it("Should throw for invalid argument type", async function () {
508+
await expectAsync(
509+
Phoenix.app.screenShotBinary(42)
510+
).toBeRejectedWithError("Expected a rect object, DOM node, or jQuery selector string");
511+
});
464512
});
465513

466514
describe("screenShotToBlob", function () {
@@ -477,6 +525,36 @@ define(function (require, exports, module) {
477525
expect(blob.type).toEqual("image/png");
478526
expect(blob.size).toBeGreaterThan(0);
479527
});
528+
529+
it("Should return a Blob when given a DOM element", async function () {
530+
const el = document.createElement("div");
531+
el.id = "screenshot-blob-test-element";
532+
el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;";
533+
document.body.appendChild(el);
534+
try {
535+
const blob = await Phoenix.app.screenShotToBlob(el);
536+
expect(blob instanceof Blob).toBeTrue();
537+
expect(blob.type).toEqual("image/png");
538+
expect(blob.size).toBeGreaterThan(0);
539+
} finally {
540+
el.remove();
541+
}
542+
});
543+
544+
it("Should return a Blob when given a jQuery selector", async function () {
545+
const el = document.createElement("div");
546+
el.id = "screenshot-blob-test-element";
547+
el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;";
548+
document.body.appendChild(el);
549+
try {
550+
const blob = await Phoenix.app.screenShotToBlob("#screenshot-blob-test-element");
551+
expect(blob instanceof Blob).toBeTrue();
552+
expect(blob.type).toEqual("image/png");
553+
expect(blob.size).toBeGreaterThan(0);
554+
} finally {
555+
el.remove();
556+
}
557+
});
480558
});
481559

482560
describe("screenShotToPNGFile", function () {
@@ -526,6 +604,29 @@ define(function (require, exports, module) {
526604
expect(isPNG(bytes)).withContext("Written file should be valid PNG").toBeTrue();
527605
});
528606

607+
it("Should write a valid PNG file when given a jQuery selector", async function () {
608+
const el = document.createElement("div");
609+
el.id = "screenshot-file-test-element";
610+
el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;";
611+
document.body.appendChild(el);
612+
try {
613+
await Phoenix.app.screenShotToPNGFile(testFilePath, "#screenshot-file-test-element");
614+
const content = await new Promise((resolve, reject) => {
615+
fs.readFile(testFilePath, 'binary', (err, data) => {
616+
if (err) {
617+
reject(err);
618+
} else {
619+
resolve(data);
620+
}
621+
});
622+
});
623+
const bytes = new Uint8Array(content);
624+
expect(isPNG(bytes)).withContext("Written file should be valid PNG").toBeTrue();
625+
} finally {
626+
el.remove();
627+
}
628+
});
629+
529630
it("Should throw when filePathToSave is not provided", async function () {
530631
await expectAsync(
531632
Phoenix.app.screenShotToPNGFile()

0 commit comments

Comments
 (0)