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
105 changes: 103 additions & 2 deletions packages/scratch-gui/src/lib/ruby-screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,93 @@ const buildFilename = function (projectTitle, spriteName) {
return `${projectTitle}_${spriteName}_ruby.png`;
};

/**
* Measures the maximum rendered text width across all visible lines
* in the Monaco editor, relative to the editor DOM node's left edge.
* This is more reliable than pixel scanning because Monaco renders
* decorative elements (overview ruler, scrollbar, line highlights)
* that span the full editor width.
* @param {HTMLElement} editorDomNode - the editor's root DOM node
* @returns {number} maximum text right edge in CSS pixels, relative to editorDomNode
*/
const measureTextWidth = function (editorDomNode) {
const viewLines = editorDomNode.querySelector('.view-lines');
if (!viewLines) return 0;

const editorLeft = editorDomNode.getBoundingClientRect().left;
let maxRight = 0;

for (const line of viewLines.children) {
const spans = line.querySelectorAll('span span');
for (const span of spans) {
const right = span.getBoundingClientRect().right;
if (right > maxRight) maxRight = right;
}
}

return maxRight > 0 ? maxRight - editorLeft : 0;
};

/**
* Measures the maximum rendered furigana annotation width.
* ViewZones may extend beyond the code text width.
* @param {HTMLElement} editorDomNode - the editor's root DOM node
* @returns {number} maximum furigana right edge in CSS pixels, relative to editorDomNode
*/
const measureFuriganaWidth = function (editorDomNode) {
const viewZones = editorDomNode.querySelector('.view-zones');
if (!viewZones) return 0;

const editorLeft = editorDomNode.getBoundingClientRect().left;
let maxRight = 0;

const spans = viewZones.querySelectorAll('span');
for (const span of spans) {
const right = span.getBoundingClientRect().right;
if (right > maxRight) maxRight = right;
}

return maxRight > 0 ? maxRight - editorLeft : 0;
};

/**
* Crops a PNG blob to the specified width.
* Returns the original blob if cropping would not reduce the width.
* @param {Blob} blob - source PNG blob
* @param {number} cropWidth - target width in image pixels
* @returns {Promise<Blob>} cropped (or original) PNG blob
*/
const cropToWidth = async function (blob, cropWidth) {
const bitmap = await createImageBitmap(blob);
const {width, height} = bitmap;

// No crop needed if target width >= image width
if (cropWidth >= width) {
bitmap.close();
return blob;
}

const canvas = document.createElement('canvas');
canvas.width = cropWidth;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0, cropWidth, height, 0, 0, cropWidth, height);
bitmap.close();

return new Promise(resolve => {
canvas.toBlob(croppedBlob => {
resolve(croppedBlob || blob);
}, 'image/png');
});
};

/**
* Captures the Monaco editor content as a PNG image and triggers a download.
* Temporarily expands the editor to its full content height so that all lines
* (including those scrolled out of view) are rendered in the DOM before capture.
* ViewZones (e.g. furigana annotations) are captured naturally as DOM elements.
* After capture, the image is cropped to the actual text content width to remove
* the large right-side whitespace caused by the editor being wider than the code.
* @param {object} editor - Monaco editor instance
* @param {string} projectTitle - Project name (used in filename)
* @param {string} spriteName - Sprite / stage name (used in filename)
Expand All @@ -39,6 +121,10 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) {
const originalHeight = containerEl.style.height;
const originalOverflow = containerEl.style.overflow;

/** Extra pixels (CSS) added to the right of the content boundary. */
const CROP_PADDING = 16;
const PIXEL_RATIO = 2;

try {
// Disable scrollBeyondLastLine to get tight content height
editor.updateOptions({scrollBeyondLastLine: false});
Expand All @@ -59,17 +145,29 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) {
});
});

// Measure content width from rendered DOM before capture
const textWidth = measureTextWidth(editorDomNode);
const furiganaWidth = measureFuriganaWidth(editorDomNode);
const contentWidth = Math.max(textWidth, furiganaWidth);

// Capture the editor DOM as a PNG blob.
// skipFonts avoids SecurityError when html-to-image tries to read
// cssRules from cross-origin stylesheets (Monaco's CDN CSS).
const blob = await toBlob(editorDomNode, {
backgroundColor: '#ffffff',
pixelRatio: 2,
pixelRatio: PIXEL_RATIO,
skipFonts: true
});

if (blob) {
downloadBlob(buildFilename(projectTitle, spriteName), blob);
let downloadableBlob = blob;
if (contentWidth > 0) {
const cropWidthPx = Math.ceil(
(contentWidth + CROP_PADDING) * PIXEL_RATIO
);
downloadableBlob = await cropToWidth(blob, cropWidthPx);
}
downloadBlob(buildFilename(projectTitle, spriteName), downloadableBlob);
}
} finally {
// Restore original state
Expand All @@ -84,5 +182,8 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) {

export {
buildFilename,
cropToWidth,
measureFuriganaWidth,
measureTextWidth,
downloadRubyAsImage
};
143 changes: 143 additions & 0 deletions packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {
buildFilename,
cropToWidth,
measureTextWidth,
measureFuriganaWidth,
downloadRubyAsImage
} from '../../../src/lib/ruby-screenshot';

Expand Down Expand Up @@ -52,11 +55,150 @@ describe('buildFilename', () => {
});
});

