diff --git a/HISTORY.md b/HISTORY.md index e6b33afa..76acd8b0 100755 --- a/HISTORY.md +++ b/HISTORY.md @@ -41,6 +41,9 @@ * **Translation Updates:** * German +### 2022-04-22 +* morphic, objects, gui: support embedding blocks into PNG metadata + ### 2022-04-20 * threads: terminate all threads waiting to display a question on ASKing a falsy value * threads: clear "answer" on ASK nothing/falsy diff --git a/snap.html b/snap.html index 9f56ab0d..a9db9fdb 100755 --- a/snap.html +++ b/snap.html @@ -13,14 +13,14 @@ - + - + - + diff --git a/src/gui.js b/src/gui.js index 531d788a..53edb72a 100644 --- a/src/gui.js +++ b/src/gui.js @@ -86,7 +86,7 @@ BlockVisibilityDialogMorph, ThreadManager*/ // Global stuff //////////////////////////////////////////////////////// -modules.gui = '2022-April-20'; +modules.gui = '2022-April-22'; // Declarations @@ -2471,7 +2471,7 @@ IDE_Morph.prototype.endBulkDrop = function () { this.bulkDropInProgress = false; }; -IDE_Morph.prototype.droppedImage = function (aCanvas, name) { +IDE_Morph.prototype.droppedImage = function (aCanvas, name, embeddedCode) { var costume = new Costume( aCanvas, this.currentSprite.newCostumeName( @@ -2491,6 +2491,7 @@ IDE_Morph.prototype.droppedImage = function (aCanvas, name) { return; } + costume.code = embeddedCode || null; this.currentSprite.addCostume(costume); this.currentSprite.wearCostume(costume); this.spriteBar.tabBar.tabTo('costumes'); @@ -10063,6 +10064,9 @@ CostumeIconMorph.prototype.exportCostume = function () { if (this.object instanceof SVG_Costume) { // don't show SVG costumes in a new tab (shows text) ide.saveFileAs(this.object.contents.src, 'text/svg', this.object.name); + } else if (this.object.code) { + // embed blocks code inside the PNG image data + ide.saveFileAs(this.object.pngData(), 'image/png', this.object.name); } else { // rasterized Costume ide.saveCanvasAs(this.object.contents, this.object.name); } diff --git a/src/morphic.js b/src/morphic.js index 83fc785a..06a83e07 100644 --- a/src/morphic.js +++ b/src/morphic.js @@ -642,7 +642,7 @@ Drops of image elements from outside the world canvas are dispatched as - droppedImage(aCanvas, name) + droppedImage(aCanvas, name, embeddedCode) droppedSVG(anImage, name) events to interested Morphs at the mouse pointer. If you want your Morph @@ -663,8 +663,22 @@ droppedImage() event with a canvas containing a rasterized version of the SVG. - The same applies to drops of audio or text files from outside the world - canvas. + Note that PNG images provide for embedded text comments, which can be used + to include code inside the image. Such a payload has to be identified by + an agreed-upon marker. The default tag is stored in MorphicPreferences and + can be overriden by apps wishing to make use of this feature. If such an + embedded text-payload is found inside a PNG it is passed as the optional + third "embeddedCode" parameter to the "droppedImage()" event. embedded text + only applies to PNGs. You can embed a string into the PNG metadata of a PNG + by calling + + embedMetadataPNG(aCanvas, aString) + + with a raster image represented by a canvas and a string that is to be + embedded into the PNG's metadata. + + The same event mechanism applies to drops of audio or text files from + outside the world canvas. Those are dispatched as @@ -1276,6 +1290,8 @@ Jason N (@cyderize) contributed native copy & paste for text editing. Bartosz Leper contributed retina display support. Zhenlei Jia and Dariusz Dorożalski pioneered IME text editing. + Dariusz Dorożalski and Jesus Villalobos contributed embedding blocks + into image metadata. Bernat Romagosa contributed to text editing and to the core design. Michael Ball found and fixed a longstanding scrolling bug. Brian Harvey contributed to the design and implementation of submenus. @@ -1289,9 +1305,9 @@ /*global window, HTMLCanvasElement, FileReader, Audio, FileList, Map*/ -/*jshint esversion: 6*/ +/*jshint esversion: 11, bitwise: false*/ -var morphicVersion = '2022-January-28'; +var morphicVersion = '2022-April-22'; var modules = {}; // keep track of additional loaded modules var useBlurredShadows = true; @@ -1303,6 +1319,7 @@ const CLEAR = new Color(0, 0, 0, 0); Object.freeze(ZERO); Object.freeze(BLACK); Object.freeze(WHITE); +Object.freeze(CLEAR); var standardSettings = { minimumFontHeight: getMinimumFontHeight(), // browser settings @@ -1318,6 +1335,7 @@ var standardSettings = { mouseScrollAmount: 40, useSliderForInput: false, isTouchDevice: false, // turned on by touch events, don't set + pngPayloadMarker: 'Data\tPayload\tEmbedded', rasterizeSVGs: false, isFlat: false, grabThreshold: 5, @@ -1338,6 +1356,7 @@ var touchScreenSettings = { mouseScrollAmount: 40, useSliderForInput: false, isTouchDevice: true, + pngPayloadMarker: 'Data\tPayload\tEmbedded', rasterizeSVGs: false, isFlat: false, grabThreshold: 5, @@ -1569,6 +1588,65 @@ function copy(target) { return c; } +function escapeString(str) { + var len, R = '', k = 0, S, chr, ord, + ascii = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + + '0123456789@*_+-./,'; + str = str.toString(); + len = str.length; + while(k < len) { + chr = str[k]; + if (ascii.indexOf(chr) != -1) { + S = chr; + } else { + ord = str.charCodeAt(k); + if (ord < 256) { + S = '%' + ("00" + ord.toString(16)).toUpperCase().slice(-2); + } else { + S = '%u' + ("0000" + ord.toString(16)).toUpperCase().slice(-4); + } + } + R += S; + k++; + } + return R; +} + +function embedMetadataPNG(aCanvas, aString) { + var embedTag = MorphicPreferences.pngPayloadMarker, + crc32 = (str, crc) => { + let table = [...Array(256).keys()].map(it => + [...Array(8)].reduce((cc) => + (cc & 1) ? (0xedb88320 ^ (cc >>> 1)) : (cc >>> 1), it) + ); + crc = [...str].reduce( + (crc, ch) => (crc >>> 8) ^ table[(crc ^ ch.charCodeAt(0)) & 0xff], + (crc ? crc = 0 : crc) ^ (-1) // (crc ||= 0) ^ (-1) + ); + return ( crc ^ (-1) ) >>> 0; + }, + arr2Str = (arr) => + arr.reduce((res, byte) => res + String.fromCharCode(byte), ''), + int2BStr = (val) => + arr2Str(Array.from(new Uint8Array(new Uint32Array( [val] ).buffer)).reverse()), + buildChunk = (data) => { + let res = "iTXt" + data; + return int2BStr(data.length) + res + int2BStr(crc32(res)); + }, + parts = aCanvas.toDataURL("image/png").split(","), + bPart = atob(parts[1]).split(""), + newChunk = buildChunk( + "Snap!_SRC\0\0\0\0\0" + + embedTag + + aString + + embedTag + ); + bPart.splice(-12, 0, ...newChunk); + parts[1] = btoa(bPart.join("")); + return parts.join(','); +} + + // Retina Display Support ////////////////////////////////////////////// /* @@ -11643,7 +11721,7 @@ HandMorph.prototype.processDrop = function (event) { onto the world canvas, turn it into an offscreen canvas or audio element and dispatch the - droppedImage(canvas, name) + droppedImage(canvas, name, embeddedCode) droppedSVG(image, name) droppedAudio(audio, name) droppedText(text, name, type) @@ -11692,16 +11770,41 @@ HandMorph.prototype.processDrop = function (event) { function readImage(aFile) { var pic = new Image(), frd = new FileReader(), - trg = target; + url = event.dataTransfer?.getData( "text/uri-list"), + file = event.dataTransfer?.files?.[0], + trg = target, + embedTag = MorphicPreferences.pngPayloadMarker; + while (!trg.droppedImage) { trg = trg.parent; } + pic.onload = () => { canvas = newCanvas(new Point(pic.width, pic.height), true); canvas.getContext('2d').drawImage(pic, 0, 0); - trg.droppedImage(canvas, aFile.name); - bulkDrop(); + + (async () => { + if (!file && url) { + // alternative: "https://api.allorigins.win/raw?url=" + url + file = await fetch(url); + } + + // extract embedded data (e.g. blocks) + // from the image's meta data if present. + let buff = new Uint8Array(await file?.arrayBuffer()), + strBuff = buff.reduce((acc, b) => + acc + String.fromCharCode(b), ""), + embedded = strBuff.includes(embedTag) ? + decodeURIComponent( + escapeString((strBuff)?.split(embedTag)[1]) + ) + : null; + + trg.droppedImage(canvas, aFile.name, embedded); + bulkDrop(); + })(); }; + frd = new FileReader(); frd.onloadend = (e) => pic.src = e.target.result; frd.readAsDataURL(aFile); diff --git a/src/objects.js b/src/objects.js index c5b03eb7..6a30619c 100644 --- a/src/objects.js +++ b/src/objects.js @@ -89,11 +89,12 @@ SpeechBubbleMorph, InputSlotMorph, isNil, FileReader, TableDialogMorph, String, BlockEditorMorph, BlockDialogMorph, PrototypeHatBlockMorph, BooleanSlotMorph, localize, TableMorph, TableFrameMorph, normalizeCanvas, VectorPaintEditorMorph, AlignmentMorph, Process, WorldMap, copyCanvas, useBlurredShadows, BLACK, -BlockVisibilityDialogMorph, CostumeIconMorph, SoundIconMorph, MenuItemMorph*/ +BlockVisibilityDialogMorph, CostumeIconMorph, SoundIconMorph, MenuItemMorph, +embedMetadataPNG*/ /*jshint esversion: 6*/ -modules.objects = '2022-April-20'; +modules.objects = '2022-April-22'; var SpriteMorph; var StageMorph; @@ -10776,6 +10777,12 @@ Costume.prototype.isTainted = function () { return false; }; +// Costume storing blocks code in PNG exports + +Costume.prototype.pngData = function () { + return embedMetadataPNG(this.contents, this.code); +}; + // SVG_Costume ///////////////////////////////////////////////////////////// /*