Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Claude Code Instructions

## Git Commits
- Use Conventional Commits format: `type(scope): description` (e.g. `fix: ...`, `feat: ...`, `chore: ...`).
- Keep commit subject lines concise; use the body for detail.
- Never include `Co-Authored-By` lines in commit messages.

## Code Style
- 4-space indentation, never tabs.
- Always use semicolons.
- Brace style: (`if (x) {`), single-line blocks allowed.
- Always use curly braces for `if`/`else`/`for`/`while`.
- No trailing whitespace.
2 changes: 1 addition & 1 deletion src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: asset: https://asset.localhost localhost:* ws://localhost:* ws://127.0.0.1:* https://storage.googleapis.com https://platform.twitter.com https://buttons.github.io https://unpkg.com/@aicore/ https://www.googletagmanager.com;
img-src * data: localhost:* asset: https://asset.localhost ;
img-src * data: blob: localhost:* asset: https://asset.localhost ;
media-src * data: localhost:* asset: https://asset.localhost ;
font-src * data: localhost:* asset: https://asset.localhost ;
frame-src * localhost:* asset: https://asset.localhost ;
Expand Down
157 changes: 157 additions & 0 deletions src/phoenix/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,86 @@ Phoenix.libs = {
// global API is only usable/stable after App init
Phoenix.globalAPI = {};

function _resolveRect(rectOrNodeOrSelector) {
if (rectOrNodeOrSelector === undefined || rectOrNodeOrSelector === null) {
return undefined; // full page capture
}
let element;
// Case 1: jQuery selector string
if (typeof rectOrNodeOrSelector === 'string') {
const $el = $(rectOrNodeOrSelector);
if ($el.length === 0) {
throw new Error("No element found for selector: " +
rectOrNodeOrSelector);
}
if ($el.length > 1) {
throw new Error("Selector must match exactly one element, but matched " +
$el.length + ": " + rectOrNodeOrSelector);
}
element = $el[0];
} else if (rectOrNodeOrSelector instanceof HTMLElement) {
// Case 2: DOM node (Element instance)
element = rectOrNodeOrSelector;
} else if (typeof rectOrNodeOrSelector === 'object') {
// Case 3: Plain rect object {x, y, width, height}
return rectOrNodeOrSelector; // pass through for validation in _capturePageBinary
} else {
throw new Error("Expected a rect object, DOM node, or jQuery selector string");
}
// Convert DOM element to rect via getBoundingClientRect().
// getBoundingClientRect() returns values in the zoomed CSS coordinate space, but
// the native capture APIs (Electron capturePage, Tauri capture_page) expect
// coordinates in the unzoomed viewport space. Divide by the webview zoom factor
// to convert.
const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1;
const domRect = element.getBoundingClientRect();
return {
x: Math.round(domRect.x * zoomFactor),
y: Math.round(domRect.y * zoomFactor),
width: Math.round(domRect.width * zoomFactor),
height: Math.round(domRect.height * zoomFactor)
};
}

async function _capturePageBinary(rectOrNodeOrSelector) {
if (!Phoenix.isNativeApp) {
throw new Error("Screenshot capture is not supported in browsers");
}
const rect = _resolveRect(rectOrNodeOrSelector);
if (rect !== undefined) {
if (rect.x === undefined || rect.y === undefined ||
rect.width === undefined || rect.height === undefined) {
throw new Error("rect must include all fields: x, y, width, height");
}
if (typeof rect.x !== 'number' || typeof rect.y !== 'number' ||
typeof rect.width !== 'number' || typeof rect.height !== 'number') {
throw new Error("rect fields x, y, width, height must be numbers");
}
if (rect.x < 0 || rect.y < 0 || rect.width < 0 || rect.height < 0) {
throw new Error("rect fields x, y, width, height must be non-negative");
}
if (rect.width <= 0 || rect.height <= 0) {
throw new Error("rect width and height must be greater than 0");
}
const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1;
const maxWidth = Math.ceil(window.innerWidth * zoomFactor);
const maxHeight = Math.ceil(window.innerHeight * zoomFactor);
if (rect.x + rect.width > maxWidth) {
throw new Error("rect x + width exceeds window innerWidth");
}
if (rect.y + rect.height > maxHeight) {
throw new Error("rect y + height exceeds window innerHeight");
}
}
if (window.__TAURI__) {
const bytes = await window.__TAURI__.invoke('capture_page', { rect });
return new Uint8Array(bytes);
}
if (window.__ELECTRON__) {
return window.electronAPI.capturePage(rect);
}
}

Phoenix.app = {
getNodeState: function (cbfn){
cbfn(new Error('Node cannot be run in phoenix browser mode'));
Expand Down Expand Up @@ -794,6 +874,83 @@ Phoenix.app = {
return window.electronAPI.onWindowEvent(eventName, callback);
}
return () => {}; // No-op for unsupported platforms
},
/**
* Captures a screenshot and returns the raw PNG bytes.
* @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be:
* - A rect object `{x, y, width, height}` specifying pixel coordinates
* - A DOM element whose bounding rect will be captured
* - A jQuery selector string (must match exactly one element)
* - Omit to capture the full page
* @returns {Promise<Uint8Array>} PNG image data
* @example <caption>Capture a specific rectangle</caption>
* const bytes = await Phoenix.app.screenShotBinary({
* x: 100, y: 100, width: 400, height: 300
* });
* @example <caption>Capture a DOM element</caption>
* const element = document.getElementById("preview");
* const bytes = await Phoenix.app.screenShotBinary(element);
* @example <caption>Capture using a selector</caption>
* const bytes = await Phoenix.app.screenShotBinary("#preview");
* @example <caption>Capture the full page</caption>
* const bytes = await Phoenix.app.screenShotBinary();
*/
screenShotBinary: function (rectOrNodeOrSelector) {
return _capturePageBinary(rectOrNodeOrSelector);
},
/**
* Captures a screenshot and returns it as a PNG Blob.
* @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be:
* - A rect object `{x, y, width, height}` specifying pixel coordinates
* - A DOM element whose bounding rect will be captured
* - A jQuery selector string (must match exactly one element)
* - Omit to capture the full page
* @returns {Promise<Blob>} PNG Blob with type "image/png"
* @example <caption>Display in an image element</caption>
* const blob = await Phoenix.app.screenShotToBlob("#preview");
* const url = URL.createObjectURL(blob);
* document.getElementById("imgOutput").src = url;
* @example <caption>Draw to a canvas</caption>
* const blob = await Phoenix.app.screenShotToBlob();
* const bitmap = await createImageBitmap(blob);
* ctx.drawImage(bitmap, 0, 0);
*/
screenShotToBlob: async function (rectOrNodeOrSelector) {
const bytes = await _capturePageBinary(rectOrNodeOrSelector);
return new Blob([bytes], { type: "image/png" });
},
/**
* Captures a screenshot and writes it to a PNG file.
* @param {string} filePathToSave - VFS path to save the PNG file to
* @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be:
* - A rect object `{x, y, width, height}` specifying pixel coordinates
* - A DOM element whose bounding rect will be captured
* - A jQuery selector string (must match exactly one element)
* - Omit to capture the full page
* @returns {Promise<void>}
* @throws {Error} If filePathToSave is not a non-empty string
* @example <caption>Save the full page</caption>
* await Phoenix.app.screenShotToPNGFile("/project/output/screenshot.png");
* @example <caption>Save a specific element</caption>
* await Phoenix.app.screenShotToPNGFile(
* "/project/output/preview.png",
* "#preview"
* );
*/
screenShotToPNGFile: async function (filePathToSave, rectOrNodeOrSelector) {
if (!filePathToSave || typeof filePathToSave !== 'string') {
throw new Error("filePathToSave must be a non-empty string");
}
const bytes = await _capturePageBinary(rectOrNodeOrSelector);
return new Promise((resolve, reject) => {
fs.writeFile(filePathToSave, bytes.buffer, 'binary', (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
};

Expand Down
Loading
Loading