NoelOConnell / quill-image-uploader

A module for Quill rich text editor to allow images to be uploaded to a server instead of being base64 encoded
MIT License
243 stars 97 forks source link

Doesn't upload data-URL images pasted from HTML #32

Open davidbludlow opened 4 years ago

davidbludlow commented 4 years ago

When you copy/paste HTML into Quill, and the pasted HTML has an image that has a src that is a data URL, then this module doesn't properly upload the image.

Screencast of this issue in action: http://somup.com/cYQjrwXZPy

Admittedly, this issue is not as common, because most images on the internet don't have src attribute values that are data URLs, but it still needs fixing.

davidbludlow commented 4 years ago

This issue was a big enough deal that I stopped using this module and made something on my own to fix the problem. Please learn from the algorithm I used, and change this module accordingly.

I didn't have time to make my implementation use nice placeholders for the images as they are being uploaded, but I find my implementation to be more reliable at making sure that no images with data URLs go un-uploaded. Here is a screencast of my code in action http://somup.com/cYQjrdXZRP . My implementation is a reusable Vue component for Quill. Sorry if it is hard for you to read .vue files or if you are not used to TypeScript, but hopefully you can get the gist of it. Please skip to the part that has lots of ****************************** asterisks.

<template>
  <div style="position: relative">
    <div class="quillOuter" ref="quillOuter">
      <div ref="quillDiv" class="quill-div"></div>
    </div>
    <div v-if="uploading" class="spinner-overlay">
      <span>Uploading Image</span>
      <!-- The following is a Vue component from the Vuetify library. It just looks like a spinning circular uploading icon -->
      <v-progress-circular
        indeterminate
        label="Loading..."
        style="margin-left: 1em"
      ></v-progress-circular>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-property-decorator';
import Quill from 'quill';
import { cloneDeep, set } from 'lodash';
import Delta from 'quill-delta';
import '../../node_modules/quill/dist/quill.snow.css';
import BlotFormatter from 'quill-blot-formatter';
import QuillImageDropAndPaste from 'quill-image-drop-and-paste';
// Uploads a data URL description of an picture to Amazon S3 storage (which is cheap) and
// returns a link to that picture that can be used as the `src` of an `img` element.
import { uploadDataUrl } from '../stores/image-storage';

@Component
export default class QuillComponent extends Vue {
  /**
   * Don't set this if you set `this.delta`.
   */
  @Prop({ type: Object, default: null }) startingDelta: QuillDeltaClasslessOrNot | null;
  /**
   * This is meant to be used with the .sync modifier (
   * https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier ).
   *
   * Don't set this if you set `this.startingDelta`.
   */
  @Prop({ type: Object, default: null }) delta: QuillDeltaClasslessOrNot | null;
  /**
   * This is meant to be used with the .sync modifier (
   * https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier ).
   *
   * The default value is "<p><br></p>" because it is the undocumented truth that that is
   * the value for an empty Quill editor. Quill can't get more empty than that.
   */
  @Prop({ type: String, default: '<p><br></p>' }) html: HtmlString;
  /**
   * This is meant to be used with the .sync modifier (
   * https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier ).
   *
   * See https://quilljs.com/docs/api/#gettext for why "\n" is the default value for this.
   */
  @Prop({ type: String, default: '\n' }) text: string;
  /**
   * An array of option modifications for Quill's initialization. Each modification is a
   * tuple. The first element of the tuple is a path to the option property, and the
   * second part of the tuple is the new value. See lodash's `_.set()` for info on what
   * form the path can take.
   */
  @Prop({ type: Array }) options: [[string, any]];

  quill?: Quill; // not initialized because reactivity is unnecessary.
  _lastValueForHtmlEmitted?: HtmlString; // not initialized because reactivity is unnecessary.
  uploading = false;
  quillDisabled_privateVar = false;
  tryingToUndoTheCreationOfAnImage?: boolean; // not initialized because reactivity is unnecessary.

