diff --git a/packages/scratch-gui/src/lib/blocks-screenshot.js b/packages/scratch-gui/src/lib/blocks-screenshot.js index ae7bd1a44e..3779d2ecd7 100644 --- a/packages/scratch-gui/src/lib/blocks-screenshot.js +++ b/packages/scratch-gui/src/lib/blocks-screenshot.js @@ -19,6 +19,29 @@ const getBlocksBoundingBox = function (workspace) { return bbox; }; +/** + * Merges the blocks bounding box with the bubble canvas bounding box so that + * comment bubbles are included in the exported area. + * @param {object} workspace - Scratch Blocks / Blockly workspace instance + * @param {{x: number, y: number, width: number, height: number}} blockBbox - Blocks-only bounding box + * @returns {{x: number, y: number, width: number, height: number}} Merged bounding box + */ +const mergeWithBubbleBBox = function (workspace, blockBbox) { + const bubbleCanvas = workspace.svgBubbleCanvas_; + if (!bubbleCanvas || bubbleCanvas.children.length === 0) { + return blockBbox; + } + const bubbleBbox = bubbleCanvas.getBBox(); + if (!bubbleBbox || (bubbleBbox.width === 0 && bubbleBbox.height === 0)) { + return blockBbox; + } + const minX = Math.min(blockBbox.x, bubbleBbox.x); + const minY = Math.min(blockBbox.y, bubbleBbox.y); + const maxX = Math.max(blockBbox.x + blockBbox.width, bubbleBbox.x + bubbleBbox.width); + const maxY = Math.max(blockBbox.y + blockBbox.height, bubbleBbox.y + bubbleBbox.height); + return {x: minX, y: minY, width: maxX - minX, height: maxY - minY}; +}; + /** * Calculates the canvas pixel dimensions needed to contain all blocks with padding. * @param {{x: number, y: number, width: number, height: number}} bbox @@ -140,12 +163,22 @@ const buildExportSVG = async function (workspace, bbox, scale, width, height, pa // Clone block canvas and re-position so bbox top-left -> (padding, padding). // bbox.x and bbox.y are workspace coordinates of the top-left of all blocks. - const canvasClone = blockCanvas.cloneNode(true); const tx = ((-bbox.x) * scale) + padding; const ty = ((-bbox.y) * scale) + padding; - canvasClone.setAttribute('transform', `translate(${tx}, ${ty}) scale(${scale})`); + const canvasTransform = `translate(${tx}, ${ty}) scale(${scale})`; + + const canvasClone = blockCanvas.cloneNode(true); + canvasClone.setAttribute('transform', canvasTransform); svg.appendChild(canvasClone); + // Clone bubble canvas (comment bubbles) with the same transform + const bubbleCanvas = workspace.svgBubbleCanvas_; + if (bubbleCanvas && bubbleCanvas.children.length > 0) { + const bubbleClone = bubbleCanvas.cloneNode(true); + bubbleClone.setAttribute('transform', canvasTransform); + svg.appendChild(bubbleClone); + } + // Inline relative image hrefs as data URIs so they survive blob serialization await inlineImageHrefs(svg); @@ -194,9 +227,10 @@ const renderSVGToCanvas = function (svgStr, width, height) { * @returns {Promise} */ const downloadBlocksAsImage = async function (workspace, projectTitle, spriteName) { - const bbox = getBlocksBoundingBox(workspace); - if (!bbox) return; + const blockBbox = getBlocksBoundingBox(workspace); + if (!blockBbox) return; + const bbox = mergeWithBubbleBBox(workspace, blockBbox); const scale = workspace.scale; const {width, height} = calculateCanvasDimensions(bbox, scale); const svgStr = await buildExportSVG(workspace, bbox, scale, width, height); @@ -212,6 +246,7 @@ const downloadBlocksAsImage = async function (workspace, projectTitle, spriteNam export { getBlocksBoundingBox, + mergeWithBubbleBBox, calculateCanvasDimensions, buildFilename, buildExportSVG, diff --git a/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js b/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js index 7fb88719aa..3cac0a1116 100644 --- a/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js +++ b/packages/scratch-gui/test/unit/lib/blocks-screenshot.test.js @@ -1,7 +1,9 @@ import { getBlocksBoundingBox, + mergeWithBubbleBBox, calculateCanvasDimensions, buildFilename, + buildExportSVG, downloadBlocksAsImage, EXPORT_PADDING } from '../../../src/lib/blocks-screenshot'; @@ -10,17 +12,26 @@ jest.mock('../../../src/lib/download-blob', () => jest.fn()); import downloadBlob from '../../../src/lib/download-blob'; // Helper: create a mock Blockly workspace -const makeMockWorkspace = ({boundingBox = null, scale = 1} = {}) => { +const makeMockWorkspace = ({boundingBox = null, scale = 1, bubbleChildren = 0} = {}) => { const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); const group = document.createElementNS(svgNS, 'g'); svg.appendChild(group); - // ownerSVGElement is read-only on real SVG elements, but we can assign it on a plain obj - const blockCanvas = group; + const bubbleGroup = document.createElementNS(svgNS, 'g'); + svg.appendChild(bubbleGroup); + for (let i = 0; i < bubbleChildren; i++) { + const rect = document.createElementNS(svgNS, 'rect'); + rect.setAttribute('x', '100'); + rect.setAttribute('y', '50'); + rect.setAttribute('width', '30'); + rect.setAttribute('height', '20'); + bubbleGroup.appendChild(rect); + } return { getBlocksBoundingBox: jest.fn(() => boundingBox), scale, - svgBlockCanvas_: blockCanvas + svgBlockCanvas_: group, + svgBubbleCanvas_: bubbleGroup }; }; @@ -50,6 +61,38 @@ describe('getBlocksBoundingBox', () => { }); }); +// ---- mergeWithBubbleBBox ---- + +describe('mergeWithBubbleBBox', () => { + test('returns original bbox when bubble canvas has no children', () => { + const workspace = makeMockWorkspace({boundingBox: {x: 10, y: 20, width: 200, height: 100}}); + const bbox = {x: 10, y: 20, width: 200, height: 100}; + expect(mergeWithBubbleBBox(workspace, bbox)).toEqual(bbox); + }); + + test('returns original bbox when workspace has no bubble canvas', () => { + const workspace = makeMockWorkspace({boundingBox: {x: 10, y: 20, width: 200, height: 100}}); + delete workspace.svgBubbleCanvas_; + const bbox = {x: 10, y: 20, width: 200, height: 100}; + expect(mergeWithBubbleBBox(workspace, bbox)).toEqual(bbox); + }); + + test('expands bbox to include bubble canvas bounds', () => { + const workspace = makeMockWorkspace({ + boundingBox: {x: 10, y: 20, width: 200, height: 100}, + bubbleChildren: 2 + }); + // Mock getBBox to return a region outside the block bbox + workspace.svgBubbleCanvas_.getBBox = jest.fn(() => ({ + x: 250, y: 10, width: 80, height: 40 + })); + const bbox = {x: 10, y: 20, width: 200, height: 100}; + const merged = mergeWithBubbleBBox(workspace, bbox); + // minX=10, minY=10, maxX=330, maxY=120 + expect(merged).toEqual({x: 10, y: 10, width: 320, height: 110}); + }); +}); + // ---- calculateCanvasDimensions ---- describe('calculateCanvasDimensions', () => { @@ -104,6 +147,41 @@ describe('buildFilename', () => { }); }); +// ---- buildExportSVG ---- + +describe('buildExportSVG', () => { + test('includes bubble canvas clone in exported SVG', async () => { + const workspace = makeMockWorkspace({ + boundingBox: {x: 0, y: 0, width: 200, height: 100}, + bubbleChildren: 3 + }); + const bbox = {x: 0, y: 0, width: 200, height: 100}; + const svgStr = await buildExportSVG(workspace, bbox, 1, 232, 132); + // Count ) + const gMatches = svgStr.match(//]/g) || []; + expect(gMatches.length).toBeGreaterThanOrEqual(2); + // Bubble canvas children (rect elements) should be present + const rectMatches = svgStr.match(//]/g) || []; + // 1 background rect + 3 bubble rects = at least 4 + expect(rectMatches.length).toBeGreaterThanOrEqual(4); + }); + + test('does not include bubble canvas when it has no children', async () => { + const workspace = makeMockWorkspace({ + boundingBox: {x: 0, y: 0, width: 200, height: 100}, + bubbleChildren: 0 + }); + const bbox = {x: 0, y: 0, width: 200, height: 100}; + const svgStr = await buildExportSVG(workspace, bbox, 1, 232, 132); + // Count /]/g) || []; + expect(gMatches.length).toBe(1); + // Only background rect, no bubble rects + const rectMatches = svgStr.match(//]/g) || []; + expect(rectMatches.length).toBe(1); + }); +}); + // ---- downloadBlocksAsImage ---- describe('downloadBlocksAsImage', () => {