DEV Community

Cover image for I made a 'Google keep' clone with most of its features using Angular
Abdjalil Brihoum
Abdjalil Brihoum

Posted on • Edited on

I made a 'Google keep' clone with most of its features using Angular

Introduction

For those who don’t know Google keep, it’s a note-taking service offered by Google.
Because I frequently use this service, I decided to make a clone that follows the same design pattern and include most of its features.

This project is made with the help of :

Github

Demonstration

Thanks to Vercel, you can play with the project online

Preview

Project structure

It follows a simple structure :

structure

Preview

Disclaimer :

⚠ This is not a tutorial on how to make a clone of Google keep. Here i'll show you the techniques i used to solve problems i encountered while making this project.

Getting started

I divided my project to 5 components, we'll discuss two(2) of them :

  • The input component.
  • The notes component.

The other three(3) are too simple to discuss.

Table Of Contents

  1. 1️⃣ The input component

    1. Problem N°1
    2. Solution to problem N°1
    3. Problem N°2
    4. Solution to problem N°2
      1. 1️⃣ Simple note (not the one with checkboxes)
      2. 2️⃣ List note (the one with checkboxes)
  2. 2️⃣ The note component

    1. Problem N°1
    2. Solution to problem N°1
    3. Problem N°2
    4. Solution problem N°2

1️⃣ The input component

--

Problem N°1

Watch the following video :

Solution to problem N°1

I created two (2) containers :

  • The first one is just for the design, a placeholder
  • The second one is where the users actually write their notes

placeholder

Then i added a hidden attribute to the second container, so only the placeholder will be visible.

hidden attribute

Now, when the user clicks on the placeholder, we call a method that :

  1. Add a hidden attribute to the placeholder

  2. Remove the hidden attribute from the second container

html

 notePhClick() {
    this.notePlaceholder.nativeElement.hidden = true;
    this.noteMain.nativeElement.hidden = false
 }
Enter fullscreen mode Exit fullscreen mode

Now, inside the second container, i have a condition :

  • What template should I render, the note template or the list template.

noteOrList

So, i need to :

  • First , create a new boolean isCheckbox and set it to false :
isCheckbox = false
Enter fullscreen mode Exit fullscreen mode
  • Second, inside my second container I need to check if isCheckbox is true, i render the list template, else the note template :
<div #noteMain class="note-main">
  <div *ngIf="isCheckbox">
    <!-- list content -->
  </div>
  <div *ngIf="!isCheckbox">
    <!-- note content -->
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

ℹ This approach works fine, but i prefer using ngTemplateOutlet and have a cleaner code :

<div #noteMain class="note-main" hidden>
  <ng-container
    [ngTemplateOutlet]="isCheckbox ? checkboxTemplate : noteTemplate"
  >
  </ng-container>
</div>
Enter fullscreen mode Exit fullscreen mode

And create two ng-template each one contains the proper HTML :

templates

So, if isCheckbox is true checkboxTemplate will be rendered, else noteTemplate will be rendered.

✳ This also mean, when the user clicks on the placeholder (and not the check icon) noteTemplate will be rendered. So, we should have :

noteTemplate

✳ But when the user clicks on the check icon, we need to change isCheckbox to true :

isCheckbox

And because of the Event bubbling, notePhClick() will be called, as a result, checkboxTemplate will be rendered (because isCheckbox = true) :

checkboxTemplate

❔ What about the autofocus you may ask ? simple :

  • Inside notePhClick() we add a condition and focus() the right element
 notePhClick() {
    this.notePlaceholder.nativeElement.hidden = true;
    this.noteMain.nativeElement.hidden = false
    //
    if (this.isCheckbox) this.noteBody.nativeElement.focus()
    else this.cboxBody.nativeElement.focus()
 }
Enter fullscreen mode Exit fullscreen mode

Now, for the last part of this problem : closing the note when clicking outside.

The solution is to listen to mousedown event :

  • If the click is outside note box, hide noteMain and show notePlaceholder. The opposite of what we did earlier :
 notePhClick() {
    // we add the following :
    document.addEventListener('mousedown', (event: Event) => {
      let el = this.noteMain.nativeElement as any
      if (!(el).contains(event.target)) {
        // if the click is outside `el` (means outside the note box) do :
        this.notePlaceholder.nativeElement.hidden = false
        this.noteMain.nativeElement.hidden = true
      }
    })
  }
Enter fullscreen mode Exit fullscreen mode

Problem N°2

Watch the following video :

Solution to problem N°2

First, let's create a tooltip, for that, i used PopperJs.

Note : for those wondering what's a tooltip :