  created() {
    if (!('modules/blotFormatter' in (Quill as any).imports)) {
      Quill.register('modules/blotFormatter', BlotFormatter);
    }
    if (!('modules/imageDropAndPaste' in (Quill as any).imports)) {
      Quill.register('modules/imageDropAndPaste', QuillImageDropAndPaste);
    }
  }

  mounted() {
    this._lastValueForHtmlEmitted = this.html;
    if (!this.quill) {
      const options = {
        modules: {
          formula: true, // Include formula module
          blotFormatter: {},
          toolbar: [
            [{ header: [1, 2, 3, false] }], // custom button values
            ['bold', 'italic'], // toggled buttons
            ['link', 'image', 'video'],
            [{ list: 'ordered' }, { list: 'bullet' }],
            [{ align: [] }, { indent: '-1' }, { indent: '+1' }], // outdent/indent
            [{ color: [] }, { background: [] }], // dropdown with defaults from theme
            ['clean'],
            ['formula'], // remove formatting button
          ],
          imageDropAndPaste: {},
        },
        theme: 'snow',
      } as { [x: string]: any };
      if (this.options) {
        for (let option of this.options) {
          set(options, option[0], option[1]);
        }
      }
      this.quill = new Quill(this.$refs.quillDiv as Element, options);
    }
    // the `as Delta` on the next line is technically not true
    this.quill.setContents((this.startingDelta ?? this.delta) as Delta);

    // **************************************************************************
    // **************************************************************************
    // **************************************************************************
    // **************************************************************************
    // **************************************************************************
    // This handler the important part!
    // **************************************************************************
    // **************************************************************************
    // **************************************************************************
    // **************************************************************************
    // **************************************************************************
    this.quill.on('text-change', (delta, oldContents, source) => {
      if (this.tryingToUndoTheCreationOfAnImage) return;
      const recordsOfNewImagesThatNeedUploading = findAllInsertDataUrlImageDeltaOperations(
        delta
      );
      if (recordsOfNewImagesThatNeedUploading.length) {
        this.uploadAllImagesThatNeedUploading(delta, oldContents); // async function called without await
      } else {
        // This is boring Vue stuff you can ignore
        this.notifyTheOtherPartsOfMyProgramThatTheQuillDocumentChanged();
      }
    });
  }

  async uploadAllImagesThatNeedUploading(delta: Delta, oldContents: Delta) {
    const imageTooLargeMessage = 'Image too large';
    const newDelta = this.quill.getContents();
    const newDeltaClone: ClasslessQuillDelta = cloneDeep(newDelta);
    const dataUrlImageObjectsInClone = findAllInsertDataUrlImageDeltaOperations(
      newDeltaClone
    );
    this.uploading = true;
    this.quillDisabled = true;
    this.tryingToUndoTheCreationOfAnImage = true;
    // When people fix the quill-image-uploader npm package, those guys will not have to
    // undo the last operation like I did on the two lines below. The only reason why I
    // did it this way, was because I didn't want the user to press the "Save" button
    // while the image was being uploaded, which would have caused a nasty, large data URL
    // to be saved in my precious database.
    const deltaToUndoLastOperation = new Delta(delta).invert(oldContents);
    this.quill.updateContents(deltaToUndoLastOperation, 'api');
    this.tryingToUndoTheCreationOfAnImage = false;
    try {
      for (let deltaOperation of dataUrlImageObjectsInClone) {
        let dataUrl = deltaOperation.insert.image;
        const size = approximateSizeOfImageAfterWillBeUploaded(dataUrl);
        const fiveMegaBytes = 5 * 1024 * 1024;
        const aBitOfLeewayToMakeUpForTheFactThatWeAreUsingAnApproximation = 200;
        if (
          size >
          fiveMegaBytes +
            aBitOfLeewayToMakeUpForTheFactThatWeAreUsingAnApproximation
        ) {
          throw new Error(imageTooLargeMessage);
        }
        const awsUrl = await uploadDataUrl(dataUrl);
        deltaOperation.insert.image = awsUrl;
      }
    } catch (error) {
      if (error.message === imageTooLargeMessage) {
        alert('Image file size is too large (needs to be < 5MB).');
      } else {
        alert('Error uploading picture');
      }
      throw error;
    } finally {
      this.quillDisabled = false;
      this.uploading = false;
    }
    // We could just do `this.quill.setContents(new Delta(newDeltaClone), 'user');` but
    // that would consistently mess up the cursor position. This only sometimes messes it
    // up.
    this.quill.updateContents(
      oldContents.diff(new Delta(newDeltaClone)),
      'user'
    );
  }