// ---- measureTextWidth ----

describe('measureTextWidth', () => {
test('returns 0 when .view-lines is not found', () => {
const div = document.createElement('div');
expect(measureTextWidth(div)).toBe(0);
});

test('measures max right edge of text spans', () => {
const editor = document.createElement('div');
const viewLines = document.createElement('div');
viewLines.classList.add('view-lines');

// Create two lines with spans at different widths
const line1 = document.createElement('div');
const span1 = document.createElement('span');
const innerSpan1 = document.createElement('span');
innerSpan1.textContent = 'short';
span1.appendChild(innerSpan1);
line1.appendChild(span1);

const line2 = document.createElement('div');
const span2 = document.createElement('span');
const innerSpan2 = document.createElement('span');
innerSpan2.textContent = 'this is a longer line of code';
span2.appendChild(innerSpan2);
line2.appendChild(span2);

viewLines.appendChild(line1);
viewLines.appendChild(line2);
editor.appendChild(viewLines);
document.body.appendChild(editor);

// jsdom returns 0 for getBoundingClientRect, so result is 0
// but the function should not throw
const width = measureTextWidth(editor);
expect(typeof width).toBe('number');

document.body.removeChild(editor);
});
});

// ---- measureFuriganaWidth ----

describe('measureFuriganaWidth', () => {
test('returns 0 when .view-zones is not found', () => {
const div = document.createElement('div');
expect(measureFuriganaWidth(div)).toBe(0);
});

test('returns 0 when view-zones has no spans', () => {
const editor = document.createElement('div');
const viewZones = document.createElement('div');
viewZones.classList.add('view-zones');
editor.appendChild(viewZones);
expect(measureFuriganaWidth(editor)).toBe(0);
});
});

// ---- cropToWidth ----

describe('cropToWidth', () => {
afterEach(() => {
document.createElement.mockRestore?.();
delete global.createImageBitmap;
});

test('crops blob to specified width', async () => {
const imgWidth = 1000;
const imgHeight = 500;
global.createImageBitmap = jest.fn(() =>
Promise.resolve({width: imgWidth, height: imgHeight, close: jest.fn()})
);

const canvases = [];
const mockCtx = {drawImage: jest.fn()};
const realCreateElement = document.createElement.bind(document);
jest.spyOn(document, 'createElement').mockImplementation(tag => {
const el = realCreateElement(tag);
if (tag === 'canvas') {
canvases.push(el);
el.getContext = jest.fn(() => mockCtx);
el.toBlob = jest.fn(cb => cb(new Blob(['cropped'], {type: 'image/png'})));
}
return el;
});

const inputBlob = new Blob(['original'], {type: 'image/png'});
const result = await cropToWidth(inputBlob, 400);

expect(result).not.toBe(inputBlob);
expect(canvases[0].width).toBe(400);
expect(canvases[0].height).toBe(imgHeight);
expect(mockCtx.drawImage).toHaveBeenCalledTimes(1);
});

test('returns original blob when cropWidth >= image width', async () => {
global.createImageBitmap = jest.fn(() =>
Promise.resolve({width: 200, height: 100, close: jest.fn()})
);

const inputBlob = new Blob(['original'], {type: 'image/png'});
const result = await cropToWidth(inputBlob, 300);

expect(result).toBe(inputBlob);
});

test('returns original blob when cropWidth equals image width', async () => {
global.createImageBitmap = jest.fn(() =>
Promise.resolve({width: 200, height: 100, close: jest.fn()})
);

const inputBlob = new Blob(['original'], {type: 'image/png'});
const result = await cropToWidth(inputBlob, 200);

expect(result).toBe(inputBlob);
});
});

// ---- downloadRubyAsImage ----

describe('downloadRubyAsImage', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock createImageBitmap for cropToWidth called inside downloadRubyAsImage.
// Return a small image so cropping can work when contentWidth > 0.
global.createImageBitmap = jest.fn(() =>
Promise.resolve({width: 2000, height: 400, close: jest.fn()})
);
const mockCtx = {drawImage: jest.fn()};
const realCreateElement = document.createElement.bind(document);
jest.spyOn(document, 'createElement').mockImplementation(tag => {
const el = realCreateElement(tag);
if (tag === 'canvas') {
el.getContext = jest.fn(() => mockCtx);
el.toBlob = jest.fn(cb => cb(new Blob(['cropped'], {type: 'image/png'})));
}
return el;
});
});

afterEach(() => {
document.createElement.mockRestore?.();
delete global.createImageBitmap;
});

test('does nothing when editor is null', async () => {
Expand Down Expand Up @@ -108,6 +250,7 @@ describe('downloadRubyAsImage', () => {

await downloadRubyAsImage(editor, 'MyProject', 'Cat');

// In jsdom, measureTextWidth returns 0, so no crop → original blob
expect(downloadBlob).toHaveBeenCalledWith('MyProject_Cat_ruby.png', mockBlob);
});

Expand Down
Loading