diff --git a/packages/scratch-gui/src/lib/ruby-screenshot.js b/packages/scratch-gui/src/lib/ruby-screenshot.js index 5834d7c089..9203608d4e 100644 --- a/packages/scratch-gui/src/lib/ruby-screenshot.js +++ b/packages/scratch-gui/src/lib/ruby-screenshot.js @@ -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} 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) @@ -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}); @@ -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 @@ -84,5 +182,8 @@ const downloadRubyAsImage = async function (editor, projectTitle, spriteName) { export { buildFilename, + cropToWidth, + measureFuriganaWidth, + measureTextWidth, downloadRubyAsImage }; diff --git a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js index 1afc7d7afa..551231de24 100644 --- a/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js +++ b/packages/scratch-gui/test/unit/lib/ruby-screenshot.test.js @@ -1,5 +1,8 @@ import { buildFilename, + cropToWidth, + measureTextWidth, + measureFuriganaWidth, downloadRubyAsImage } from '../../../src/lib/ruby-screenshot'; @@ -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 () => { @@ -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); });