polubis / 4markdown

16 stars 3 forks source link

[Bug][3] If links added or long one line text creator layout is broken in full preview mode #64

Open polubis opened 7 months ago

polubis commented 7 months ago

Context

Copy the following document content into creator:

> Before you start reading this article, make sure that your **Firebase** project is already created, you have **Blaze** or another premium plan on **Firebase**, and the **React** app is configured. We'll not dive into these concepts.

# Uploading Images with **Firebase** and **React**

Uploading images with **Firebase** and **React** is quite straightforward. Nevertheless, there are potential pitfalls that can consume your time unnecessarily. These include validation, permissions, determining the appropriate image format, and uploading logic.

With these prerequisites in mind, let's dive in! Here you have a demo of the final result, that is already working on this site!

![Demo of Image Upload](https://firebasestorage.googleapis.com/v0/b/markdown-b9f5e.appspot.com/o/AQf2hcbxgSevVmNGPhRZTJg4M7D3%2Fimages%2F354f3e69-e138-4205-ab0a-da6cfe66d5e9?alt=media)
*Demo of Image Upload*

> I simplified the example really hard, to focus on **upload** logic. If you want to check how to implement the following UI from the demo, go to the final repository that is linked at the end of the article. 

## Let's Start with **Frontend**

To begin, we need to have a `file input`. Take a look at the following code: 

```javascript
const FileInput = () => {
  const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    console.log(e.target.files); // The files list to pick up and validate.
  };

  return <input type="file" onChange={handleChange} />;
};

We've set up an input mechanism. Now, our next step is to ensure that any uploaded images are parsed correctly before they're sent to the Cloud Function. To accomplish this, we'll create the following parser:

const readFileAsBase64 = (file: File): Promise<FileReader['result']> => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    // Converts image to URL -> data:image/jpeg;base64,
    reader.readAsDataURL(file);
  });
};

export { readFileAsBase64 };

The readAsDataURL enables you to read files asynchronously and obtain a data URL representation.

Now, you need to call readFileAsBase64 just after getting files from file input. Take note of the Array.isArray call, which checks whether the files have been selected by the user or not.

  const handleChange: React.ChangeEventHandler<HTMLInputElement> = async (
    e,
  ) => {
    // Files may be "null" or "FileList".
    if (Array.isArray(e.target.files)) {
      const base64URL = await readFileAsBase64(e.target.files[0]);
    }
  };

Frontend is almost ready. To finish that part, we need to invoke a Cloud Function. In our case, it will be an uploadImage.

import { FirebaseOptions, initializeApp } from 'firebase/app';
import { getFunctions } from 'firebase/functions';

// firebase-setup.ts
const config: FirebaseOptions = {
  apiKey: process.env.GATSBY_API_KEY,
  authDomain: process.env.GATSBY_AUTH_DOMAIN,
  projectId: process.env.GATSBY_PROJECT_ID,
  storageBucket: process.env.GATSBY_STORAGE_BUCKET,
  messagingSenderId: process.env.GATSBY_MESSAGING_SENDER_ID,
  appId: process.env.GATSBY_APP_ID,
  measurementId: process.env.GATSBY_MEASURMENT_ID,
};

const app = initializeApp(config);
const functions = getFunctions(app);

export { app, functions };

Then, in any place in the React components tree, you may import the functions variable and perform a call to Cloud Function.

import React from 'react';
import { httpsCallable } from 'firebase/functions';
import { readFileAsBase64 } from 'development-kit/file-reading';
import { functions } from './firebase-setup';

// Payload object shape.
type UploadImagePayload = {};
// Response object shape.
type UploadImageDto = {};

const FileInput = () => {
  const handleChange: React.ChangeEventHandler<HTMLInputElement> = async (
    e,
  ) => {
    if (Array.isArray(e.target.files)) {
      const { data } = await httpsCallable<UploadImagePayload, UploadImageDto>(
        functions,
        `uploadImage`,
      )({ image: await readFileAsBase64(e.target.files[0]) });
    }
  };

  return <input type="file" onChange={handleChange} />;
};

Why we've chosen the base64 representation instead of the simple FormData and File? The answer is simple, we want to have the option to upload images from clipboard without any additional headaches.

By default Cloud Functions will send a application/json type of request.

That's all on Frontend, now it's time for Backend.

Adding Storage Bucket

We need to have a place to store our images. By default, there is no storage attached to your project on Firebase. You need to create it manually, or programmatically based on your application logic. To create it manually you need to go to Firebase Console. Just follow this gif:

Finding Storage in Dashboard Finding Storage in Dashboard

In my case, I already have the storage. If it's your first time, don't worry, the UX on Firebase is great, and they will guide you. It's just several fields to populate, and nothing more.

Keep in mind, that after creating a storage, the default bucket for files will be created - this is the place where we'll save images.

Creating Rules for Storage

We need to navigate to the Storage -> Rules tab in the dashboard.

Rules Setup Location Rules Setup Location

Some changes in default rules will be required. We need to be sure that operations on our images will be allowed only for signed-in users (update, delete, write). On the other hand, we want to make images public for everyone. Of course, your app requirements may be different. It's just for the showcase:

rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read;
      allow update, delete, write: if request.auth != null;
    }
  }
}

Cloud Function to Upload Image

Now, on the Backend we need to create a function called uploadImage. We'll use the onCall helper from sdk.

import { https } from 'firebase-functions';
import * as admin from 'firebase-admin';
import { v4 as uuid } from 'uuid';

admin.initializeApp();

const { onCall, HttpsError } = https;

type UploadImagePayload = { image: string };

export const uploadImage = onCall(
  async ({ image }: UploadImagePayload, context) => {
    const { auth } = context;

    if (!auth)
      throw new HttpsError(`internal`, `Only authorized users allowed`);

    const storage = admin.storage();
    const bucket = storage.bucket();
    const [bucketExists] = await bucket.exists();

    if (!bucketExists)
      throw new HttpsError(
        `internal`,
        `No bucket. Create it manually on dashboard or use existing one`,
      );

    const id = uuid();
    const location = `${auth.uid}/images/${id}`;
    const file = bucket.file(location);

    const [meta] = image.split(`,`);
    const contentType = meta.split(`:`)[1].split(`;`)[0]; // image/png or other...
    const extension = contentType.replace(`image/`, ``); // png or other...
    const blob = image.replace(/^data:image\/\w+;base64,/, ``); // Image data.
    const buffer = Buffer.from(blob, `base64`); // Buffer to upload.

    await file.save(buffer, {
      contentType,
    });
    // Unique URL to fetch image.
    const url = `https://firebasestorage.googleapis.com/v0/b/${
      bucket.name
    }/o/${encodeURIComponent(location)}?alt=media`;

    return { url, extension, contentType, id }; // It goes to Frontend
  },
);

At the beginning we checked for authorization status - it may be determined by the existence of the auth property in the context object. If it's null, it means the user is not authorized.

Next, we've checked for bucket existence. Same situation, if the storage and bucket are not created, we're throwing an error.

By calling admin.storage() we're getting a reference to default storage. The same happens with the storage.bucket() call. If you want to choose another storage or bucket, you may pass a reference path as a parameter.

Later, we took the original image parameter which is the base64 format string. That's the payload from Frontend. We split it into several parts: type, extension, and data.

At that end, we've used the encodeURIComponent function. Without that, the image will not be displayed, instead, you'll get an error. It's because of the complex URL form that may contain many / characters and this encoded form contains a replaced variant.

The encodeURIComponent function transform example: This is a & test / to This%20is%20a%20%26%20test%20%2F.

Validating Image

Before uploading an image we need to have a validation mechanism, for example, how to check if the uploaded file is an image or check its size. To achieve that, at the beginning of the previous function, you can add something like this:

    const buffer = Buffer.from(blob, `base64`); // Buffer to upload.

    const sizeAsMegabytes = Number.parseFloat(
      (Buffer.byteLength(buffer) / 1024 / 1024).toFixed(2),
    );
    // Checks if the image is smaller than 4 Megabytes
    const hasAllowedSize = sizeAsMegabytes < 4;

If you want to check the file type, use the following code:

    const extension = contentType.replace(`image/`, ``); // png or other...
    const IMAGE_EXTENSIONS = [`png`, `jpeg`, `jpg`, `gif`] as const;
    type ImageExtension = (typeof IMAGE_EXTENSIONS)[number];
    const isFormatCorrect = IMAGE_EXTENSIONS.includes(extension as ImageExtension)

Final Result and Source Code

All code that has been shown here can be found in the following repositories:

  1. Backend - https://github.com/polubis/greenonsoftware-api/tree/develop/functions/src.
  2. Frontend - https://github.com/polubis/4markdown/tree/develop.

Summary

Now you know the quick way of uploading images on Firebase Storage. We've used Cloud Functions to provide some logic on the Backend side, we performed a validation, and at the end, the image was saved on default bucket localized in storage.

On the Frontend we transformed the original File to base64 format, to prepare a base for uploading images from clipboard - especially useful on desktop devices φ(* ̄0 ̄).



After that, on mobile devices, you'll see the completely broken layout, when full preview mode is clicked.

**Definition of Done**

1. There should be an internal scroll inside the preview area, that blocks the occurrence of window x scroll. 
2. Layout looks for every device from 320px+ width devices. 
polubis commented 7 months ago

Can be tested her: https://soft-pithivier-093469.netlify.app/

polubis commented 6 months ago

Tested