tooltip

By reading PopperJs Docs :

popper

So i need two things :

  • A button : needs no further explanation.
  • A tooltip div : the tooltip shown in the picture above is too simple to discuss, so i'm going to show you how i managed to create the colors & backgrounds tooltip.

tooltip

To create this tooltip :

➖ First, I need a list of colors & backgrounds, so i created two enums (you can also get this done with only simple objects) :
enums

➖ Second, I need to iterate over my enum using *NgFor. And because I have an object and not an array, i need to add the keyvalue pipe :

<!-- tooltip div -->
<div #colorMenuTt hidden>
  <div class="first-row">
    <!-- first row : colors -->
    <ng-container *ngFor="let color of bgColors | keyvalue">
      <div
        [style.background-color]="color.value"
        (click)="changeColor(color.value)"
      ></div>
    </ng-container>
  </div>
  <div class="second-row">
    <!-- first row : backgrounds -->
    <ng-container *ngFor="let image of bgImages | keyvalue">
      <div
        [style.background-image]="'url('+image.value+')'"
        (click)="changeImage(image.value)"
      ></div>
    </ng-container>
  </div>
</div>
<!-- We will discuss changeImage() & changeColor() later -->
Enter fullscreen mode Exit fullscreen mode

Note : notice the hidden attribute, we don't need this div to be visible, until we need it to be.

--

✳I have my button, I have my tooltip div, I can now create a new tooltip using PopperJs :

  • On the button element btn I need to bind a click event that calls a method, this method needs two parameters : a reference to this same button & to the tooltip div
<!-- colorMenuTt : reference to the tooltip div we created just above  -->
<div
  #colorMenuTtBtn
  (click)="openTooltip(colorMenuTtBtn,colorMenuTt)"
  class="color"
></div>
Enter fullscreen mode Exit fullscreen mode

The method in question :

  openTooltip(button: HTMLDivElement, tooltipEl: HTMLDivElement) {
    // first, we need to remove the `hidden` attribute from the tooltip div
    this.tooltipEl.nativeElement.hidden = false;
    // then we call PopperJs
    createPopper(button, tooltipEl)
  }
Enter fullscreen mode Exit fullscreen mode

Note : If you want the tooltip to close (or hide) when the user clicks away, you need to listen to mousedown event like we did earlier, and re-add the hidden attribute to the tooltip div. So, the method will look like this :

  openTooltip(button: HTMLDivElement, tooltipEl: HTMLDivElement) {
    this.tooltipEl.nativeElement.hidden = false
    createPopper(button, tooltipEl)
    //
    document.addEventListener('mousedown', (event: Event) => {
      let el = this.tooltipEl.nativeElement as any
      if (!(el).contains(event.target)) {
        this.tooltipEl.nativeElement.hidden = true
      }
    })
  }
Enter fullscreen mode Exit fullscreen mode

✳ Now, let's retrieve what the user typed. And we start with :

1️⃣ Simple note (not the one with checkboxes)

