From d062166290cb62610c95301b33b52c0454d0b583 Mon Sep 17 00:00:00 2001 From: Krikor Krumlian Date: Wed, 10 Jan 2024 14:26:52 -0500 Subject: [PATCH 1/3] Reverting Draw Widget --- config/default-config.json | 1 + widget/draw/draw-widget.js | 706 +++++++++++++++++++++++++++++++++++ widget/draw/draw-widget.scss | 233 ++++++++++++ 3 files changed, 940 insertions(+) create mode 100644 widget/draw/draw-widget.js create mode 100644 widget/draw/draw-widget.scss diff --git a/config/default-config.json b/config/default-config.json index 5109dccf..c59e943c 100644 --- a/config/default-config.json +++ b/config/default-config.json @@ -53,6 +53,7 @@ "../../../widget/discrepancy-note/dn-widget", "../../../widget/strict-class/strict-class", "../../../widget/signature-external/signature-external", + "../../../widget/draw/draw-widget", "likert", "range", "columns", diff --git a/widget/draw/draw-widget.js b/widget/draw/draw-widget.js new file mode 100644 index 00000000..43707303 --- /dev/null +++ b/widget/draw/draw-widget.js @@ -0,0 +1,706 @@ +import $ from 'jquery'; +import fileManager from 'enketo/file-manager'; +/** + * @external SignaturePad + */ +import SignaturePad from 'signature_pad'; +import { t } from 'enketo/translator'; +import dialog from 'enketo/dialog'; +import support from '../../js/support'; +import events from '../../js/event'; +import Widget from '../../js/widget'; +import { dataUriToBlobSync, getFilename } from '../../js/utils'; +import downloadUtils from '../../js/download-utils'; + +const DELAY = 1500; + +/** + * SignaturePad.prototype.fromDataURL is asynchronous and does not return + * a Promise. This is a rewrite returning a promise and the objectUrl. + * In addition it also fixes a bug where a loaded image is stretched to fit + * the canvas. + * + * @function external:SignaturePad#fromObjectURL + * @param {*} objectUrl - ObjectURL + * @param {object} options - options + * @param {number} [options.ratio] - ratio + * @param {number} [options.width] - width + * @param {number} [options.height] - height + * @return {Promise} a promise that resolves with an objectURL + */ +SignaturePad.prototype.fromObjectURL = function (objectUrl, options) { + const image = new Image(); + options = options || {}; + const deviceRatio = options.ratio || window.devicePixelRatio || 1; + const width = options.width || this._canvas.width / deviceRatio; + const height = options.height || this._canvas.height / deviceRatio; + const that = this; + + this._reset(); + + return new Promise((resolve) => { + image.src = objectUrl; + image.onload = () => { + const imgWidth = image.width; + const imgHeight = image.height; + const hRatio = width / imgWidth; + const vRatio = height / imgHeight; + let left; + let top; + + if (hRatio < 1 || vRatio < 1) { + // if image is bigger than canvas then fit within the canvas + const ratio = Math.min(hRatio, vRatio); + left = (width - imgWidth * ratio) / 2; + top = (height - imgHeight * ratio) / 2; + that._ctx.drawImage( + image, + 0, + 0, + imgWidth, + imgHeight, + left, + top, + imgWidth * ratio, + imgHeight * ratio + ); + } else { + // if image is smaller than canvas then show it in the center and don't stretch it + left = (width - imgWidth) / 2; + top = (height - imgHeight) / 2; + that._ctx.drawImage(image, left, top, imgWidth, imgHeight); + } + resolve(objectUrl); + }; + that._isEmpty = false; + }); +}; + +/** + * Similar to SignaturePad.prototype.fromData except that it doesn't clear the canvas. + * This is to facilitate undoing a drawing stroke over a background (bitmap) image. + * + * @function external:SignaturePad#updateData + * @param {*} pointGroups - pointGroups + */ +SignaturePad.prototype.updateData = function (pointGroups) { + const that = this; + this._fromData( + pointGroups, + (curve, widths) => { + that._drawCurve(curve, widths.start, widths.end); + }, + (rawPoint) => { + that._drawDot(rawPoint); + } + ); + + this._data = pointGroups; +}; + +/** + * Widget to obtain user-provided drawings or signature. + * + * @augments Widget + */ +class DrawWidget extends Widget { + /** + * @type {string} + */ + static get selector() { + // note that the selector needs to match both the pre-instantiated form and the post-instantiated form (type attribute changes) + return '.or-appearance-draw input[data-type-xml="binary"][accept^="image"], .or-appearance-signature input[data-type-xml="binary"][accept^="image"], .or-appearance-annotate input[data-type-xml="binary"][accept^="image"]'; + } + + _init() { + let canvas; + const that = this; + const existingFilename = this.element.dataset.loadedFileName; + + this.element.type = 'text'; + this.element.dataset.drawing = true; + + this.element.after(this._getMarkup()); + const { question } = this; + + question.classList.add(`or-${this.props.type}-initialized`); + + this.$widget = $(question.querySelector('.widget')); + + canvas = this.$widget[0].querySelector('.draw-widget__body__canvas'); + this._handleResize(canvas); + this._resizeCanvas(canvas); + + if (this.props.load) { + this._handleFiles(existingFilename); + } + + // This listener serves to capture a drawing when the submit button is clicked within [DELAY] + // milliseconds after the last stroke ended. Note that this could be the entire drawing/signature. + canvas.addEventListener('blur', this._forceUpdate.bind(this)); + + // We built a delay in saving on stroke "end", to avoid excessive updating + // This event does not fire on touchscreens for which we use the .hide-canvas-btn click + // to do the same thing. + + this.initialize = fileManager.init().then(() => { + that.pad = new SignaturePad(canvas, { + onEnd: () => { + // keep replacing this timer so continuous drawing + // doesn't update the value after every stroke. + clearTimeout(that._updateWithDelay); + that._updateWithDelay = setTimeout( + that._updateValue.bind(that), + DELAY + ); + }, + penColor: that.props.colors[0] || 'black', + }); + that.pad.off(); + if (existingFilename) { + that.element.value = existingFilename; + + return that + ._loadFileIntoPad(existingFilename) + .then(that._updateDownloadLink.bind(that)); + } + + return true; + }); + this.disable(); + this.initialize + .then(() => { + that.$widget + .find('.btn-reset') + .on('click', that._reset.bind(that)) + .end() + .find('.draw-widget__colorpicker') + .on('click', '.current', function () { + $(this).parent().toggleClass('reveal'); + }) + .on('click', '[data-color]:not(.current)', function () { + $(this) + .siblings() + .removeClass('current') + .end() + .addClass('current') + .parent() + .removeClass('reveal'); + that.pad.penColor = this.dataset.color; + }) + .end() + .find('.draw-widget__undo') + .on('click', () => { + const data = that.pad.toData(); + that.pad.clear(); + const fileInput = + that.$widget[0].querySelector('input[type=file]'); + // that.element.dataset.loadedFileName will have been removed only after resetting + const fileToLoad = + fileInput && fileInput.files[0] + ? fileInput.files[0] + : that.element.dataset.loadedFileName; + that._loadFileIntoPad(fileToLoad).then(() => { + that.pad.updateData(data.slice(0, -1)); + that._updateValue(); + that.pad.penColor = that.$widget.find( + '.draw-widget__colorpicker .current' + )[0].dataset.color; + }); + }) + .end() + .find('.show-canvas-btn') + .on('click', () => { + that.$widget.addClass('full-screen'); + that._resizeCanvas(canvas); + that.enable(); + + return false; + }) + .end() + .find('.hide-canvas-btn') + .on('click', () => { + that.$widget.removeClass('full-screen'); + that.pad.off(); + that._forceUpdate(); + that._resizeCanvas(canvas); + + return false; + }) + .click(); + + $(canvas).on('canvasreload', () => { + if (that.cache) { + that.pad + .fromObjectURL(that.cache) + .then(that._updateValue.bind(that, false)); + } + }); + that.enable(); + }) + .catch((error) => { + that._showFeedback(error.message); + }); + + $(this.element) + .on('applyfocus', () => { + canvas.focus(); + }) + .closest('[role="page"]') + .on(events.PageFlip().type, () => { + // When an existing value is loaded into the canvas and is not + // the first page, it won't become visible until the canvas is clicked + // or the window is resized: + // https://github.com/kobotoolbox/enketo-express/issues/895 + // This also fixes a similar issue with an empty canvas: + // https://github.com/kobotoolbox/enketo-express/issues/844 + that._resizeCanvas(canvas); + }); + } + + _forceUpdate() { + if (this._updateWithDelay) { + clearTimeout(this._updateWithDelay); + this._updateValue(); + } + } + + // All this is copied from the file-picker widget + /** + * @param {string} loadedFileName - the loaded filename + */ + _handleFiles(loadedFileName) { + // Monitor maxSize changes to update placeholder text in annotate widget. This facilitates asynchronous + // obtaining of max size from server without slowing down form loading. + this._updatePlaceholder(); + this.element + .closest('form.or') + .addEventListener( + events.UpdateMaxSize().type, + this._updatePlaceholder.bind(this) + ); + + const that = this; + + const $input = this.$widget.find('input[type=file]'); + const $fakeInput = this.$widget.find('.fake-file-input'); + + // show loaded file name or placeholder regardless of whether widget is supported + this._showFileName(loadedFileName); + + $input + .on('click', (event) => { + // The purpose of this handler is to block the filepicker window + // when the label is clicked outside of the input. + if (that.props.readonly || event.namespace !== 'propagate') { + that.$fakeInput.focus(); + event.stopImmediatePropagation(); + + return false; + } + }) + .on('change', function () { + // Get the file + const file = this.files[0]; + + if (file) { + // Process the file + if (!fileManager.isTooLarge(file)) { + // Update UI + that.pad.clear(); + that._loadFileIntoPad(this.files[0]).then(() => { + that._updateValue.call(that); + that._showFileName(file.name); + that.enable(); + }); + } else { + that._showFeedback( + t('filepicker.toolargeerror', { + maxSize: fileManager.getMaxSizeReadable(), + }) + ); + } + } else { + that._showFileName(null); + } + }); + + $fakeInput + .on('click', function (event) { + /* + The purpose of this handler is to selectively propagate clicks on the fake + input to the underlying file input (to show the file picker window). + It blocks propagation if the filepicker has a value to avoid accidentally + clearing files in a loaded record, hereby blocking native browser file input behavior + to clear values. Instead the reset button is the only way to clear a value. + */ + if ( + that.props.readonly || + $input[0].value || + $fakeInput[0].value + ) { + $(this).focus(); + event.stopImmediatePropagation(); + + return false; + } + event.preventDefault(); + $input.trigger('click.propagate'); + }) + .on( + 'change', + () => + // For robustness, avoid any editing of filenames by user. + false + ); + } + + /** + * @param {string} fileName - filename to show + */ + _showFileName(fileName) { + this.$widget + .find('.fake-file-input') + .val(fileName) + .prop('readonly', !!fileName); + } + + /** + * Updates placeholder + */ + _updatePlaceholder() { + this.$widget.find('.fake-file-input').attr( + 'placeholder', + t('filepicker.placeholder', { + maxSize: fileManager.getMaxSizeReadable() || '?MB', + }) + ); + } + + /** + * @return {DocumentFragment} a document fragment with the widget markup + */ + _getMarkup() { + // HTML syntax copied from filepicker widget + const load = this.props.load + ? `
` + : ''; + const fullscreenBtns = this.props.touch + ? '' + + '' + : ''; + const fragment = document.createRange().createContextualFragment( + `
+
+ ${fullscreenBtns} + ${load} + +
+ ${ + this.props.type === 'signature' + ? '' + : '' + } +
+ +
` + ); + fragment + .querySelector('.draw-widget__footer') + .prepend(this.downloadButtonHtml); + fragment + .querySelector('.draw-widget__footer') + .prepend(this.resetButtonHtml); + + const colorpicker = fragment.querySelector('.draw-widget__colorpicker'); + + this.props.colors.forEach((color, index) => { + const current = index === 0 ? ' current' : ''; + const colorDiv = document + .createRange() + .createContextualFragment( + `
` + ); + colorpicker.append(colorDiv); + }); + + return fragment; + } + + /** + * Updates value + * + * @param {boolean} [changed] - whether the value has changed + */ + _updateValue(changed = true) { + const newValue = this.pad.toDataURL(); + if (this.value !== newValue) { + const now = new Date(); + const postfix = `-${now.getHours()}_${now.getMinutes()}_${now.getSeconds()}`; + this.element.dataset.filenamePostfix = postfix; + // Note that this.element has become a text input. + // When a default file is loaded this function is called by the canvasreload handler, but the user hasn't changed anything. + // We want to make sure the model remains unchanged in that case. + if (changed) { + this.originalInputValue = this.props.filename; + } + // pad.toData() doesn't seem to work when redrawing on a smaller canvas. Doesn't scale. + // pad.toDataURL() is crude and memory-heavy but the advantage is that it will also work for appearance=annotate + this.value = newValue; + this._updateDownloadLink(this.value); + } + } + + /** + * Clears pad, cache, loaded file name, download link and others + */ + _reset() { + const that = this; + + if (this.element.value) { + // This discombobulated line is to help the i18next parser pick up all 3 keys. + const item = + this.props.type === 'signature' + ? t('drawwidget.signature') + : this.props.type === 'drawing' + ? t('drawwidget.drawing') + : t('drawwidget.annotation'); + dialog + .confirm(t('filepicker.resetWarning', { item })) + .then((confirmed) => { + if (!confirmed) { + return; + } + that.pad.clear(); + that.cache = null; + // Only upon reset is loadedFileName removed, so that "undo" will work + // for drawings loaded from storage. + delete that.element.dataset.loadedFileName; + delete that.element.dataset.loadedUrl; + that.element.dataset.filenamePostfix = ''; + $(that.element).val('').trigger('change'); + if (that._updateWithDelay) { + // This ensures that an emptied canvas will not be considered a drawing to be captured + // in _forceUpdate, e.g. after the blur event fires on an empty canvas see issue #924 + that._updateWithDelay = null; + } + // Annotate file input + that.$widget + .find('input[type=file]') + .val('') + .trigger('change'); + that._updateDownloadLink(''); + that.disable(); + that.enable(); + }); + } + } + + /** + * @param {string|File} file - Either a filename or a file. + * @return {Promise} promise resolving with a string + */ + _loadFileIntoPad(file) { + const that = this; + if (!file) { + return Promise.resolve(''); + } + if ( + typeof file === 'string' && + file.startsWith('jr://') && + this.element.dataset.loadedUrl + ) { + file = this.element.dataset.loadedUrl; + } + + return fileManager + .getObjectUrl(file) + .then(that.pad.fromObjectURL.bind(that.pad)) + .then((objectUrl) => { + that.cache = objectUrl; + + return objectUrl; + }) + .catch(() => { + that._showFeedback( + 'File could not be loaded (leave unchanged if already submitted and you want to preserve it).', + 'error' + ); + }); + } + + /** + * @param {string} message - the feedback message to show + */ + _showFeedback(message) { + message = message || ''; + + // replace text and replace all existing classes with the new status class + this.$widget.find('.draw-widget__feedback').text(message); + } + + /** + * @param {string} url - the download URL + */ + _updateDownloadLink(url) { + if (url && url.indexOf('data:') === 0) { + url = URL.createObjectURL(dataUriToBlobSync(url)); + } + const fileName = url + ? getFilename( + { name: this.element.value }, + this.element.dataset.filenamePostfix + ) + : ''; + downloadUtils.updateDownloadLink( + this.$widget.find('.btn-download')[0], + url, + fileName + ); + } + + /** + * Forces update and resizes canvas on window resize + * + * @param {Element} canvas - Canvas element + */ + _handleResize(canvas) { + const that = this; + $(window).on('resize', () => { + // that._forceUpdate(); + that._resizeCanvas(canvas); + }); + } + + /** + * Adjust canvas coordinate space taking into account pixel ratio, + * to make it look crisp on mobile devices. + * This also causes canvas to be cleared. + * + * @param {Element} canvas - Canvas element + */ + _resizeCanvas(canvas) { + // Use a little trick to avoid resizing currently-hidden canvases + // https://github.com/enketo/enketo-core/issues/605 + if (canvas.offsetWidth > 0) { + // When zoomed out to less than 100%, for some very strange reason, + // some browsers report devicePixelRatio as less than 1 + // and only part of the canvas is cleared then. + const ratio = Math.max(window.devicePixelRatio || 1, 1); + canvas.width = canvas.offsetWidth * ratio; + canvas.height = canvas.offsetHeight * ratio; + canvas.getContext('2d').scale(ratio, ratio); + $(canvas).trigger('canvasreload'); + } + } + + /** + * Disables widget + */ + disable() { + const that = this; + const canvas = this.$widget.find('.draw-widget__body__canvas')[0]; + + this.initialize.then(() => { + that.pad.off(); + canvas.classList.add('disabled'); + that.$widget.find('.btn-reset').prop('disabled', true); + }); + } + + /** + * Enables widget + */ + enable() { + const that = this; + const canvas = this.$widget.find('.draw-widget__body__canvas')[0]; + const touchNotFull = + this.props.touch && !this.$widget.is('.full-screen'); + const needFile = this.props.load && !this.element.value; + + this.initialize.then(() => { + if (!that.props.readonly && !needFile && !touchNotFull) { + that.pad.on(); + canvas.classList.remove('disabled'); + that.$widget.find('.btn-reset').prop('disabled', false); + } + // https://github.com/enketo/enketo-core/issues/450 + // When loading a question with a relevant, it is invisible + // until branch.js removes the "pre-init" class. The rendering of the + // canvas may therefore still be ongoing when this widget is instantiated. + // For that reason we call _resizeCanvas when enable is called to make + // sure the canvas is rendered properly. + that._resizeCanvas(canvas); + }); + } + + /** + * Updates value when it is programmatically cleared. + * There is no way to programmatically update a file input other than clearing it, so that's all + * we need to do. + */ + update() { + if (this.originalInputValue === '') { + this._reset(); + } + } + + /** + * @type {object} + */ + get props() { + const props = this._props; + + props.type = props.appearances.includes('draw') + ? 'drawing' + : props.appearances.includes('signature') + ? 'signature' + : 'annotation'; + props.filename = `${props.type}.png`; + props.load = props.type === 'annotation'; + props.colors = + props.type === 'signature' + ? [] + : [ + 'black', + 'lightblue', + 'blue', + 'red', + 'orange', + 'cyan', + 'yellow', + 'lightgreen', + 'green', + 'pink', + 'purple', + 'lightgray', + 'darkgray', + ]; + props.touch = support.touch; + props.accept = this.element.getAttribute('accept'); + props.capture = this.element.getAttribute('capture'); + + return props; + } + + /** + * @type {string} + */ + get value() { + return this.cache || ''; + } + + set value(dataUrl) { + this.cache = dataUrl; + } +} + +export default DrawWidget; diff --git a/widget/draw/draw-widget.scss b/widget/draw/draw-widget.scss new file mode 100644 index 00000000..7a2c7db6 --- /dev/null +++ b/widget/draw/draw-widget.scss @@ -0,0 +1,233 @@ +/* + * To save headaches with resizing canvases, it is important to maintain fixed aspect + * ratios at all times: + */ + +$ratio1: 0.75; +$ratio2: 0.45; +$fullscreen-margin-v: 50px; +$fullscreen-margin-h: 15px; +$picker-border: 2px solid grey; + +.or-drawing-initialized, +.or-signature-initialized, +.or-annotation-initialized { + input[type='text'] { + display: none; + } +} + +.or-signature-initialized { + .draw-widget__body { + padding-top: $ratio2 * 100%; + } +} + +.or-annotation-initialized { + // make space for absolutely positioned fake-file-input + .draw-widget { + margin-top: 50px; + } +} + +.draw-widget { + width: 100%; + + &__body { + position: relative; + width: 100%; // trick to fix aspect ratio with width of 100% to 4:3 + // combined with absolutely positioned canvas child + padding-top: $ratio1 * 100%; + + &__canvas { + background: white; + // for plain theme: + border: 1px solid $gray-lighter; // override border: + @include form-control; + + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + padding: 0; + width: 100%; + height: 100%; + + &.disabled { + cursor: not-allowed; + + ~ .draw-widget__colorpicker, + ~ .draw-widget__undo { + display: none; + } + + // show canvas normally (for readonly record views) + background: white; + opacity: 1; + } + } + + input[type='file'] { + display: none; + } + + .file-picker { + position: absolute; + top: -($fullscreen-margin-v); + left: 0; // TODO: RTL + width: 100%; + } + + .show-canvas-btn { + position: absolute; + z-index: 10; + top: calc(50% - 16px); + left: 50%; + width: 200px; + margin-left: -100px; + } + + .hide-canvas-btn { + display: none; + } + } + + &__footer { + margin-top: 10px; + + .draw-widget__btn-reset:disabled { + display: none; + } + } + + &__feedback { + @include question-error-message; + } + + &__undo { + position: absolute; + top: 37px; + right: 7px; + width: 20px; + height: 20px; + margin: 2px; + padding: 0; + border: $picker-border; + } + + &__colorpicker { + position: absolute; + display: flex; + flex-wrap: wrap; + max-width: calc(100% - (2 * 7px)); + top: 7px; + right: 7px; + + div { + display: none; + } + + div { + width: 20px; + height: 20px; + margin: 2px; + border: none; + padding: 0; + } + + &.reveal div { + display: block; + } + + .current { + display: block; + border: $picker-border; + } + } + + &.full-screen { + @include display-flex; + + @include flex-direction(column); + + @include flex-wrap(nowrap); + + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 20; + background: white; + margin: 0; + padding-left: $fullscreen-margin-h; + padding-right: $fullscreen-margin-h; + + .draw-widget__body { + width: calc(100% - 2 * #{$fullscreen-margin-h}); + padding-top: calc( + #{$ratio1} * (100% - 2 * #{$fullscreen-margin-h}) + ); + margin: $fullscreen-margin-v auto; + + input[type='file'] { + left: 90px; // TODO: RTL + } + + .file-picker { + left: 80px; + width: calc(100% - 18px); + } + } + + .hide-canvas-btn { + display: block; + position: absolute; + z-index: 30; + top: -($fullscreen-margin-v - 10px); + left: 0; // TODO: RTL + width: 70px; + } + + .show-canvas-btn { + display: none; + } + + .draw-widget__footer { + width: calc(100vmin - 2 * #{$fullscreen-margin-h}); + margin: -($fullscreen-margin-v - 10px) auto 0 auto; + } + } + + .btn-download { + margin-right: 0; + + &[href=''] { + display: none; + } + } +} + +.or-signature-initialized { + .draw-widget.full-screen { + .draw-widget__body { + width: calc(100% - 2 * #{$fullscreen-margin-h}); + padding-top: calc( + #{$ratio2} * (100% - 2 * #{$fullscreen-margin-h}) + ); + } + + .draw-widget__footer { + width: calc( + 100% - 2 * #{$fullscreen-margin-h} + ); //margin: -($fullscreen-margin-v - 10px) auto 0 auto; + } + } +} + +.or-annotate-initialized { + .draw-widget__body { + margin-top: $fullscreen-margin-v; + } +} From e49ffb662456af9e0f7c5a67d8d6a9f8e746a245 Mon Sep 17 00:00:00 2001 From: Krikor Krumlian Date: Wed, 10 Jan 2024 14:37:32 -0500 Subject: [PATCH 2/3] Reverting Draw Widget #2 --- widget/draw/draw-widget.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/widget/draw/draw-widget.js b/widget/draw/draw-widget.js index 43707303..d43de67b 100644 --- a/widget/draw/draw-widget.js +++ b/widget/draw/draw-widget.js @@ -6,11 +6,11 @@ import fileManager from 'enketo/file-manager'; import SignaturePad from 'signature_pad'; import { t } from 'enketo/translator'; import dialog from 'enketo/dialog'; -import support from '../../js/support'; -import events from '../../js/event'; -import Widget from '../../js/widget'; -import { dataUriToBlobSync, getFilename } from '../../js/utils'; -import downloadUtils from '../../js/download-utils'; +import support from 'enketo-core/src/js/support'; +import events from '../../public/js/src/module/event'; +import Widget from 'enketo-core/src/js/widget'; +import { dataUriToBlobSync, getFilename } from 'enketo-core/src/js/utils'; +import downloadUtils from 'enketo-core/src/js/download-utils'; const DELAY = 1500; From 8b95f3550f09b40401ccff48fef314f794b92c71 Mon Sep 17 00:00:00 2001 From: Krikor Krumlian Date: Wed, 10 Jan 2024 16:03:45 -0500 Subject: [PATCH 3/3] Reverting Draw Widget #3 --- config/default-config.json | 1 - package.json | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/default-config.json b/config/default-config.json index c59e943c..28765a77 100644 --- a/config/default-config.json +++ b/config/default-config.json @@ -47,7 +47,6 @@ "datetime", "select-media", "file", - "draw", "rank", "../../../widget/analog-scale/analog-scalepicker", "../../../widget/discrepancy-note/dn-widget", diff --git a/package.json b/package.json index 543213d0..eb888f8c 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "valid-url": "^1.0.9", "vex-dialog-enketo": "github:enketo/vex-dialog-enketo#b6e0f01", "vex-js": "^4.1.0", - "xml2js": "^0.5.0" + "xml2js": "^0.5.0", + "signature_pad": "2.3.x" }, "devDependencies": { "@babel/core": "^7.22.10",