  private notifyTheOtherPartsOfMyProgramThatTheQuillDocumentChanged() {
    this.$emit('contentsChanged', this.quill);
    if (this.delta) {
      this.$emit('update:delta', this.quill.getContents());
      const html = this.quill.root.innerHTML;
      this._lastValueForHtmlEmitted = html;
      this.$emit('update:html', html);
      this.$emit('update:text', this.quill.getText());
    }
  }

  @Watch('startingDelta')
  onStartingDeltaChanged() {
    // the `as Delta` on the next line is technically not true
    this.quill.setContents(this.startingDelta as Delta);
  }

  @Watch('html')
  onHtmlChanged(value: HtmlString) {
    if (this._lastValueForHtmlEmitted !== value) {
      // If `this.html` changed, we will assume that this.delta changed. We only care if
      // the delta changed. We are only paying attention to the html, because since the
      // html is a string, it is easier to compare than the delta, or so I assume.

      // the `as Delta` is technically not true
      this.quill.setContents(this.delta as Delta);
    }
  }

  get quillDisabled() {
    return this.quillDisabled_privateVar;
  }
  set quillDisabled(value: boolean) {
    this.quill.enable(!value);
    this.quillDisabled_privateVar = value;
  }
}

function findAllInsertDataUrlImageDeltaOperations(delta: ClasslessQuillDelta) {
  const dataUrlRegex = /^data:image/;
  return delta.ops.filter(
    deltaOperation =>
      deltaOperation.insert &&
      deltaOperation.insert.image &&
      deltaOperation.insert.image.match(dataUrlRegex)
  );
}

/**
 * Returns approximate size in bytes. my not-all-that-educated guess is that it will not
 * be more than 300 bytes off.
 */
function approximateSizeOfImageAfterWillBeUploaded(dataUrl: string) {
  // Later, if we wanted to make a more exact algorithm, we could use the
  // quill-image-drop-and-paste package we already have to convert the data URL to a
  // `File` then just read the file size.
  //
  // See https://developer.mozilla.org/en-US/docs/Glossary/Base64 for this approximation
  // algorithm
  const aFewBytesToAccountForTheSizeOfTheFileName = 200;
  return Math.ceil(
    (dataUrl.length * 3) / 4 + aFewBytesToAccountForTheSizeOfTheFileName
  );
}

/**
 * This is a Quill Delta object that has been serialized then deserialized.
 *
 * The creation of this type is probably incomplete.
 */
type ClasslessQuillDelta = { ops: import('quill').DeltaOperation[] };

/**
 * What should be saved if you ever might edit this rich text again. It is the only
 * acceptable input for the Quill editor.
 *
 * Even though it is called a Delta, it still holds the entire document. It is obtained by
 * `quill.getContents()`.
 *
 * This is either a `Delta` object or a serialized then deserialized `Delta` object.
 */
type QuillDeltaClasslessOrNot = import('quill-delta') | ClasslessQuillDelta;
</script>

<style scoped>
.ql-container {
  height: auto;
}

/** prevent link and formula tool to show *behind* other components */
.quillOuter >>> .ql-tooltip {
  z-index: 1;
}

.spinner-overlay {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  background: rgba(0, 0, 0, 0.3);
  display: flex;
  justify-content: center;
  align-items: center;
}

.spinner-overlay .spinner-border {
  width: 3rem;
  height: 3rem;
}
</style>

My package.json includes dependencies on

    "quill": "^1.3.7",
    "quill-blot-formatter": "^1.0.5",
    "quill-image-drop-and-paste": "^1.1.1",

among other things.