To retrieve what the user typed, i need to add two template variables, one for the note title, and one for the note body (#noteTitle, #noteBody).

template variable

Now, with the help of ViewChild & innerHTML (because it's a div, not an input), i can retrieve what the user typed by executing the following method :

  saveNote() {
    let noteObj = {
      noteTitle: this.noteTitle.nativeElement.innerHTML,
      noteBody: this.noteBody.nativeElement.innerHTML
    }
    // we call another method to save this object on the database or whatever
    this...save(noteObj)
  }
Enter fullscreen mode Exit fullscreen mode

⚠ So, it needs to be called every time the user closes the note, To do that, we need to add it to notePhClick(), inside the callback of the event listener :

  notePhClick() {
    //...
    document.addEventListener('mousedown', (event: Event) => {
      let el = this.noteMain.nativeElement as any
      if (!(el).contains(event.target)) {
        // ⚠ we add it here ⚠
        this.saveNote()
      }
    })
  }
Enter fullscreen mode Exit fullscreen mode

TLDR : when the user clicks outside the note box, the callback function inside notePhClick() event listener calls saveNote().

2️⃣ List note (the one with checkboxes)

cb

When the user clicks on the new list icon, isCheckbox becomes true and this time, checkboxTemplate will be rendered.

Before starting, I need first to create a new array :

 interface CheckboxI {
    done: boolean,
    data: any,
    id: number
}
checkBoxes: CheckboxI[] = []
Enter fullscreen mode Exit fullscreen mode

After that, inside checkboxTemplate I have the following :

<!-- ------------- part 1 ------------- -->
<!-- looping through the array we just created -->
<ng-template #checkboxesTemplate>
  <ng-container *ngFor="let cb of checkBoxes">
    <!-- the check icon https://i.imgur.com/ANXf4Po.png -->
    <div
      [class.done]="cb.done"
      class="cbox-icon"
      (click)="cb.done = !cb.done"
    ></div>
    <!-- the checkbox content -->
    <div>
      <div
        [innerHTML]="cb.data"
        class="cbox-note"
        contenteditable="true"
        spellcheck="true"
      ></div>
    </div>
  </ng-container>
  <!-- ------------- part 2 ------------- -->
  <div
    #cboxPh
    (keydown.enter)="addCheckBox(cboxPh,$event)"
    class="cbox-note"
    contenteditable="true"
    spellcheck="true"
  ></div>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

➖ If you take a look at part 1 , you notice that we are looping through checkBoxes array, and because the latter is empty (we didn't push anything there yet), part 1 will render nothing.

➖ About part 2, this part is just an area where the user can type the content of the checkbox, and when he presses the Enter key, addCheckBox() method will be called, and pushes a new object to checkBoxes array

parts

addCheckBox() method :

  addCheckBox(el: HTMLDivElement, $event: Event) {
    $event.preventDefault() // otherwise a line break will be added
    let value = el.innerHTML // we retrieve the entered value
    el.innerHTML = ''
    // we push the entered data
    this.checkBoxes.push({
      done: false,
      data: value,
      id: this.checkBoxes.length
    })
  }
Enter fullscreen mode Exit fullscreen mode

ℹ After pushing a new checkbox this is the result :

parts

✳ Finally, To save this array, we need to add it to noteObj :

  saveNote() {
    let noteObj = {
      //...
      isPinned: this.isPinned,
      isArchived: this.isArchived,
      isDeleted: this.isDeleted,
    }
    // we call another method to save this object on the database or whatever
    this...save(noteObj)
  }
Enter fullscreen mode Exit fullscreen mode

Note : The simple note and the list note share the same title div. This means whether we have a note or a list, we retrieve the title from the same element.


✳ Now, retrieving the background color/image.

I recorded a video showing you how i did it :


And the last part of this problem : Checking if the note is pinned, archived or deleted :

  • Simple, I need to create 3 new variables, isPinned = false, isArchived = false, isDeleted = false

And when the user wants to pin/delete/archive the note, I change the corresponding variable to true

Ex : this.isPinned = true

So, noteObj will look like this :

saveNote() {
  let noteObj = {
    //...
    isPinned: this.isPinned,
    isArchived: this.isArchived,
    isDeleted: this.isDeleted,
    //save to db
    this...save(noteObj)
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ Some useful notes :

➖ If you want to avoid saving empty notes, you need to check if :

  • the note title ain't empty, or the note body ain't empty, or if there's any checkbox
    • if any of the above conditions is truthy, save the note, else do nothing.
  saveNote() {
    let noteObj = {
      //...
    }
    if (noteObj.noteTitle.length || noteObj.noteBody.length || this.checkBoxes.length) {
      // if any of the conditions is truthy, save the note
      this...save(noteObj)
    }
  }
Enter fullscreen mode Exit fullscreen mode

➖ Every time the user creates a note, you need to reset all the variables and entered data to the default values. Otherwise, when you try to create a new note, you find it already filled.

reset() {
  this.noteTitle.nativeElement.innerHTML = ''
  this.checkBoxes = []
  // ....
}
Enter fullscreen mode Exit fullscreen mode

➖ If you want to have a masonry layout for your notes, I advise you to check Bricks.js

🎉 Congratulations. we just finished discussing the logic behind input component.

2️⃣ The note component

--

Problem N°1

Please watch the following video :

Solution to problem N°1

I need to create two containers, one for the pinned notes and the other for the other notes, and inside it, i need to loop through :

  • pinned notes inside the first container
  • other notes inside the second container

This means i also need two arrays one for each container. So when retrieving notes from the database, i need to separate them with the help of filter() :

    pinnedNotes:NoteI[] = []
    otherNotes:NoteI[] = []
    //
    notes() {
      this.Notes.notesList$.subscribe({
        next: (notes: NoteI[]) => {
          this.pinnedNotes = notes.filter(x => x.pinned === true).reverse()
          this.otherNotes = notes.filter(x => x.pinned === false).reverse()
        }
      })
    }
Enter fullscreen mode Exit fullscreen mode

Now, my HTML should look like this :

<section>
  <p>pinned</p>
  <ng-container *ngFor="let note of pinnedNotes">
    <!-- ⚠ notice the property & style binding  -->
    <div
      class="note-main"
      [ngStyle]="{'background-color':note.bgColor,'border-color':note.bgColor,'background-image' : note.bgImage}"
    ></div>
    <div class="note-title">
      <span [innerHTML]="note.noteTitle"></span>
    </div>
    <!-- and so on ... -->
  </ng-container>
</section>

<section>
  <p>other</p>
  <ng-container *ngFor="let note of otherNotes">
    <div
      class="note-main"
      [ngStyle]="{'background-color':note.bgColor,'border-color':note.bgColor,'background-image' : note.bgImage}"
    ></div>
    <div class="note-title">
      <span [innerHTML]="note.noteTitle"></span>
    </div>
    <!-- and so on ... -->
  </ng-container>
</section>
Enter fullscreen mode Exit fullscreen mode

⚠ The code inside first container and the second container is exactly the same, and repeating the same code is not a good practice.
The solution here is to use ngTemplateOutlet with ngTemplateOutletContext.

So, we should have :

<div>
  <p>pinned</p>
  <!-- here we pass pinnedNotes to noteTemplate -->
  <ng-container
    [ngTemplateOutlet]="noteTemplate"
    [ngTemplateOutletContext]="{notes:pinnedNotes}"
  >
  </ng-container>
</div>

<div>
  <p>other</p>
  <!-- and here we pass otherNotes to noteTemplate -->
  <ng-container
    [ngTemplateOutlet]="noteTemplate"
    [ngTemplateOutletContext]="{notes:otherNotes}"
  >
  </ng-container>
</div>
<!-- -- Templates -- -->

<!-- let-notes="notes" ◀ notes array passed from ngTemplateOutletContext -->
<ng-template #noteTemplate let-notes="notes">
  <ng-container *ngFor="let note of notes">
    <ng-container *ngFor="let note of otherNotes">
      <!-- ⚠ notice the property & style binding  -->
      <div
        class="note-main"
        [ngStyle]="{'background-color':note.bgColor,'border-color':note.bgColor,'background-image' : note.bgImage}"
      ></div>
      <div class="note-title">
        <span [innerHTML]="note.noteTitle"></span>
      </div>
      <!-- and so on ... -->
    </ng-container>
  </ng-container>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

✳ So, every time the user adds a new note, pinned or not, it will be rendered on the proper container.


❔ What if the user unpin or pin a saved note ?

notes

ℹ For that, every time the user clicks on the pin icon, you should update the isPinned key of this note on the database :

  togglePin(noteId: number, pinned: boolean) {
    pinned = !pinned
    // call a method that updates your note on your database
    this...updateKey({ isPinned: pinned } , noteId)
  }
Enter fullscreen mode Exit fullscreen mode

And, of course, on every change, you need to refresh pinnedNotes & otherNotes.

(Because i'm using DexieJs, the latter has a method that triggers whenever detects a change)

And the same thing goes for the other properties, like isArchived ...


Problem N°2

Please watch the following video :

Solution problem N°2

According to this video, clicking on a note opens a modal, from this modal we can edit this note. So inside note component, we need to add multiple methods that take care of every action the user can perform, for example :

  • A method for pinning/unpinning a note
  • A method that changes the background color/image
  • A method that saves the note when closed
  • ....

😓 Seems like we are repeating what we have already done on the input component.

✅ The solution here is kinda simple :

Please watch the following video :

ℹ TLDR : Inside input component, we have a variable by the name of isEditing decorated with Input() and set to false by default:

@Input() isEditing = false
Enter fullscreen mode Exit fullscreen mode

Inside notes component, we created a modal, this modal opens every time we click on a note, inside it, we added input component as child and pass isEditing to input component as true.

With the help of *ngIf, input component initiate every time the modal opens.

<div #modalContainer class="modal-container">
  <div #modal class="modal">
    <app-input [isEditing]="true" *ngIf="isModalOpen"></app-input>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

When the modal opens, input component will be initiated, and a method there will be executed. This method changes the values of title, body, background image... which are empty by default to the ones of the clicked note. Its like we are about to create a new note, but the title, body, and background are already filled by some data.
And when it comes to saving the note, and because isEditing is true, we update the note instead of creating a new one.

✳ I guess we finished, so 🎉🎉


Important note : The code & logic I demonstrated on this post are not 100% identical to the ones of the project, for the simple reason that I wanted to simplify every step I explained, so most of the interested readers won't have a bad time.

I still have a feeling that i missed or skipped some parts, but thats okay because you can always find the source code on github.

Github

Top comments (0)