diff --git a/dev.html b/dev.html index 19eeb4bd5..180fd708a 100644 --- a/dev.html +++ b/dev.html @@ -40,8 +40,8 @@ muc_show_logs_before_join: true, notify_all_room_messages: ['discuss@conference.conversejs.org'], view_mode: 'fullscreen', - websocket_url: 'wss://conversejs.org/xmpp-websocket', - // websocket_url: 'ws://chat.example.org:5380/xmpp-websocket', + // websocket_url: 'wss://conversejs.org/xmpp-websocket', + websocket_url: 'ws://chat.example.org:5380/xmpp-websocket', whitelisted_plugins: ['converse-debug'], // connection_options: { worker: '/dist/shared-connection-worker.js' } }); diff --git a/package-lock.json b/package-lock.json index e02a08a47..bcc78b0b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "dayjs": "^1.11.8", "dompurify": "^2.3.1", "favico.js-slevomat": "^0.3.11", + "gifuct-js": "^2.1.2", "jed": "1.1.1", "lit": "^2.4.0", "localforage-webextensionstorage-driver": "^3.0.0", @@ -5612,6 +5613,14 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6632,6 +6641,11 @@ "integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ==", "peer": true }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 58f4dfa80..f9948883b 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "dayjs": "^1.11.8", "dompurify": "^2.3.1", "favico.js-slevomat": "^0.3.11", + "gifuct-js": "^2.1.2", "jed": "1.1.1", "lit": "^2.4.0", "localforage-webextensionstorage-driver": "^3.0.0", diff --git a/src/shared/components/gif.js b/src/shared/components/gif.js index 4df4c9bd2..0b4437af8 100644 --- a/src/shared/components/gif.js +++ b/src/shared/components/gif.js @@ -28,6 +28,7 @@ export default class ConverseGIFElement extends CustomElement { constructor () { super(); + this.src = null; this.autoplay = false; this.noloop = false; this.fallback = 'url'; @@ -90,8 +91,6 @@ export default class ConverseGIFElement extends CustomElement { onControlsClicked (ev) { ev.preventDefault(); - - if (this.supergif.playing) { this.supergif.pause(); } else if (this.supergif.frames.length > 0) { diff --git a/src/shared/components/image.js b/src/shared/components/image.js index 29d6bdbc5..6d6cc9154 100644 --- a/src/shared/components/image.js +++ b/src/shared/components/image.js @@ -16,6 +16,14 @@ export default class Image extends CustomElement { } } + constructor () { + super(); + this.src = null; + this.href = null; + this.onImgClick = null; + this.onImgLoad = null; + } + render () { if (isGIFURL(this.src) && shouldRenderMediaFromURL(this.src, 'image')) { return tplGif(filterQueryParamsFromURL(this.src), true); diff --git a/src/shared/gif/index.js b/src/shared/gif/index.js index abfa05f74..55d8d32cf 100644 --- a/src/shared/gif/index.js +++ b/src/shared/gif/index.js @@ -1,22 +1,10 @@ -/** - * @copyright Shachaf Ben-Kiki, JC Brand - * @description - * Started as a fork of Shachaf Ben-Kiki's jsgif library - * https://github.com/shachaf/jsgif - * @license MIT License - */ -import Stream from './stream.js'; import { getOpenPromise } from '@converse/openpromise'; -import { parseGIF } from './utils.js'; - -const DELAY_FACTOR = 10; - +import { parseGIF, decompressFrames } from 'gifuct-js'; export default class ConverseGif { - /** * Creates a new ConverseGif instance - * @param { HTMLElement } el + * @param { import('lit').LitElement } el * @param { Object } [options] * @param { Number } [options.width] - The width, in pixels, of the canvas * @param { Number } [options.height] - The height, in pixels, of the canvas @@ -30,7 +18,8 @@ export default class ConverseGif { * @param { Number } [options.progress_bar_height=5] */ constructor (el, opts) { - this.options = Object.assign({ + this.options = Object.assign( + { width: null, height: null, autoplay: true, @@ -38,7 +27,7 @@ export default class ConverseGif { show_progress_bar: true, progress_bg_color: 'rgba(0,0,0,0.4)', progress_color: 'rgba(255,0,22,.8)', - progress_bar_height: 5 + progress_bar_height: 5, }, opts ); @@ -51,21 +40,15 @@ export default class ConverseGif { this.offscreenCanvas = document.createElement('canvas'); this.ctx_scaled = false; - this.disposal_method = null; - this.disposal_restore_from_idx = null; - this.frame = null; - this.frame_offsets = []; // elements have .x and .y properties this.frames = []; - this.last_disposal_method = null; - this.last_img = null; this.load_error = null; this.playing = this.options.autoplay; - this.transparency = null; - this.frame_delay = null; this.frame_idx = 0; this.iteration_count = 0; this.start = null; + this.hovering = null; + this.frameImageData = null; this.initialize(); } @@ -75,7 +58,7 @@ export default class ConverseGif { this.setSizes(this.options.width, this.options.height); } const data = await this.fetchGIF(this.gif_el.src); - requestAnimationFrame(() => this.startParsing(data)); + requestAnimationFrame(() => this.handleGIFResponse(data)); } initPlayer () { @@ -90,7 +73,7 @@ export default class ConverseGif { this.putFrame(this.frame_idx); if (this.options.autoplay) { - const delay = (this.frames[this.frame_idx]?.delay ?? 0) * DELAY_FACTOR; + const delay = (this.frames[this.frame_idx]?.delay ?? 0); setTimeout(() => this.play(), delay); } } @@ -137,8 +120,8 @@ export default class ConverseGif { * `putFrame(0)` needs to be called *before* this method, otherwise the * animation will incorrectly start from frame #1 (this is done in `initPlayer`). * - * @param { DOMHighRestTimestamp } timestamp - The timestamp as returned by `requestAnimationFrame` - * @param { DOMHighRestTimestamp } previous_timestamp - The timestamp from the previous iteration of this method. + * @param { DOMHighResTimeStamp } timestamp - The timestamp as returned by `requestAnimationFrame` + * @param { DOMHighResTimeStamp } previous_timestamp - The timestamp from the previous iteration of this method. * We need this in order to calculate whether we have waited long enough to * show the next frame. * @param { Number } frame_delay - The delay (in 1/100th of a second) @@ -148,10 +131,10 @@ export default class ConverseGif { if (!this.playing) { return; } - if ((timestamp - previous_timestamp) < frame_delay) { + if (timestamp - previous_timestamp < frame_delay) { this.hovering ? this.drawPauseIcon() : this.putFrame(this.frame_idx); // We need to wait longer - requestAnimationFrame(ts => this.onAnimationFrame(ts, previous_timestamp, frame_delay)); + requestAnimationFrame((ts) => this.onAnimationFrame(ts, previous_timestamp, frame_delay)); return; } const next_frame = this.getNextFrameNo(); @@ -160,8 +143,8 @@ export default class ConverseGif { } this.frame_idx = next_frame; this.putFrame(this.frame_idx); - const delay = (this.frames[this.frame_idx]?.delay || 8) * DELAY_FACTOR; - requestAnimationFrame(ts => this.onAnimationFrame(ts, timestamp, delay)); + const delay = (this.frames[this.frame_idx]?.delay || 8); + requestAnimationFrame((ts) => this.onAnimationFrame(ts, timestamp, delay)); } setSizes (w, h) { @@ -175,19 +158,6 @@ export default class ConverseGif { this.offscreenCanvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0); } - setFrameOffset (frame, offset) { - if (!this.frame_offsets[frame]) { - this.frame_offsets[frame] = offset; - return; - } - if (typeof offset.x !== 'undefined') { - this.frame_offsets[frame].x = offset.x; - } - if (typeof offset.y !== 'undefined') { - this.frame_offsets[frame].y = offset.y; - } - } - doShowProgress (pos, length, draw) { if (draw && this.options.show_progress_bar) { let height = this.options.progress_bar_height; @@ -207,32 +177,20 @@ export default class ConverseGif { /** * Starts parsing the GIF stream data by calling `parseGIF` and passing in * a map of handler functions. - * @param { String } data - The GIF file data, as returned by the server + * @param {ArrayBuffer} data - The GIF file data, as returned by the server */ - startParsing (data) { - const stream = new Stream(data); - /** - * @typedef { Object } GIFParserHandlers - * A map of callback functions passed `parseGIF`. These functions are - * called as various parts of the GIF file format are parsed. - * @property { Function } hdr - Callback to handle the GIF header data - * @property { Function } gce - Callback to handle the GIF Graphic Control Extension data - * @property { Function } com - Callback to handle the comment extension block - * @property { Function } img - Callback to handle image data - * @property { Function } eof - Callback once the end of file has been reached - */ - const handler = { - 'hdr': this.withProgress(stream, header => this.handleHeader(header)), - 'gce': this.withProgress(stream, gce => this.handleGCE(gce)), - 'com': this.withProgress(stream, ), - 'img': this.withProgress(stream, img => this.doImg(img), true), - 'eof': () => this.handleEOF(stream) - }; + handleGIFResponse (data) { try { - parseGIF(stream, handler); + const gif = parseGIF(data); + this.hdr = gif.header; + this.lsd = gif.lsd; + this.setSizes(this.options.width ?? this.lsd.width, this.options.height ?? this.lsd.height); + this.frames = decompressFrames(gif, true); } catch (err) { - this.showError('parse'); + this.showError(); } + this.initPlayer(); + !this.options.autoplay && this.drawPlayIcon(); } drawError () { @@ -240,23 +198,23 @@ export default class ConverseGif { this.ctx.fillRect( 0, 0, - this.options.width ? this.options.width : this.hdr.width, - this.options.height ? this.options.height : this.hdr.height + this.options.width ? this.options.width : this.lsd.width, + this.options.height ? this.options.height : this.lsd.height ); this.ctx.strokeStyle = 'red'; this.ctx.lineWidth = 3; this.ctx.moveTo(0, 0); this.ctx.lineTo( - this.options.width ? this.options.width : this.hdr.width, - this.options.height ? this.options.height : this.hdr.height + this.options.width ? this.options.width : this.lsd.width, + this.options.height ? this.options.height : this.lsd.height ); - this.ctx.moveTo(0, this.options.height ? this.options.height : this.hdr.height); - this.ctx.lineTo(this.options.width ? this.options.width : this.hdr.width, 0); + this.ctx.moveTo(0, this.options.height ? this.options.height : this.lsd.height); + this.ctx.lineTo(this.options.width ? this.options.width : this.lsd.width, 0); this.ctx.stroke(); } - showError (errtype) { - this.load_error = errtype; + showError () { + this.load_error = true; this.hdr = { width: this.gif_el.width, height: this.gif_el.height, @@ -266,164 +224,49 @@ export default class ConverseGif { this.el.requestUpdate(); } - handleHeader (header) { - this.hdr = header; - this.setSizes( - this.options.width ?? this.hdr.width, - this.options.height ?? this.hdr.height - ); - } - - /** - * Handler for GIF Graphic Control Extension (GCE) data - */ - handleGCE (gce) { - this.pushFrame(); - this.clear(); - this.frame_delay = gce.delayTime; - this.transparency = gce.transparencyGiven ? gce.transparencyIndex : null; - this.disposal_method = gce.disposalMethod; - } - - /** - * Handler for when the end of the GIF's file has been reached - */ - handleEOF (stream) { - this.pushFrame(); - this.doDecodeProgress(stream, false); - this.initPlayer(); - !this.options.autoplay && this.drawPlayIcon(); - } - - pushFrame () { - if (!this.frame) return; - this.frames.push({ - data: this.frame.getImageData(0, 0, this.hdr.width, this.hdr.height), - delay: this.frame_delay - }); - this.frame_offsets.push({ x: 0, y: 0 }); - } - - doImg (img) { - this.frame = this.frame || this.offscreenCanvas.getContext('2d'); - const currIdx = this.frames.length; - - //ct = color table, gct = global color table - const ct = img.lctFlag ? img.lct : this.hdr.gct; // TODO: What if neither exists? - - /* - * Disposal method indicates the way in which the graphic is to - * be treated after being displayed. - * - * Values : 0 - No disposal specified. The decoder is - * not required to take any action. - * 1 - Do not dispose. The graphic is to be left - * in place. - * 2 - Restore to background color. The area used by the - * graphic must be restored to the background color. - * 3 - Restore to previous. The decoder is required to - * restore the area overwritten by the graphic with - * what was there prior to rendering the graphic. - * - * Importantly, "previous" means the frame state - * after the last disposal of method 0, 1, or 2. - */ - if (currIdx > 0) { - if (this.last_disposal_method === 3) { - // Restore to previous - // If we disposed every frame including first frame up to this point, then we have - // no composited frame to restore to. In this case, restore to background instead. - if (this.disposal_restore_from_idx !== null) { - this.frame.putImageData(this.frames[this.disposal_restore_from_idx].data, 0, 0); - } else { - this.frame.clearRect( - this.last_img.leftPos, - this.last_img.topPos, - this.last_img.width, - this.last_img.height - ); - } - } else { - this.disposal_restore_from_idx = currIdx - 1; - } - - if (this.last_disposal_method === 2) { - // Restore to background color - // Browser implementations historically restore to transparent; we do the same. - // http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079 - this.frame.clearRect( - this.last_img.leftPos, - this.last_img.topPos, - this.last_img.width, - this.last_img.height - ); - } - } - // else, Undefined/Do not dispose. - // frame contains final pixel data from the last frame; do nothing - - //Get existing pixels for img region after applying disposal method - const imgData = this.frame.getImageData(img.leftPos, img.topPos, img.width, img.height); - - //apply color table colors - img.pixels.forEach((pixel, i) => { - // imgData.data === [R,G,B,A,R,G,B,A,...] - if (pixel !== this.transparency) { - imgData.data[i * 4 + 0] = ct[pixel][0]; - imgData.data[i * 4 + 1] = ct[pixel][1]; - imgData.data[i * 4 + 2] = ct[pixel][2]; - imgData.data[i * 4 + 3] = 255; // Opaque. - } - }); - - this.frame.putImageData(imgData, img.leftPos, img.topPos); - - if (!this.ctx_scaled) { - this.ctx.scale(this.getCanvasScale(), this.getCanvasScale()); - this.ctx_scaled = true; - } - - if (!this.last_img) { - // This is the first received image, so we draw it - this.ctx.drawImage(this.offscreenCanvas, 0, 0); - } - this.last_img = img; - } - /** * Draws a gif frame at a specific index inside the canvas. - * @param { Number } i - The frame index + * @param {number|string} i - The frame index */ - putFrame (i, show_pause_on_hover=true) { - if (!this.frames.length) return + putFrame (i, show_pause_on_hover = true) { + if (!this.frames.length) return; - i = parseInt(i, 10); + i = parseInt(i.toString(), 10); if (i > this.frames.length - 1 || i < 0) { i = 0; } - const offset = this.frame_offsets[i]; - this.offscreenCanvas.getContext('2d').putImageData(this.frames[i].data, offset.x, offset.y); + + const frame = this.frames[i]; + const dims = frame.dims; + + if ( + !this.frameImageData || + dims.width != this.frameImageData.width || + dims.height != this.frameImageData.height + ) { + this.offscreenCanvas.width = dims.width; + this.offscreenCanvas.height = dims.height; + this.frameImageData = this.offscreenCanvas.getContext('2d').createImageData(dims.width, dims.height); + } + + // set the patch data as an override + this.frameImageData.data.set(frame.patch); + + this.offscreenCanvas.getContext('2d').putImageData(this.frameImageData, 0, 0); this.ctx.globalCompositeOperation = 'copy'; - this.ctx.drawImage(this.offscreenCanvas, 0, 0); + this.ctx.drawImage(this.offscreenCanvas, dims.left, dims.top); if (show_pause_on_hover && this.hovering) { this.drawPauseIcon(); } } - clear () { - this.transparency = null; - this.last_disposal_method = this.disposal_method; - this.disposal_method = null; - this.frame = null; - } - /** * Start playing the gif */ play () { this.playing = true; - requestAnimationFrame(ts => this.onAnimationFrame(ts, 0, 0)); + requestAnimationFrame((ts) => this.onAnimationFrame(ts, 0, 0)); } /** @@ -431,7 +274,7 @@ export default class ConverseGif { */ pause () { this.playing = false; - requestAnimationFrame(() => this.drawPlayIcon()) + requestAnimationFrame(() => this.drawPlayIcon()); } drawPauseIcon () { @@ -447,40 +290,32 @@ export default class ConverseGif { this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - const icon_size = this.canvas.height*0.1; + const icon_size = this.canvas.height * 0.1; // Draw bars - this.ctx.lineWidth = this.canvas.height*0.04; + this.ctx.lineWidth = this.canvas.height * 0.04; this.ctx.beginPath(); - this.ctx.moveTo(this.canvas.width/2-icon_size/2, this.canvas.height/2-icon_size); - this.ctx.lineTo(this.canvas.width/2-icon_size/2, this.canvas.height/2+icon_size); + this.ctx.moveTo(this.canvas.width / 2 - icon_size / 2, this.canvas.height / 2 - icon_size); + this.ctx.lineTo(this.canvas.width / 2 - icon_size / 2, this.canvas.height / 2 + icon_size); this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.stroke(); this.ctx.beginPath(); - this.ctx.moveTo(this.canvas.width/2+icon_size/2, this.canvas.height/2-icon_size); - this.ctx.lineTo(this.canvas.width/2+icon_size/2, this.canvas.height/2+icon_size); + this.ctx.moveTo(this.canvas.width / 2 + icon_size / 2, this.canvas.height / 2 - icon_size); + this.ctx.lineTo(this.canvas.width / 2 + icon_size / 2, this.canvas.height / 2 + icon_size); this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.stroke(); // Draw circle - this.ctx.lineWidth = this.canvas.height*0.02; + this.ctx.lineWidth = this.canvas.height * 0.02; this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.beginPath(); - this.ctx.arc( - this.canvas.width/2, - this.canvas.height/2, - icon_size*1.5, - 0, - 2*Math.PI - ); + this.ctx.arc(this.canvas.width / 2, this.canvas.height / 2, icon_size * 1.5, 0, 2 * Math.PI); this.ctx.stroke(); } drawPlayIcon () { - if (this.playing) { - return; - } + if (this.playing) return; // Clear the potential pause button by re-rendering the current frame this.putFrame(this.frame_idx, false); @@ -492,52 +327,28 @@ export default class ConverseGif { this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); // Draw triangle - const triangle_size = this.canvas.height*0.1; + const triangle_size = this.canvas.height * 0.1; const region = new Path2D(); - region.moveTo(this.canvas.width/2+triangle_size, this.canvas.height/2); // start at the pointy end - region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2+triangle_size); - region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2-triangle_size); + region.moveTo(this.canvas.width / 2 + triangle_size, this.canvas.height / 2); // start at the pointy end + region.lineTo(this.canvas.width / 2 - triangle_size / 2, this.canvas.height / 2 + triangle_size); + region.lineTo(this.canvas.width / 2 - triangle_size / 2, this.canvas.height / 2 - triangle_size); region.closePath(); this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.fill(region); // Draw circle - const circle_size = triangle_size*1.5; - this.ctx.lineWidth = this.canvas.height*0.02; + const circle_size = triangle_size * 1.5; + this.ctx.lineWidth = this.canvas.height * 0.02; this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.beginPath(); - this.ctx.arc( - this.canvas.width/2, - this.canvas.height/2, - circle_size, - 0, - 2*Math.PI - ); + this.ctx.arc(this.canvas.width / 2, this.canvas.height / 2, circle_size, 0, 2 * Math.PI); this.ctx.stroke(); } - doDecodeProgress (stream, draw) { - this.doShowProgress(stream.pos, stream.data.length, draw); - } - - /** - * @param{boolean=} draw Whether to draw progress bar or not; - * this is not idempotent because of translucency. - * Note that this means that the text will be unsynchronized - * with the progress bar on non-frames; - * but those are typically so small (GCE etc.) that it doesn't really matter - */ - withProgress (stream, fn, draw) { - return block => { - fn?.(block); - this.doDecodeProgress(stream, draw); - }; - } - getCanvasScale () { let scale; - if (this.options.max_width && this.hdr && this.hdr.width > this.options.max_width) { - scale = this.options.max_width / this.hdr.width; + if (this.options.max_width && this.hdr && this.lsd.width > this.options.max_width) { + scale = this.options.max_width / this.lsd.width; } else { scale = 1; } @@ -547,22 +358,24 @@ export default class ConverseGif { /** * Makes an HTTP request to fetch a GIF * @param { String } url - * @returns { Promise } Returns a promise which resolves with the response data. + * @returns { Promise } Returns a promise which resolves with the response data. */ fetchGIF (url) { const promise = getOpenPromise(); const h = new XMLHttpRequest(); h.open('GET', url, true); + h.responseType = 'arraybuffer'; + h?.overrideMimeType('text/plain; charset=x-user-defined'); h.onload = () => { if (h.status != 200) { - this.showError('xhr - response'); + this.showError(); return promise.reject(); } promise.resolve(h.response); }; - h.onprogress = (e) => (e.lengthComputable && this.doShowProgress(e.loaded, e.total, true)); - h.onerror = () => this.showError('xhr'); + h.onprogress = (e) => e.lengthComputable && this.doShowProgress(e.loaded, e.total, true); + h.onerror = () => this.showError(); h.send(); return promise; } diff --git a/src/shared/gif/utils.js b/src/shared/gif/utils.js deleted file mode 100644 index 65f076155..000000000 --- a/src/shared/gif/utils.js +++ /dev/null @@ -1,319 +0,0 @@ -/** - * @copyright Shachaf Ben-Kiki and the Converse.js contributors - * @description - * Started as a fork of Shachaf Ben-Kiki's jsgif library - * https://github.com/shachaf/jsgif - * @license MIT License - */ - -function bitsToNum (ba) { - return ba.reduce(function (s, n) { - return s * 2 + n; - }, 0); -} - -function byteToBitArr (bite) { - const a = []; - for (let i = 7; i >= 0; i--) { - a.push( !! (bite & (1 << i))); - } - return a; -} - -function lzwDecode (minCodeSize, data) { - // TODO: Now that the GIF parser is a bit different, maybe this should get an array of bytes instead of a String? - let pos = 0; // Maybe this streaming thing should be merged with the Stream? - function readCode (size) { - let code = 0; - for (let i = 0; i < size; i++) { - if (data.charCodeAt(pos >> 3) & (1 << (pos & 7))) { - code |= 1 << i; - } - pos++; - } - return code; - } - - const output = []; - const clearCode = 1 << minCodeSize; - const eoiCode = clearCode + 1; - - let codeSize = minCodeSize + 1; - let dict = []; - - const clear = function () { - dict = []; - codeSize = minCodeSize + 1; - for (let i = 0; i < clearCode; i++) { - dict[i] = [i]; - } - dict[clearCode] = []; - dict[eoiCode] = null; - }; - - let code = clearCode; - let last; - clear(); - - while (true) { // eslint-disable-line no-constant-condition - last = code; - code = readCode(codeSize); - - if (code === clearCode) { - clear(); - continue; - } - if (code === eoiCode) break; - - if (code < dict.length) { - if (last !== clearCode) { - dict.push(dict[last].concat(dict[code][0])); - } - } - else { - if (code !== dict.length) throw new Error('Invalid LZW code.'); - dict.push(dict[last].concat(dict[last][0])); - } - output.push.apply(output, dict[code]); - - if (dict.length === (1 << codeSize) && codeSize < 12) { - // If we're at the last code and codeSize is 12, the next code will be a clearCode, and it'll be 12 bits long. - codeSize++; - } - } - // I don't know if this is technically an error, but some GIFs do it. - //if (Math.ceil(pos / 8) !== data.length) throw new Error('Extraneous LZW bytes.'); - return output; -} - - -function readSubBlocks (st) { - let size, data; - data = ''; - do { - size = st.readByte(); - data += st.read(size); - } while (size !== 0); - return data; -} - -/** - * Parses GIF image color table information - * @param { Stream } st - * @param { Number } entries - */ -function parseCT (st, entries) { // Each entry is 3 bytes, for RGB. - const ct = []; - for (let i = 0; i < entries; i++) { - ct.push(st.readBytes(3)); - } - return ct; -} - -/** - * Parses GIF image information - * @param { Stream } st - * @param { ByteStream } img - * @param { Function } [callback] - */ -function parseImg (st, img, callback) { - function deinterlace (pixels, width) { - // Of course this defeats the purpose of interlacing. And it's *probably* - // the least efficient way it's ever been implemented. But nevertheless... - const newPixels = new Array(pixels.length); - const rows = pixels.length / width; - function cpRow (toRow, fromRow) { - const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width); - newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels)); - } - - // See appendix E. - const offsets = [0, 4, 2, 1]; - const steps = [8, 8, 4, 2]; - let fromRow = 0; - for (let pass = 0; pass < 4; pass++) { - for (let toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) { - cpRow(toRow, fromRow) - fromRow++; - } - } - return newPixels; - } - - img.leftPos = st.readUnsigned(); - img.topPos = st.readUnsigned(); - img.width = st.readUnsigned(); - img.height = st.readUnsigned(); - - const bits = byteToBitArr(st.readByte()); - img.lctFlag = bits.shift(); - img.interlaced = bits.shift(); - img.sorted = bits.shift(); - img.reserved = bits.splice(0, 2); - img.lctSize = bitsToNum(bits.splice(0, 3)); - - if (img.lctFlag) { - img.lct = parseCT(st, 1 << (img.lctSize + 1)); - } - img.lzwMinCodeSize = st.readByte(); - - const lzwData = readSubBlocks(st); - img.pixels = lzwDecode(img.lzwMinCodeSize, lzwData); - - if (img.interlaced) { // Move - img.pixels = deinterlace(img.pixels, img.width); - } - callback?.(img); -} - -/** - * Parses GIF header information - * @param { Stream } st - * @param { Function } [callback] - */ -function parseHeader (st, callback) { - const hdr = {}; - hdr.sig = st.read(3); - hdr.ver = st.read(3); - if (hdr.sig !== 'GIF') { - throw new Error('Not a GIF file.'); - } - hdr.width = st.readUnsigned(); - hdr.height = st.readUnsigned(); - - const bits = byteToBitArr(st.readByte()); - hdr.gctFlag = bits.shift(); - hdr.colorRes = bitsToNum(bits.splice(0, 3)); - hdr.sorted = bits.shift(); - hdr.gctSize = bitsToNum(bits.splice(0, 3)); - - hdr.bgColor = st.readByte(); - hdr.pixelAspectRatio = st.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 - if (hdr.gctFlag) { - hdr.gct = parseCT(st, 1 << (hdr.gctSize + 1)); - } - callback?.(hdr); -} - -function parseExt (st, block, handler) { - - function parseGCExt (block) { - st.readByte(); // blocksize, always 4 - const bits = byteToBitArr(st.readByte()); - block.reserved = bits.splice(0, 3); // Reserved; should be 000. - block.disposalMethod = bitsToNum(bits.splice(0, 3)); - block.userInput = bits.shift(); - block.transparencyGiven = bits.shift(); - block.delayTime = st.readUnsigned(); - block.transparencyIndex = st.readByte(); - block.terminator = st.readByte(); - handler?.gce(block); - } - - function parseComExt (block) { - block.comment = readSubBlocks(st); - handler.com && handler.com(block); - } - - function parsePTExt (block) { - // No one *ever* uses this. If you use it, deal with parsing it yourself. - st.readByte(); // blocksize, always 12 - block.ptHeader = st.readBytes(12); - block.ptData = readSubBlocks(st); - handler.pte && handler.pte(block); - } - - function parseAppExt (block) { - function parseNetscapeExt (block) { - st.readByte(); // blocksize, always 3 - block.unknown = st.readByte(); // ??? Always 1? What is this? - block.iterations = st.readUnsigned(); - block.terminator = st.readByte(); - handler.app && handler.app.NETSCAPE && handler.app.NETSCAPE(block); - } - - function parseUnknownAppExt (block) { - block.appData = readSubBlocks(st); - // FIXME: This won't work if a handler wants to match on any identifier. - handler.app && handler.app[block.identifier] && handler.app[block.identifier](block); - } - - st.readByte(); // blocksize, always 11 - block.identifier = st.read(8); - block.authCode = st.read(3); - switch (block.identifier) { - case 'NETSCAPE': - parseNetscapeExt(block); - break; - default: - parseUnknownAppExt(block); - break; - } - } - - function parseUnknownExt (block) { - block.data = readSubBlocks(st); - handler.unknown && handler.unknown(block); - } - - block.label = st.readByte(); - switch (block.label) { - case 0xF9: - block.extType = 'gce'; - parseGCExt(block); - break; - case 0xFE: - block.extType = 'com'; - parseComExt(block); - break; - case 0x01: - block.extType = 'pte'; - parsePTExt(block); - break; - case 0xFF: - block.extType = 'app'; - parseAppExt(block); - break; - default: - block.extType = 'unknown'; - parseUnknownExt(block); - break; - } -} - -/** - * @param { Stream } st - * @param { GIFParserHandlers } handler - */ -function parseBlock (st, handler) { - const block = {} - block.sentinel = st.readByte(); - switch (String.fromCharCode(block.sentinel)) { // For ease of matching - case '!': - block.type = 'ext'; - parseExt(st, block, handler); - break; - case ',': - block.type = 'img'; - parseImg(st, block, handler?.img); - break; - case ';': - block.type = 'eof'; - handler?.eof(block); - break; - default: - throw new Error('Unknown block: 0x' + block.sentinel.toString(16)); // TODO: Pad this with a 0. - } - if (block.type !== 'eof') setTimeout(() => parseBlock(st, handler), 0); -} - -/** - * Takes a Stream and parses it for GIF data, calling the relevant handler - * methods on the passed in `handler` object. - * @param { Stream } st - * @param { GIFParserHandlers } handler - */ -export function parseGIF (st, handler={}) { - parseHeader(st, handler?.hdr); - setTimeout(() => parseBlock(st, handler), 0); -} diff --git a/src/shared/rich-text.js b/src/shared/rich-text.js index 019aa9bec..f52e094af 100644 --- a/src/shared/rich-text.js +++ b/src/shared/rich-text.js @@ -283,11 +283,6 @@ export class RichText extends String { /** * Parse the text and add template references for rendering the "rich" parts. - * - * @param { RichText } text - * @param { Boolean } show_images - Should URLs of images be rendered as `` tags? - * @param { Function } onImgLoad - * @param { Function } onImgClick **/ async addTemplates () { /** diff --git a/tsconfig.json b/tsconfig.json index f89f3d91f..5404c2e3a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,11 @@ "target": "es2016", "module": "esnext", + "lib": [ + "ES2020", + "dom" + ], + "allowJs": true, "checkJs": true,